qex 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qex-0.1.1.dist-info/METADATA +121 -0
- qex-0.1.1.dist-info/RECORD +12 -0
- qex-0.1.1.dist-info/WHEEL +5 -0
- qex-0.1.1.dist-info/top_level.txt +1 -0
- qlab/__init__.py +20 -0
- qlab/backend.py +89 -0
- qlab/bloch.py +176 -0
- qlab/demos.py +59 -0
- qlab/experiment.py +45 -0
- qlab/py.typed +0 -0
- qlab/runner.py +106 -0
- qlab/store.py +269 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qex
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A lightweight experiment-runner and lab notebook for quantum computing
|
|
5
|
+
Author: qlab contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/AndreaPallotta/qlab
|
|
8
|
+
Project-URL: Documentation, https://github.com/AndreaPallotta/qlab#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/AndreaPallotta/qlab
|
|
10
|
+
Project-URL: Issues, https://github.com/AndreaPallotta/qlab/issues
|
|
11
|
+
Keywords: quantum,quantum-computing,cirq,experiments,quantum-simulation,bloch-sphere
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: cirq>=1.6.1
|
|
25
|
+
Requires-Dist: numpy>=1.24.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# qlab
|
|
34
|
+
|
|
35
|
+
<div align="center">
|
|
36
|
+
<img src="https://raw.githubusercontent.com/AndreaPallotta/qlab/main/assets/logo.png" alt="qlab logo" width="200"/>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
A lightweight experiment-runner and lab notebook for quantum computing, built on top of Cirq.
|
|
40
|
+
|
|
41
|
+
## Overview
|
|
42
|
+
|
|
43
|
+
`qlab` is designed for running quantum experiments, tracking results, and visualizing outcomes. It focuses on reproducibility, persistence, and visualization rather than being a general-purpose quantum framework.
|
|
44
|
+
|
|
45
|
+
**Key Features:**
|
|
46
|
+
- ๐งช **Experiment Management**: Define parametric quantum circuits and run them systematically
|
|
47
|
+
- ๐พ **Result Persistence**: Store runs, results, and metadata in SQLite
|
|
48
|
+
- ๐ **Visualization**: Automatic Bloch sphere visualization for 1-qubit states
|
|
49
|
+
- ๐ฌ **Reproducibility**: All parameters and results are stored for later analysis
|
|
50
|
+
- ๐ **Simple API**: Minimal, opinionated design focused on experiments
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install qex
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from qlab import CirqBackend, Runner, ResultStore
|
|
62
|
+
from qlab.demos import hadamard_experiment
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
# Setup
|
|
66
|
+
backend = CirqBackend()
|
|
67
|
+
runner = Runner(backend)
|
|
68
|
+
store = ResultStore(Path("qlab_data/qlab.db"))
|
|
69
|
+
|
|
70
|
+
# Run an experiment
|
|
71
|
+
experiment = hadamard_experiment()
|
|
72
|
+
record = runner.run(experiment, params={})
|
|
73
|
+
|
|
74
|
+
# Persist results
|
|
75
|
+
store.save_run(record)
|
|
76
|
+
|
|
77
|
+
# Retrieve and view
|
|
78
|
+
runs = store.list_runs(experiment_name="hadamard")
|
|
79
|
+
rho = runs[0].get_density_matrix()
|
|
80
|
+
print(f"Density matrix:\n{rho}")
|
|
81
|
+
|
|
82
|
+
store.close()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Built-in Experiments
|
|
86
|
+
|
|
87
|
+
`qlab` includes three demo experiments for validation:
|
|
88
|
+
|
|
89
|
+
- **X Gate**: `|0โฉ โ X โ |1โฉ` - Simple bit flip
|
|
90
|
+
- **Hadamard**: `|0โฉ โ H โ superposition` - Creates equal superposition
|
|
91
|
+
- **Ry Sweep**: `|0โฉ โ Ry(ฮธ)` - Rotation around Y-axis with parameter `theta`
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
- **[API Reference](API.md)**: Complete API documentation
|
|
96
|
+
- **[Design Specification](DESIGN.md)**: Architecture and design decisions
|
|
97
|
+
- **[Database Schema](SCHEMA.md)**: SQLite schema documentation
|
|
98
|
+
- **[Bloch Sphere Contract](BLOCH_CONTRACT.md)**: Visualization math and interface
|
|
99
|
+
|
|
100
|
+
## Current Scope (MVP)
|
|
101
|
+
|
|
102
|
+
- โ
1-qubit experiments only
|
|
103
|
+
- โ
Ideal simulation (no noise)
|
|
104
|
+
- โ
Density matrix results
|
|
105
|
+
- โ
SQLite persistence
|
|
106
|
+
- โ
Bloch sphere HTML visualization
|
|
107
|
+
- โ
Cirq-based backend
|
|
108
|
+
|
|
109
|
+
## Requirements
|
|
110
|
+
|
|
111
|
+
- Python >= 3.12
|
|
112
|
+
- Cirq >= 1.6.1
|
|
113
|
+
- NumPy >= 1.24.0
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Apache License 2.0
|
|
118
|
+
|
|
119
|
+
## Contributing
|
|
120
|
+
|
|
121
|
+
Contributions are welcome! Please see the [Design Specification](DESIGN.md) for architecture details and constraints.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
qlab/__init__.py,sha256=47G37bkDpMm9lyMFWKVYyM9ELKJo65apPh41sTQYIV4,477
|
|
2
|
+
qlab/backend.py,sha256=V7TASI-SSCNK9AApvD6d2C1LabB7XB1xNyH-I4h65qI,2383
|
|
3
|
+
qlab/bloch.py,sha256=J34uLDZ07HgfMPs7ANV4aeGLSZB2LUU7QylxyKdrw2E,5903
|
|
4
|
+
qlab/demos.py,sha256=AFNovp2NnhvBftmAkwxNPs--gOQFqX7gglwXsLki6kQ,1704
|
|
5
|
+
qlab/experiment.py,sha256=PjPMb4N8T3oyx0bSyZ5Vl7uKqKh_vp16bpt-9nSTnuE,1458
|
|
6
|
+
qlab/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
qlab/runner.py,sha256=iHk6eGm61eZxlgQmeFKWPCZD0ziLjKh39ZlEijAdgnQ,3535
|
|
8
|
+
qlab/store.py,sha256=GgpkSXf61AS9dQZ01ajHsqGAskF4bEwWqknSZQl_vnA,8837
|
|
9
|
+
qex-0.1.1.dist-info/METADATA,sha256=KdqNuy-sJP4s8QQ6Dd8OvsMcHeK2u95C9Kc6qoAuGwY,3963
|
|
10
|
+
qex-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
11
|
+
qex-0.1.1.dist-info/top_level.txt,sha256=XdHlK9uLEScGqteqlmIiXaMKp12rVhyoXvdhS_TTvdg,5
|
|
12
|
+
qex-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qlab
|
qlab/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qlab: A lightweight experiment-runner and lab notebook for quantum computing.
|
|
3
|
+
|
|
4
|
+
Built on top of Cirq, focused on experiments, runs, reproducibility, and visualization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from qlab.experiment import Experiment
|
|
8
|
+
from qlab.backend import Backend, CirqBackend
|
|
9
|
+
from qlab.runner import Runner
|
|
10
|
+
from qlab.store import ResultStore, RunRecord
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Experiment",
|
|
15
|
+
"Backend",
|
|
16
|
+
"CirqBackend",
|
|
17
|
+
"Runner",
|
|
18
|
+
"ResultStore",
|
|
19
|
+
"RunRecord",
|
|
20
|
+
]
|
qlab/backend.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend abstraction: interface for executing quantum circuits.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
import cirq
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Backend(ABC):
|
|
12
|
+
"""
|
|
13
|
+
Abstract interface for executing quantum circuits.
|
|
14
|
+
|
|
15
|
+
A backend executes a circuit and returns the final density matrix.
|
|
16
|
+
For MVP, all backends must support 1-qubit circuits only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def run(self, circuit: cirq.Circuit) -> np.ndarray:
|
|
21
|
+
"""
|
|
22
|
+
Execute a circuit and return the final density matrix.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
circuit: A Cirq Circuit (must be 1-qubit for MVP).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A 2x2 density matrix as a numpy array (complex dtype).
|
|
29
|
+
"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def get_name(self) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Get the name/identifier of this backend.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Backend name string.
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CirqBackend(Backend):
|
|
44
|
+
"""
|
|
45
|
+
Concrete backend using Cirq's ideal simulator.
|
|
46
|
+
|
|
47
|
+
Uses Cirq's Simulator for ideal (noiseless) simulation.
|
|
48
|
+
Results are returned as density matrices derived from statevectors.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the Cirq ideal simulator backend.
|
|
54
|
+
"""
|
|
55
|
+
self._simulator = cirq.Simulator()
|
|
56
|
+
|
|
57
|
+
def run(self, circuit: cirq.Circuit) -> np.ndarray:
|
|
58
|
+
"""
|
|
59
|
+
Execute circuit on ideal Cirq simulator and return density matrix.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
circuit: A Cirq Circuit (must be 1-qubit for MVP).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
2x2 density matrix (complex dtype).
|
|
66
|
+
"""
|
|
67
|
+
# Validate 1-qubit constraint
|
|
68
|
+
qubits = circuit.all_qubits()
|
|
69
|
+
if len(qubits) != 1:
|
|
70
|
+
raise ValueError(f"Circuit must have exactly 1 qubit, got {len(qubits)}")
|
|
71
|
+
|
|
72
|
+
# Run simulation to get final state
|
|
73
|
+
result = self._simulator.simulate(circuit)
|
|
74
|
+
statevector = result.final_state_vector
|
|
75
|
+
|
|
76
|
+
# Convert statevector to density matrix: |ฯโฉโจฯ|
|
|
77
|
+
# For 1 qubit, statevector is length 2
|
|
78
|
+
rho = np.outer(statevector, np.conj(statevector))
|
|
79
|
+
|
|
80
|
+
return rho
|
|
81
|
+
|
|
82
|
+
def get_name(self) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Get backend name.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
"cirq_ideal"
|
|
88
|
+
"""
|
|
89
|
+
return "cirq_ideal"
|
qlab/bloch.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bloch sphere visualization: density matrix โ (x,y,z) coordinates โ HTML artifact.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def density_matrix_to_bloch(rho: np.ndarray) -> Tuple[float, float, float]:
|
|
10
|
+
"""
|
|
11
|
+
Convert a 1-qubit density matrix to Bloch sphere coordinates (x, y, z).
|
|
12
|
+
|
|
13
|
+
For a density matrix ฯ, the Bloch vector is computed as:
|
|
14
|
+
x = Tr(ฯ ยท ฯ_x)
|
|
15
|
+
y = Tr(ฯ ยท ฯ_y)
|
|
16
|
+
z = Tr(ฯ ยท ฯ_z)
|
|
17
|
+
|
|
18
|
+
where ฯ_x, ฯ_y, ฯ_z are the Pauli matrices.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
rho: 2x2 density matrix (complex dtype).
|
|
22
|
+
Must be a valid density matrix (Hermitian, trace=1, positive semidefinite).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Tuple of (x, y, z) coordinates as real floats.
|
|
26
|
+
Coordinates are guaranteed to satisfy xยฒ + yยฒ + zยฒ โค 1.
|
|
27
|
+
"""
|
|
28
|
+
# Pauli matrices
|
|
29
|
+
sigma_x = np.array([[0, 1], [1, 0]], dtype=complex)
|
|
30
|
+
sigma_y = np.array([[0, -1j], [1j, 0]], dtype=complex)
|
|
31
|
+
sigma_z = np.array([[1, 0], [0, -1]], dtype=complex)
|
|
32
|
+
|
|
33
|
+
# Compute Bloch coordinates: Tr(ฯ ยท ฯ_i)
|
|
34
|
+
x = np.real(np.trace(rho @ sigma_x))
|
|
35
|
+
y = np.real(np.trace(rho @ sigma_y))
|
|
36
|
+
z = np.real(np.trace(rho @ sigma_z))
|
|
37
|
+
|
|
38
|
+
return (float(x), float(y), float(z))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bloch_to_html(x: float, y: float, z: float, title: str = "Bloch Sphere") -> str:
|
|
42
|
+
"""
|
|
43
|
+
Generate an HTML artifact visualizing a point on the Bloch sphere.
|
|
44
|
+
|
|
45
|
+
The HTML should:
|
|
46
|
+
- Render a 3D Bloch sphere
|
|
47
|
+
- Mark the point (x, y, z) on the sphere
|
|
48
|
+
- Display the coordinates
|
|
49
|
+
- Be self-contained (no external dependencies, or use CDN for 3D library)
|
|
50
|
+
- Be viewable in a browser
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
x: X coordinate on Bloch sphere.
|
|
54
|
+
y: Y coordinate on Bloch sphere.
|
|
55
|
+
z: Z coordinate on Bloch sphere.
|
|
56
|
+
title: Optional title for the visualization.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Complete HTML string that can be saved to a file and opened in a browser.
|
|
60
|
+
"""
|
|
61
|
+
# Use Three.js via CDN for 3D visualization
|
|
62
|
+
html = f"""<!DOCTYPE html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="UTF-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
67
|
+
<title>{title}</title>
|
|
68
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
69
|
+
<style>
|
|
70
|
+
body {{
|
|
71
|
+
margin: 0;
|
|
72
|
+
font-family: Arial, sans-serif;
|
|
73
|
+
background: #1a1a1a;
|
|
74
|
+
color: #fff;
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
align-items: center;
|
|
78
|
+
padding: 20px;
|
|
79
|
+
}}
|
|
80
|
+
#container {{
|
|
81
|
+
width: 800px;
|
|
82
|
+
height: 600px;
|
|
83
|
+
border: 2px solid #444;
|
|
84
|
+
border-radius: 8px;
|
|
85
|
+
margin: 20px 0;
|
|
86
|
+
}}
|
|
87
|
+
#info {{
|
|
88
|
+
text-align: center;
|
|
89
|
+
margin: 10px 0;
|
|
90
|
+
}}
|
|
91
|
+
.coord {{
|
|
92
|
+
display: inline-block;
|
|
93
|
+
margin: 0 15px;
|
|
94
|
+
font-family: monospace;
|
|
95
|
+
}}
|
|
96
|
+
</style>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<h1>{title}</h1>
|
|
100
|
+
<div id="container"></div>
|
|
101
|
+
<div id="info">
|
|
102
|
+
<div class="coord">x = {x:.4f}</div>
|
|
103
|
+
<div class="coord">y = {y:.4f}</div>
|
|
104
|
+
<div class="coord">z = {z:.4f}</div>
|
|
105
|
+
</div>
|
|
106
|
+
<script>
|
|
107
|
+
// Scene setup
|
|
108
|
+
const scene = new THREE.Scene();
|
|
109
|
+
const camera = new THREE.PerspectiveCamera(75, 800/600, 0.1, 1000);
|
|
110
|
+
const renderer = new THREE.WebGLRenderer({{ antialias: true }});
|
|
111
|
+
renderer.setSize(800, 600);
|
|
112
|
+
document.getElementById('container').appendChild(renderer.domElement);
|
|
113
|
+
|
|
114
|
+
// Lighting
|
|
115
|
+
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
|
|
116
|
+
scene.add(ambientLight);
|
|
117
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
118
|
+
directionalLight.position.set(5, 5, 5);
|
|
119
|
+
scene.add(directionalLight);
|
|
120
|
+
|
|
121
|
+
// Bloch sphere (unit sphere)
|
|
122
|
+
const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);
|
|
123
|
+
const sphereMaterial = new THREE.MeshPhongMaterial({{
|
|
124
|
+
color: 0x4a90e2,
|
|
125
|
+
transparent: true,
|
|
126
|
+
opacity: 0.3,
|
|
127
|
+
side: THREE.DoubleSide,
|
|
128
|
+
wireframe: false
|
|
129
|
+
}});
|
|
130
|
+
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
|
|
131
|
+
scene.add(sphere);
|
|
132
|
+
|
|
133
|
+
// Wireframe overlay
|
|
134
|
+
const wireframe = new THREE.WireframeGeometry(sphereGeometry);
|
|
135
|
+
const wireframeLine = new THREE.LineSegments(wireframe, new THREE.LineBasicMaterial({{ color: 0xffffff, opacity: 0.2 }}));
|
|
136
|
+
scene.add(wireframeLine);
|
|
137
|
+
|
|
138
|
+
// Axes
|
|
139
|
+
const axesHelper = new THREE.AxesHelper(1.2);
|
|
140
|
+
scene.add(axesHelper);
|
|
141
|
+
|
|
142
|
+
// Point marker
|
|
143
|
+
const pointGeometry = new THREE.SphereGeometry(0.05, 16, 16);
|
|
144
|
+
const pointMaterial = new THREE.MeshPhongMaterial({{ color: 0xff4444 }});
|
|
145
|
+
const point = new THREE.Mesh(pointGeometry, pointMaterial);
|
|
146
|
+
point.position.set({x}, {y}, {z});
|
|
147
|
+
scene.add(point);
|
|
148
|
+
|
|
149
|
+
// Line from origin to point
|
|
150
|
+
const lineGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
151
|
+
new THREE.Vector3(0, 0, 0),
|
|
152
|
+
new THREE.Vector3({x}, {y}, {z})
|
|
153
|
+
]);
|
|
154
|
+
const lineMaterial = new THREE.LineBasicMaterial({{ color: 0xff4444, linewidth: 2 }});
|
|
155
|
+
const line = new THREE.Line(lineGeometry, lineMaterial);
|
|
156
|
+
scene.add(line);
|
|
157
|
+
|
|
158
|
+
// Camera position
|
|
159
|
+
camera.position.set(2.5, 2.5, 2.5);
|
|
160
|
+
camera.lookAt(0, 0, 0);
|
|
161
|
+
|
|
162
|
+
// Animation loop
|
|
163
|
+
let angle = 0;
|
|
164
|
+
function animate() {{
|
|
165
|
+
requestAnimationFrame(animate);
|
|
166
|
+
angle += 0.01;
|
|
167
|
+
camera.position.x = 2.5 * Math.cos(angle);
|
|
168
|
+
camera.position.z = 2.5 * Math.sin(angle);
|
|
169
|
+
camera.lookAt(0, 0, 0);
|
|
170
|
+
renderer.render(scene, camera);
|
|
171
|
+
}}
|
|
172
|
+
animate();
|
|
173
|
+
</script>
|
|
174
|
+
</body>
|
|
175
|
+
</html>"""
|
|
176
|
+
return html
|
qlab/demos.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in demo experiments for validation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Any
|
|
6
|
+
import cirq
|
|
7
|
+
from qlab.experiment import Experiment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def x_gate_experiment() -> Experiment:
|
|
11
|
+
"""
|
|
12
|
+
Demo: |0โฉ โ X โ |1โฉ
|
|
13
|
+
|
|
14
|
+
Simple X gate that flips |0โฉ to |1โฉ.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Experiment with no parameters.
|
|
18
|
+
"""
|
|
19
|
+
def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
|
|
20
|
+
"""Build circuit: X gate on qubit."""
|
|
21
|
+
return cirq.Circuit(cirq.X(qubit))
|
|
22
|
+
|
|
23
|
+
return Experiment(name="x_gate", builder=builder)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hadamard_experiment() -> Experiment:
|
|
27
|
+
"""
|
|
28
|
+
Demo: |0โฉ โ H โ superposition
|
|
29
|
+
|
|
30
|
+
Hadamard gate creating equal superposition |+โฉ = (|0โฉ + |1โฉ)/โ2.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Experiment with no parameters.
|
|
34
|
+
"""
|
|
35
|
+
def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
|
|
36
|
+
"""Build circuit: Hadamard gate on qubit."""
|
|
37
|
+
return cirq.Circuit(cirq.H(qubit))
|
|
38
|
+
|
|
39
|
+
return Experiment(name="hadamard", builder=builder)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ry_sweep_experiment() -> Experiment:
|
|
43
|
+
"""
|
|
44
|
+
Demo: |0โฉ โ Ry(ฮธ) sweep
|
|
45
|
+
|
|
46
|
+
Rotation around Y-axis with parameter ฮธ.
|
|
47
|
+
This experiment expects params = {"theta": float}.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Experiment that takes "theta" parameter (in radians).
|
|
51
|
+
"""
|
|
52
|
+
def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
|
|
53
|
+
"""Build circuit: Ry(theta) rotation on qubit."""
|
|
54
|
+
theta = params.get("theta", 0.0)
|
|
55
|
+
if not isinstance(theta, (int, float)):
|
|
56
|
+
raise ValueError(f"theta must be a number, got {type(theta)}")
|
|
57
|
+
return cirq.Circuit(cirq.ry(theta)(qubit))
|
|
58
|
+
|
|
59
|
+
return Experiment(name="ry_sweep", builder=builder)
|
qlab/experiment.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Experiment abstraction: parametric circuit builder.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Dict, Any
|
|
6
|
+
import cirq
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Experiment:
|
|
10
|
+
"""
|
|
11
|
+
An experiment defines a parametric quantum circuit builder.
|
|
12
|
+
|
|
13
|
+
An experiment has a name and a function that builds a Cirq circuit
|
|
14
|
+
from parameters. The circuit must be 1-qubit for MVP.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: Unique identifier for the experiment.
|
|
18
|
+
builder: Function that takes parameters and returns a Cirq Circuit.
|
|
19
|
+
Must accept a single qubit as first argument.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, name: str, builder: Callable[[cirq.Qid, Dict[str, Any]], cirq.Circuit]):
|
|
23
|
+
"""
|
|
24
|
+
Initialize an experiment.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Unique name for the experiment.
|
|
28
|
+
builder: Function signature: (qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit
|
|
29
|
+
The builder must create a 1-qubit circuit.
|
|
30
|
+
"""
|
|
31
|
+
self.name = name
|
|
32
|
+
self.builder = builder
|
|
33
|
+
|
|
34
|
+
def build_circuit(self, qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
|
|
35
|
+
"""
|
|
36
|
+
Build the circuit for this experiment with given parameters.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
qubit: The single qubit to operate on.
|
|
40
|
+
params: Parameter dictionary for the experiment.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A Cirq Circuit operating on the given qubit.
|
|
44
|
+
"""
|
|
45
|
+
return self.builder(qubit, params)
|
qlab/py.typed
ADDED
|
File without changes
|
qlab/runner.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runner: executes experiments with parameters and configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import cirq
|
|
10
|
+
import numpy as np
|
|
11
|
+
from qlab.experiment import Experiment
|
|
12
|
+
from qlab.backend import Backend
|
|
13
|
+
from qlab.store import RunRecord # type: ignore
|
|
14
|
+
from qlab.bloch import density_matrix_to_bloch, bloch_to_html
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Runner:
|
|
18
|
+
"""
|
|
19
|
+
Executes an experiment with given parameters and backend configuration.
|
|
20
|
+
|
|
21
|
+
The runner coordinates experiment execution, result computation,
|
|
22
|
+
and artifact generation. It does not handle persistence (that's ResultStore's job).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, backend: Backend, base_dir: Optional[Path] = None):
|
|
26
|
+
"""
|
|
27
|
+
Initialize a runner with a backend.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
backend: The backend to use for circuit execution.
|
|
31
|
+
base_dir: Base directory for storing results and artifacts.
|
|
32
|
+
If None, defaults to current directory.
|
|
33
|
+
"""
|
|
34
|
+
self.backend = backend
|
|
35
|
+
self.base_dir = Path(base_dir) if base_dir else Path.cwd()
|
|
36
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
(self.base_dir / "results").mkdir(exist_ok=True)
|
|
38
|
+
(self.base_dir / "artifacts").mkdir(exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def run(
|
|
41
|
+
self,
|
|
42
|
+
experiment: Experiment,
|
|
43
|
+
params: Dict[str, Any],
|
|
44
|
+
config: Optional[Dict[str, Any]] = None
|
|
45
|
+
) -> RunRecord:
|
|
46
|
+
"""
|
|
47
|
+
Execute an experiment and return a run record.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
experiment: The experiment to run.
|
|
51
|
+
params: Parameters for the experiment's circuit builder.
|
|
52
|
+
config: Optional configuration (e.g., qubit selection, metadata).
|
|
53
|
+
Defaults to using a single qubit.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
RunRecord containing:
|
|
57
|
+
- Experiment name and parameters
|
|
58
|
+
- Density matrix result
|
|
59
|
+
- Generated artifacts (e.g., Bloch sphere HTML)
|
|
60
|
+
- Metadata (timestamp, backend name, etc.)
|
|
61
|
+
"""
|
|
62
|
+
config = config or {}
|
|
63
|
+
|
|
64
|
+
# Generate run ID
|
|
65
|
+
run_id = str(uuid.uuid4())
|
|
66
|
+
timestamp = time.time()
|
|
67
|
+
|
|
68
|
+
# Create qubit (default to GridQubit(0, 0) if not specified)
|
|
69
|
+
qubit = config.get("qubit", cirq.GridQubit(0, 0))
|
|
70
|
+
|
|
71
|
+
# Build circuit
|
|
72
|
+
circuit = experiment.build_circuit(qubit, params)
|
|
73
|
+
|
|
74
|
+
# Execute circuit
|
|
75
|
+
rho = self.backend.run(circuit)
|
|
76
|
+
|
|
77
|
+
# Save density matrix
|
|
78
|
+
rho_path = f"results/{run_id}_rho.npy"
|
|
79
|
+
np.save(self.base_dir / rho_path, rho)
|
|
80
|
+
|
|
81
|
+
# Generate Bloch sphere visualization
|
|
82
|
+
x, y, z = density_matrix_to_bloch(rho)
|
|
83
|
+
html_content = bloch_to_html(x, y, z, title=f"{experiment.name} - {run_id[:8]}")
|
|
84
|
+
html_path = f"artifacts/{run_id}_bloch.html"
|
|
85
|
+
(self.base_dir / html_path).write_text(html_content)
|
|
86
|
+
|
|
87
|
+
# Create artifacts dict
|
|
88
|
+
artifacts = {"bloch_sphere": html_path}
|
|
89
|
+
|
|
90
|
+
# Build metadata
|
|
91
|
+
metadata = config.get("metadata", {})
|
|
92
|
+
metadata["qubit"] = str(qubit)
|
|
93
|
+
|
|
94
|
+
# Create RunRecord
|
|
95
|
+
record = RunRecord(
|
|
96
|
+
run_id=run_id,
|
|
97
|
+
experiment_name=experiment.name,
|
|
98
|
+
params=params,
|
|
99
|
+
backend_name=self.backend.get_name(),
|
|
100
|
+
timestamp=timestamp,
|
|
101
|
+
density_matrix_path=rho_path,
|
|
102
|
+
artifacts=artifacts,
|
|
103
|
+
metadata=metadata
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return record
|
qlab/store.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ResultStore: SQLite persistence for runs, results, and artifacts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import json
|
|
8
|
+
import sqlite3
|
|
9
|
+
import numpy as np
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunRecord:
|
|
14
|
+
"""
|
|
15
|
+
Metadata and references for a single experiment run.
|
|
16
|
+
|
|
17
|
+
Contains:
|
|
18
|
+
- Run ID (UUID or auto-increment)
|
|
19
|
+
- Experiment name
|
|
20
|
+
- Parameters dictionary (JSON-serializable)
|
|
21
|
+
- Backend name
|
|
22
|
+
- Timestamp
|
|
23
|
+
- Path to density matrix file (numpy .npy)
|
|
24
|
+
- Path to artifacts (e.g., Bloch sphere HTML)
|
|
25
|
+
- Additional metadata
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
run_id: str,
|
|
31
|
+
experiment_name: str,
|
|
32
|
+
params: Dict[str, Any],
|
|
33
|
+
backend_name: str,
|
|
34
|
+
timestamp: float,
|
|
35
|
+
density_matrix_path: str,
|
|
36
|
+
artifacts: Dict[str, str], # artifact_name -> file_path
|
|
37
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Initialize a run record.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
run_id: Unique identifier for this run.
|
|
44
|
+
experiment_name: Name of the experiment.
|
|
45
|
+
params: Parameters used for this run.
|
|
46
|
+
backend_name: Name of the backend used.
|
|
47
|
+
timestamp: Unix timestamp of when run was executed.
|
|
48
|
+
density_matrix_path: Path to saved density matrix (.npy file).
|
|
49
|
+
artifacts: Dictionary mapping artifact names to file paths.
|
|
50
|
+
metadata: Optional additional metadata.
|
|
51
|
+
"""
|
|
52
|
+
self.run_id = run_id
|
|
53
|
+
self.experiment_name = experiment_name
|
|
54
|
+
self.params = params
|
|
55
|
+
self.backend_name = backend_name
|
|
56
|
+
self.timestamp = timestamp
|
|
57
|
+
self.density_matrix_path = density_matrix_path
|
|
58
|
+
self.artifacts = artifacts
|
|
59
|
+
self.metadata = metadata or {}
|
|
60
|
+
self._base_dir: Optional[Path] = None # Set by ResultStore when loading
|
|
61
|
+
|
|
62
|
+
def set_base_dir(self, base_dir: Path) -> None:
|
|
63
|
+
"""Set the base directory for resolving relative paths."""
|
|
64
|
+
self._base_dir = base_dir
|
|
65
|
+
|
|
66
|
+
def get_density_matrix(self) -> np.ndarray:
|
|
67
|
+
"""
|
|
68
|
+
Load and return the density matrix for this run.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
2x2 density matrix (complex dtype).
|
|
72
|
+
"""
|
|
73
|
+
if self._base_dir is None:
|
|
74
|
+
raise ValueError("Base directory not set. Use ResultStore.get_run() to load records.")
|
|
75
|
+
full_path = self._base_dir / self.density_matrix_path
|
|
76
|
+
return np.load(full_path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ResultStore:
|
|
80
|
+
"""
|
|
81
|
+
SQLite-based persistence layer for experiment runs.
|
|
82
|
+
|
|
83
|
+
Stores run metadata, density matrices, and artifact references.
|
|
84
|
+
Provides query interface for retrieving runs.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, db_path: Path):
|
|
88
|
+
"""
|
|
89
|
+
Initialize the result store.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
db_path: Path to SQLite database file.
|
|
93
|
+
If file doesn't exist, it will be created with schema.
|
|
94
|
+
"""
|
|
95
|
+
self.db_path = Path(db_path).resolve()
|
|
96
|
+
self.base_dir = self.db_path.parent
|
|
97
|
+
self.conn = sqlite3.connect(str(self.db_path))
|
|
98
|
+
self.conn.row_factory = sqlite3.Row
|
|
99
|
+
self._initialize_schema()
|
|
100
|
+
self._ensure_directories()
|
|
101
|
+
|
|
102
|
+
def _initialize_schema(self) -> None:
|
|
103
|
+
"""Create database tables if they don't exist."""
|
|
104
|
+
cursor = self.conn.cursor()
|
|
105
|
+
|
|
106
|
+
# Create runs table
|
|
107
|
+
cursor.execute("""
|
|
108
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
109
|
+
run_id TEXT PRIMARY KEY,
|
|
110
|
+
experiment_name TEXT NOT NULL,
|
|
111
|
+
params TEXT NOT NULL,
|
|
112
|
+
backend_name TEXT NOT NULL,
|
|
113
|
+
timestamp REAL NOT NULL,
|
|
114
|
+
density_matrix_path TEXT NOT NULL,
|
|
115
|
+
metadata TEXT
|
|
116
|
+
)
|
|
117
|
+
""")
|
|
118
|
+
|
|
119
|
+
# Create artifacts table
|
|
120
|
+
cursor.execute("""
|
|
121
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
122
|
+
artifact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
run_id TEXT NOT NULL,
|
|
124
|
+
artifact_name TEXT NOT NULL,
|
|
125
|
+
artifact_path TEXT NOT NULL,
|
|
126
|
+
artifact_type TEXT NOT NULL,
|
|
127
|
+
FOREIGN KEY (run_id) REFERENCES runs(run_id)
|
|
128
|
+
)
|
|
129
|
+
""")
|
|
130
|
+
|
|
131
|
+
# Create indexes
|
|
132
|
+
cursor.execute("""
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_runs_experiment_name
|
|
134
|
+
ON runs(experiment_name)
|
|
135
|
+
""")
|
|
136
|
+
cursor.execute("""
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_runs_timestamp
|
|
138
|
+
ON runs(timestamp)
|
|
139
|
+
""")
|
|
140
|
+
cursor.execute("""
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_run_id
|
|
142
|
+
ON artifacts(run_id)
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
self.conn.commit()
|
|
146
|
+
|
|
147
|
+
def _ensure_directories(self) -> None:
|
|
148
|
+
"""Create results and artifacts directories if they don't exist."""
|
|
149
|
+
(self.base_dir / "results").mkdir(parents=True, exist_ok=True)
|
|
150
|
+
(self.base_dir / "artifacts").mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
def save_run(self, record: RunRecord) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Persist a run record to the database.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
record: The RunRecord to save.
|
|
158
|
+
"""
|
|
159
|
+
cursor = self.conn.cursor()
|
|
160
|
+
|
|
161
|
+
# Save run metadata
|
|
162
|
+
cursor.execute("""
|
|
163
|
+
INSERT INTO runs (run_id, experiment_name, params, backend_name,
|
|
164
|
+
timestamp, density_matrix_path, metadata)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
166
|
+
""", (
|
|
167
|
+
record.run_id,
|
|
168
|
+
record.experiment_name,
|
|
169
|
+
json.dumps(record.params),
|
|
170
|
+
record.backend_name,
|
|
171
|
+
record.timestamp,
|
|
172
|
+
record.density_matrix_path,
|
|
173
|
+
json.dumps(record.metadata) if record.metadata else None
|
|
174
|
+
))
|
|
175
|
+
|
|
176
|
+
# Save artifacts
|
|
177
|
+
for artifact_name, artifact_path in record.artifacts.items():
|
|
178
|
+
# Determine artifact type from extension
|
|
179
|
+
artifact_type = "text/html" if artifact_path.endswith(".html") else "application/octet-stream"
|
|
180
|
+
cursor.execute("""
|
|
181
|
+
INSERT INTO artifacts (run_id, artifact_name, artifact_path, artifact_type)
|
|
182
|
+
VALUES (?, ?, ?, ?)
|
|
183
|
+
""", (record.run_id, artifact_name, artifact_path, artifact_type))
|
|
184
|
+
|
|
185
|
+
self.conn.commit()
|
|
186
|
+
|
|
187
|
+
def get_run(self, run_id: str) -> Optional[RunRecord]:
|
|
188
|
+
"""
|
|
189
|
+
Retrieve a run record by ID.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
run_id: The run ID to look up.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
RunRecord if found, None otherwise.
|
|
196
|
+
"""
|
|
197
|
+
cursor = self.conn.cursor()
|
|
198
|
+
|
|
199
|
+
# Get run metadata
|
|
200
|
+
cursor.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,))
|
|
201
|
+
row = cursor.fetchone()
|
|
202
|
+
if row is None:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# Get artifacts
|
|
206
|
+
cursor.execute("""
|
|
207
|
+
SELECT artifact_name, artifact_path
|
|
208
|
+
FROM artifacts
|
|
209
|
+
WHERE run_id = ?
|
|
210
|
+
""", (run_id,))
|
|
211
|
+
artifacts = {row["artifact_name"]: row["artifact_path"] for row in cursor.fetchall()}
|
|
212
|
+
|
|
213
|
+
# Build RunRecord
|
|
214
|
+
record = RunRecord(
|
|
215
|
+
run_id=row["run_id"],
|
|
216
|
+
experiment_name=row["experiment_name"],
|
|
217
|
+
params=json.loads(row["params"]),
|
|
218
|
+
backend_name=row["backend_name"],
|
|
219
|
+
timestamp=row["timestamp"],
|
|
220
|
+
density_matrix_path=row["density_matrix_path"],
|
|
221
|
+
artifacts=artifacts,
|
|
222
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else None
|
|
223
|
+
)
|
|
224
|
+
record.set_base_dir(self.base_dir)
|
|
225
|
+
return record
|
|
226
|
+
|
|
227
|
+
def list_runs(
|
|
228
|
+
self,
|
|
229
|
+
experiment_name: Optional[str] = None,
|
|
230
|
+
limit: Optional[int] = None
|
|
231
|
+
) -> List[RunRecord]:
|
|
232
|
+
"""
|
|
233
|
+
List run records, optionally filtered by experiment name.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
experiment_name: Optional filter by experiment name.
|
|
237
|
+
limit: Optional maximum number of records to return.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of RunRecord objects, ordered by timestamp (newest first).
|
|
241
|
+
"""
|
|
242
|
+
cursor = self.conn.cursor()
|
|
243
|
+
|
|
244
|
+
query = "SELECT run_id FROM runs"
|
|
245
|
+
params = []
|
|
246
|
+
if experiment_name:
|
|
247
|
+
query += " WHERE experiment_name = ?"
|
|
248
|
+
params.append(experiment_name)
|
|
249
|
+
query += " ORDER BY timestamp DESC"
|
|
250
|
+
if limit:
|
|
251
|
+
query += " LIMIT ?"
|
|
252
|
+
params.append(limit)
|
|
253
|
+
|
|
254
|
+
cursor.execute(query, params)
|
|
255
|
+
run_ids = [row["run_id"] for row in cursor.fetchall()]
|
|
256
|
+
|
|
257
|
+
records = []
|
|
258
|
+
for run_id in run_ids:
|
|
259
|
+
record = self.get_run(run_id)
|
|
260
|
+
if record:
|
|
261
|
+
records.append(record)
|
|
262
|
+
|
|
263
|
+
return records
|
|
264
|
+
|
|
265
|
+
def close(self) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Close the database connection.
|
|
268
|
+
"""
|
|
269
|
+
self.conn.close()
|