diff --git a/pyproject.toml b/pyproject.toml index 00f8515..547719d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic", "nicegui", "flask", + "oqd-core", ] diff --git a/src/oqd_teaching_demo/analog.py b/src/oqd_teaching_demo/analog.py index 1edd1a6..6741780 100644 --- a/src/oqd_teaching_demo/analog.py +++ b/src/oqd_teaching_demo/analog.py @@ -1,37 +1,47 @@ -# Copyright 2024 OPEN QUANTUM DESIGN -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Copyright 2024-2025 Open Quantum Design -from src.base import TypeReflectBaseModel +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 -class MathExpr(TypeReflectBaseModel): - pass +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Literal +from pydantic import BaseModel, Field -class Linear(MathExpr): - max: float = 0.0 - min: float = 1.0 - duration: float = 1.0 # seconds +class AmplitudeEnvelope(BaseModel): + kind: Literal["constant", "linear", "sinusoidal", "expression"] = "constant" + # constant + value: float = 1.0 + # linear + start: float = 0.0 + end: float = 1.0 + # sinusoidal + frequency: float = 1.0 + phase: float = 0.0 + # expression (free-text math expression using variable 't') + expression: str = "1.0" -class ExponentialDecay(MathExpr): - max: float = 0.0 - min: float = 1.0 - duration: float = 1.0 # seconds +class HamiltonianTerm(BaseModel): + pauli: Literal["I", "X", "Y", "Z"] = "X" + ion: int = 0 + coefficient: float = 1.0 + envelope: AmplitudeEnvelope = Field(default_factory=AmplitudeEnvelope) -class Sinusoidal(MathExpr): - max: float = 0.0 - min: float = 1.0 - duration: float = 1.0 # seconds + +class EvolveStep(BaseModel): + terms: list[HamiltonianTerm] = Field(default_factory=lambda: [HamiltonianTerm()]) + duration: float = 1.0 + + +class AnalogProgramSpec(BaseModel): + n_ions: int = 4 + steps: list[EvolveStep] = Field(default_factory=lambda: [EvolveStep()]) diff --git a/src/oqd_teaching_demo/analog_compiler.py b/src/oqd_teaching_demo/analog_compiler.py new file mode 100644 index 0000000..0b7fac3 --- /dev/null +++ b/src/oqd_teaching_demo/analog_compiler.py @@ -0,0 +1,81 @@ +# Copyright 2024-2025 Open Quantum Design + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import numpy as np + +from oqd_teaching_demo.analog import AnalogProgramSpec, AmplitudeEnvelope +from oqd_teaching_demo.program import Program + +SAFE_MATH_NAMESPACE = { + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "exp": math.exp, + "log": math.log, + "sqrt": math.sqrt, + "abs": abs, + "pi": math.pi, + "e": math.e, +} + + +def evaluate_envelope(envelope: AmplitudeEnvelope, t: float, duration: float) -> float: + if envelope.kind == "constant": + return envelope.value + + elif envelope.kind == "linear": + if duration > 0: + frac = t / duration + else: + frac = 1.0 + return envelope.start + (envelope.end - envelope.start) * frac + + elif envelope.kind == "sinusoidal": + return math.sin(envelope.frequency * t + envelope.phase) + + elif envelope.kind == "expression": + namespace = {**SAFE_MATH_NAMESPACE, "t": t} + try: + return float(eval(envelope.expression, {"__builtins__": {}}, namespace)) + except Exception: + return 0.0 + + return 1.0 + + +def compile_to_program(spec: AnalogProgramSpec, dt: float = 0.1) -> Program: + all_intensities = [] + + for step in spec.steps: + n_steps = max(1, int(step.duration / dt)) + t_array = np.linspace(0, step.duration, n_steps, endpoint=False) + + for t in t_array: + ion_intensity = [0.0] * spec.n_ions + for term in step.terms: + if term.pauli == "I": + continue + amp = abs(term.coefficient * evaluate_envelope(term.envelope, t, step.duration)) + idx = min(term.ion, spec.n_ions - 1) + ion_intensity[idx] += amp + + ion_intensity = [max(0.0, min(1.0, v)) for v in ion_intensity] + all_intensities.append(ion_intensity) + + if not all_intensities: + all_intensities = [[0.0] * spec.n_ions] + + return Program(red_lasers_intensity=all_intensities, dt=dt) diff --git a/src/oqd_teaching_demo/gui/analog_builder.py b/src/oqd_teaching_demo/gui/analog_builder.py new file mode 100644 index 0000000..9bd4f34 --- /dev/null +++ b/src/oqd_teaching_demo/gui/analog_builder.py @@ -0,0 +1,297 @@ +# Copyright 2024-2025 Open Quantum Design + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import threading + +from nicegui import ui + +from oqd_teaching_demo.analog import ( + AnalogProgramSpec, + EvolveStep, + HamiltonianTerm, + AmplitudeEnvelope, +) +from oqd_teaching_demo.ir_builder import build_analog_circuit +from oqd_teaching_demo.analog_compiler import compile_to_program +from oqd_teaching_demo.gui.programs import ( + preset_rabi_flopping, + preset_ising, +) + + +def analog_builder_card(board, stream_ip: str): + spec = AnalogProgramSpec() + + steps_container = None + json_output = None + run_thread = None + + def rebuild_steps_ui(): + nonlocal steps_container + if steps_container is None: + return + steps_container.clear() + with steps_container: + _render_ion_chain() + for si, step in enumerate(spec.steps): + _render_step(si, step) + ui.button("+ Add Evolution Step", on_click=add_step).props("flat color=primary") + + def _render_ion_chain(): + with ui.row().classes("items-center gap-1 mb-2"): + ui.label("Ion Chain:").classes("text-sm font-bold") + for i in range(spec.n_ions): + if i > 0: + ui.label("\u2014").classes("text-xs text-grey") + ui.badge(str(i), color="primary").props("rounded") + ui.number( + "Ions", + value=spec.n_ions, + min=1, + max=8, + step=1, + on_change=lambda e: _set_n_ions(e.value), + ).classes("w-20 ml-4").props("dense") + + def _set_n_ions(val): + if val is None: + return + spec.n_ions = int(val) + for step in spec.steps: + for term in step.terms: + if term.ion >= spec.n_ions: + term.ion = spec.n_ions - 1 + rebuild_steps_ui() + + def _render_step(si, step): + with ui.card().classes("w-full mb-2"): + with ui.row().classes("items-center w-full"): + ui.label(f"Step {si + 1}").classes("text-sm font-bold") + ui.number( + "Duration (s)", + value=step.duration, + min=0.01, + step=0.1, + format="%.2f", + on_change=lambda e, si=si: _set_duration(si, e.value), + ).classes("w-32").props("dense") + ui.space() + if len(spec.steps) > 1: + ui.button( + icon="delete", + on_click=lambda _, si=si: remove_step(si), + ).props("flat round color=negative size=sm") + + for ti, term in enumerate(step.terms): + _render_term(si, ti, term) + + ui.button( + "+ Add Term", + on_click=lambda _, si=si: add_term(si), + ).props("flat color=primary size=sm") + + def _set_duration(si, val): + if val is not None and 0 <= si < len(spec.steps): + spec.steps[si].duration = float(val) + + def _render_term(si, ti, term): + with ui.row().classes("items-center gap-2 w-full"): + ui.toggle( + ["I", "X", "Y", "Z"], + value=term.pauli, + on_change=lambda e, si=si, ti=ti: _set_pauli(si, ti, e.value), + ).props("dense size=sm") + + ui.select( + list(range(spec.n_ions)), + value=term.ion, + label="Ion", + on_change=lambda e, si=si, ti=ti: _set_ion(si, ti, e.value), + ).classes("w-20").props("dense") + + ui.number( + "Coeff", + value=term.coefficient, + step=0.1, + format="%.3f", + on_change=lambda e, si=si, ti=ti: _set_coeff(si, ti, e.value), + ).classes("w-28").props("dense") + + ui.select( + ["constant", "linear", "sinusoidal", "expression"], + value=term.envelope.kind, + label="Envelope", + on_change=lambda e, si=si, ti=ti: _set_envelope_kind(si, ti, e.value), + ).classes("w-32").props("dense") + + _render_envelope_params(si, ti, term.envelope) + + ui.button( + icon="close", + on_click=lambda _, si=si, ti=ti: remove_term(si, ti), + ).props("flat round color=negative size=sm") + + def _render_envelope_params(si, ti, envelope): + if envelope.kind == "constant": + ui.number( + "Value", + value=envelope.value, + step=0.1, + format="%.3f", + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "value", e.value), + ).classes("w-24").props("dense") + + elif envelope.kind == "linear": + ui.number( + "Start", + value=envelope.start, + step=0.1, + format="%.2f", + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "start", e.value), + ).classes("w-20").props("dense") + ui.number( + "End", + value=envelope.end, + step=0.1, + format="%.2f", + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "end", e.value), + ).classes("w-20").props("dense") + + elif envelope.kind == "sinusoidal": + ui.number( + "Freq", + value=envelope.frequency, + step=0.1, + format="%.2f", + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "frequency", e.value), + ).classes("w-20").props("dense") + ui.number( + "Phase", + value=envelope.phase, + step=0.1, + format="%.2f", + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "phase", e.value), + ).classes("w-20").props("dense") + + elif envelope.kind == "expression": + ui.input( + "f(t) =", + value=envelope.expression, + on_change=lambda e, si=si, ti=ti: _set_env_param(si, ti, "expression", e.value), + ).classes("w-40").props("dense") + + def _set_pauli(si, ti, val): + if val is not None: + spec.steps[si].terms[ti].pauli = val + + def _set_ion(si, ti, val): + if val is not None: + spec.steps[si].terms[ti].ion = int(val) + + def _set_coeff(si, ti, val): + if val is not None: + spec.steps[si].terms[ti].coefficient = float(val) + + def _set_envelope_kind(si, ti, val): + if val is not None: + spec.steps[si].terms[ti].envelope = AmplitudeEnvelope(kind=val) + rebuild_steps_ui() + + def _set_env_param(si, ti, param, val): + if val is not None: + setattr(spec.steps[si].terms[ti].envelope, param, val) + + def add_step(): + spec.steps.append(EvolveStep()) + rebuild_steps_ui() + + def remove_step(si): + if len(spec.steps) > 1: + spec.steps.pop(si) + rebuild_steps_ui() + + def add_term(si): + spec.steps[si].terms.append(HamiltonianTerm()) + rebuild_steps_ui() + + def remove_term(si, ti): + if len(spec.steps[si].terms) > 1: + spec.steps[si].terms.pop(ti) + rebuild_steps_ui() + + def load_preset(preset_fn): + nonlocal spec + spec = preset_fn() + rebuild_steps_ui() + ui.notify("Preset loaded", type="positive") + + def show_ir_json(): + nonlocal json_output + try: + circuit = build_analog_circuit(spec) + text = json.dumps(circuit.model_dump(serialize_as_any=True), indent=2) + if json_output is not None: + json_output.set_content(text) + ui.notify("IR JSON generated", type="positive") + except Exception as e: + ui.notify(f"Error generating IR: {e}", type="negative") + + def run_program(): + nonlocal run_thread + try: + program = compile_to_program(spec) + board.device._stop_event.clear() + + def _run(): + board.device.run(program) + + run_thread = threading.Thread(target=_run, daemon=True) + run_thread.start() + ui.notify(f"Running program ({len(program)} steps)", type="info") + except Exception as e: + ui.notify(f"Error: {e}", type="negative") + + def stop_program(): + board.device.stop() + ui.notify("Program stopped", type="warning") + + with ui.dialog() as analog_dialog, ui.card().classes("w-full").style("max-width: 900px"): + ui.label("Analog Program Builder").style( + "color: #6E93D6; font-size: 200%; font-weight: 300" + ) + + steps_container = ui.column().classes("w-full") + rebuild_steps_ui() + + ui.separator() + + with ui.row().classes("items-center gap-2"): + ui.label("Presets:").classes("text-sm font-bold") + ui.button("Rabi Flopping", on_click=lambda: load_preset(preset_rabi_flopping)).props("flat") + ui.button("Ising Model", on_click=lambda: load_preset(preset_ising)).props("flat") + + ui.separator() + + with ui.row().classes("items-center gap-2"): + ui.button("Show IR JSON", on_click=show_ir_json).props("color=primary") + ui.button("Run Program", icon="play_arrow", on_click=run_program).props("color=positive") + ui.button("Stop", icon="stop", on_click=stop_program).props("color=negative") + + json_output = ui.code("", language="json").classes("w-full") + + with ui.card().classes("w-full"): + ui.image(stream_ip) + + return analog_dialog diff --git a/src/oqd_teaching_demo/gui/main.py b/src/oqd_teaching_demo/gui/main.py index 1268bc9..6720e9f 100644 --- a/src/oqd_teaching_demo/gui/main.py +++ b/src/oqd_teaching_demo/gui/main.py @@ -5,7 +5,8 @@ import logging from oqd_teaching_demo.program import Program -from oqd_teaching_demo.gui.programs import digital_shor, digital_random, analog_ising, analog_all_to_all +from oqd_teaching_demo.gui.programs import digital_shor, digital_random +from oqd_teaching_demo.gui.analog_builder import analog_builder_card # For development & testing (i.e., unitaryDESIGN participants!), set this MOCK = True MOCK = True @@ -102,30 +103,16 @@ def digital_card(board: Board): return digital_dialog -def analog_card(board: Board): - with ui.dialog() as analog_dialog, ui.card(): - with ui.row().classes('fixed-center'): - with ui.list(): - ui.button("Nearest Neighbours Ising", on_click=lambda: board.device.run(analog_ising())) - ui.button("All-to-All Interactions", on_click=lambda: board.device.run(analog_all_to_all())) - - - with ui.card().classes('w-full'): - ui.image(stream_ip) - - return analog_dialog - - def main(): board = Board() atexit.register(board.cleanup) - + control_dialog = control_card(board) digital_dialog = digital_card(board) - analog_dialog = analog_card(board) + analog_dialog = analog_builder_card(board, stream_ip) with ui.column(): with ui.row().classes('fixed-center'): diff --git a/src/oqd_teaching_demo/gui/programs.py b/src/oqd_teaching_demo/gui/programs.py index 19c6908..e9add87 100644 --- a/src/oqd_teaching_demo/gui/programs.py +++ b/src/oqd_teaching_demo/gui/programs.py @@ -16,6 +16,7 @@ import numpy as np from oqd_teaching_demo.program import Program +from oqd_teaching_demo.analog import AnalogProgramSpec, EvolveStep, HamiltonianTerm, AmplitudeEnvelope def digital_simple(): @@ -96,3 +97,61 @@ def analog_all_to_all(n: int = 60): ) program = Program(red_lasers_intensity=red_lasers_intensity, dt=dt) return program + + +def preset_rabi_flopping() -> AnalogProgramSpec: + return AnalogProgramSpec( + n_ions=4, + steps=[ + EvolveStep( + terms=[ + HamiltonianTerm( + pauli="X", + ion=0, + coefficient=1.0, + envelope=AmplitudeEnvelope(kind="constant", value=1.0), + ), + ], + duration=2.0, + ), + ], + ) + + +def preset_ising() -> AnalogProgramSpec: + terms = [] + for i in range(3): + terms.append( + HamiltonianTerm( + pauli="X", + ion=i, + coefficient=0.5, + envelope=AmplitudeEnvelope( + kind="sinusoidal", frequency=1.0, phase=0.0 + ), + ) + ) + terms.append( + HamiltonianTerm( + pauli="X", + ion=i + 1, + coefficient=0.5, + envelope=AmplitudeEnvelope( + kind="sinusoidal", frequency=1.0, phase=0.0 + ), + ) + ) + for i in range(4): + terms.append( + HamiltonianTerm( + pauli="Z", + ion=i, + coefficient=0.3, + envelope=AmplitudeEnvelope(kind="constant", value=1.0), + ) + ) + + return AnalogProgramSpec( + n_ions=4, + steps=[EvolveStep(terms=terms, duration=3.0)], + ) diff --git a/src/oqd_teaching_demo/ir_builder.py b/src/oqd_teaching_demo/ir_builder.py new file mode 100644 index 0000000..9e44355 --- /dev/null +++ b/src/oqd_teaching_demo/ir_builder.py @@ -0,0 +1,99 @@ +# Copyright 2024-2025 Open Quantum Design + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from functools import reduce + +from oqd_core.interface.analog.operation import AnalogCircuit, AnalogGate +from oqd_core.interface.analog.operator import ( + PauliI, + PauliX, + PauliY, + PauliZ, +) +from oqd_core.interface.math import MathNum, MathVar, MathFunc, MathMul, MathAdd, MathDiv, MathStr + +from oqd_teaching_demo.analog import AnalogProgramSpec, AmplitudeEnvelope, HamiltonianTerm + +PAULI_MAP = { + "I": PauliI, + "X": PauliX, + "Y": PauliY, + "Z": PauliZ, +} + + +def _make_kron_chain(pauli_name, ion, n_ions): + operators = [] + for i in range(n_ions): + if i == ion: + operators.append(PAULI_MAP[pauli_name]()) + else: + operators.append(PauliI()) + + return reduce(lambda a, b: a @ b, operators) + + +def _make_math_expr(envelope, duration): + if envelope.kind == "constant": + return MathNum(value=envelope.value) + + elif envelope.kind == "linear": + # start + (end - start) * (t / duration) + t = MathVar(name="t") + t_normalized = MathDiv(expr1=t, expr2=MathNum(value=duration)) + slope = MathNum(value=envelope.end - envelope.start) + return MathAdd( + expr1=MathNum(value=envelope.start), + expr2=MathMul(expr1=slope, expr2=t_normalized), + ) + + elif envelope.kind == "sinusoidal": + # sin(frequency * t + phase) + t = MathVar(name="t") + inner = MathAdd( + expr1=MathMul(expr1=MathNum(value=envelope.frequency), expr2=t), + expr2=MathNum(value=envelope.phase), + ) + return MathFunc(func="sin", expr=inner) + + elif envelope.kind == "expression": + return MathStr(string=envelope.expression) + + return MathNum(value=1.0) + + +def _build_term_operator(term, n_ions, duration): + kron = _make_kron_chain(term.pauli, term.ion, n_ions) + math_expr = _make_math_expr(term.envelope, duration) + coeff_expr = MathMul(expr1=MathNum(value=term.coefficient), expr2=math_expr) + return coeff_expr * kron + + +def _combine_terms(term_ops): + return reduce(lambda a, b: a + b, term_ops) + + +def build_analog_circuit(spec): + circuit = AnalogCircuit() + + for step in spec.steps: + term_ops = [_build_term_operator(t, spec.n_ions, step.duration) for t in step.terms] + hamiltonian = _combine_terms(term_ops) if term_ops else PauliI() + + gate = AnalogGate(hamiltonian=hamiltonian) + circuit.evolve(gate=gate, duration=step.duration) + + circuit.measure() + + return circuit