diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be1721f..0b2252f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: cache: "pip" # caching pip dependencies - name: Install dependencies run: | - sudo apt-get install -y -qq libboost-all-dev cmake + sudo apt-get update && sudo apt-get install -y -qq libboost-all-dev cmake - name: Install qlbm run: | pip install --upgrade pip diff --git a/README.md b/README.md index 06b90bc..3f43552 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ There are also Docker container images in the `Docker` directory that can be use Currently, `qlbm` supports two algorithms: - The Quantum Transport Method (Collisionless QLBM) described in [Efficient and fail-safe quantum algorithm for the transport equation](https://doi.org/10.1016/j.jcp.2024.112816) ([arXiv:2211.14269](https://arxiv.org/abs/2211.14269)) by M.A. Schalkers and M. Möller. - - The Space-Time QLBM/QLGA described in [On the importance of data encoding in quantum Boltzmann methods](https://link.springer.com/article/10.1007/s11128-023-04216-6) by M.A. Schalkers and M. Möller and expanded in [Fully Quantum Lattice Gas Automata Building Blocks for Computational Basis State Encodings](https://arxiv.org/abs/2506.12662). - - The Linear-encoding Quantum Lattice Gas Automata (LQLGA) described in [On quantum extensions of hydrodynamic lattice gas automata](https://www.mdpi.com/2410-3896/4/2/48) by P. Love and [Fully Quantum Lattice Gas Automata Building Blocks for Computational Basis State Encodings](https://arxiv.org/abs/2506.12662). + - The Space-Time QLBM/QLGA described in [On the importance of data encoding in quantum Boltzmann methods](https://link.springer.com/article/10.1007/s11128-023-04216-6) by M.A. Schalkers and M. Möller and expanded in [Fully Quantum Lattice Gas Automata Building Blocks for Computational Basis State Encodings](https://doi.org/10.1016/j.jcp.2025.114595). + - The Linear-encoding Quantum Lattice Gas Automata (LQLGA) described in [On quantum extensions of hydrodynamic lattice gas automata](https://www.mdpi.com/2410-3896/4/2/48) by P. Love and [Fully Quantum Lattice Gas Automata Building Blocks for Computational Basis State Encodings](https://doi.org/10.1016/j.jcp.2025.114595). The `demos` directory contains several use cases for simulating and analyzing these algorithms. Each demo requires minimal setup once the virtual environment has been configured. Consult the `README.md` file in the `demos` directory for further details. diff --git a/demos/simulation/ab_simulation.ipynb b/demos/simulation/ab_simulation.ipynb index e8f839b..45ab31a 100644 --- a/demos/simulation/ab_simulation.ipynb +++ b/demos/simulation/ab_simulation.ipynb @@ -19,13 +19,13 @@ "\n", "from qlbm.components import (\n", " CQLBM,\n", + " ABDiscreteUniformInitialConditions,\n", " ABGridMeasurement,\n", - " ABInitialConditions,\n", " EmptyPrimitive,\n", ")\n", "from qlbm.infra import QiskitRunner, SimulationConfig\n", "from qlbm.lattice import ABLattice\n", - "from qlbm.tools.utils import create_directory_and_parents\n" + "from qlbm.tools.utils import create_directory_and_parents" ] }, { @@ -57,7 +57,11 @@ "outputs": [], "source": [ "cfg = SimulationConfig(\n", - " initial_conditions=ABInitialConditions(lattice),\n", + " initial_conditions=ABDiscreteUniformInitialConditions(\n", + " lattice,\n", + " [1, 2, 5], # E, N, and NE\n", + " ([], list(range(5))), # Span the entire y axis, but nothing on the x axis\n", + " ),\n", " algorithm=CQLBM(lattice),\n", " postprocessing=EmptyPrimitive(lattice),\n", " measurement=ABGridMeasurement(lattice),\n", @@ -67,7 +71,7 @@ " statevector_sampling=True,\n", " execution_backend=AerSimulator(method=\"statevector\"),\n", " sampling_backend=AerSimulator(method=\"statevector\"),\n", - ")\n" + ")" ] }, { @@ -115,6 +119,14 @@ " statevector_snapshots=True,\n", ")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b61274d7", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/demos/simulation/ab_simulation_parallel_bcs.ipynb b/demos/simulation/ab_simulation_parallel_bcs.ipynb new file mode 100644 index 0000000..605513e --- /dev/null +++ b/demos/simulation/ab_simulation_parallel_bcs.ipynb @@ -0,0 +1,184 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "348de144", + "metadata": {}, + "source": [ + "# Simulating the Amplitude-Based Collisionless QLBM" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "181af34e", + "metadata": {}, + "outputs": [], + "source": [ + "from qiskit_aer import AerSimulator\n", + "\n", + "from qlbm.components import (\n", + " CQLBM,\n", + " ABDiscreteUniformInitialConditions,\n", + " ABGridMeasurement,\n", + " EmptyPrimitive,\n", + ")\n", + "from qlbm.infra import QiskitRunner, SimulationConfig\n", + "from qlbm.lattice import ABLattice\n", + "from qlbm.tools.utils import create_directory_and_parents" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "05e4915d", + "metadata": {}, + "outputs": [], + "source": [ + "lattice = ABLattice(\n", + " {\n", + " \"lattice\": {\"dim\": {\"x\": 16, \"y\": 16}, \"velocities\": \"d2q9\"},\n", + " \"geometry\": [],\n", + " }\n", + ")\n", + "\n", + "output_dir = \"qlbm-output/ab-pbc-d2q9-16x16-1-obstacle-qiskit\"\n", + "create_directory_and_parents(output_dir)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e274b50", + "metadata": {}, + "outputs": [], + "source": [ + "# Geometry of marker |0>: two \"stacked\" \"wide\" rectangles\n", + "# Geometry of the marker |1>: one \"tall\" rectangle under the first two\n", + "# These geometries will act fully in parallel, and we will observe the average behavior\n", + "lattice.set_geometries(\n", + " [\n", + " [\n", + " {\"shape\": \"cuboid\", \"x\": [6, 12], \"y\": [12, 14], \"boundary\": \"bounceback\"},\n", + " {\"shape\": \"cuboid\", \"x\": [6, 12], \"y\": [8, 10], \"boundary\": \"bounceback\"},\n", + " # {\"shape\": \"cuboid\", \"x\": [6, 8], \"y\": [0, 6], \"boundary\": \"bounceback\"},\n", + " ],\n", + " [{\"shape\": \"cuboid\", \"x\": [6, 8], \"y\": [0, 6], \"boundary\": \"bounceback\"}],\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89709eb9", + "metadata": {}, + "outputs": [], + "source": [ + "lattice.geometries" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "deb472a0", + "metadata": {}, + "outputs": [], + "source": [ + "from qlbm.components.ab.initial import ABParallelDiscreteUniformInitialConditions\n", + "\n", + "cfg = SimulationConfig(\n", + " initial_conditions=ABParallelDiscreteUniformInitialConditions(\n", + " lattice,\n", + " [[1], [1]],\n", + " [([0, 1], [0, 1, 2, 3]), ([0, 1], [0, 1, 2, 3])],\n", + " ),\n", + " algorithm=CQLBM(lattice),\n", + " postprocessing=EmptyPrimitive(lattice),\n", + " measurement=ABGridMeasurement(lattice),\n", + " target_platform=\"QISKIT\",\n", + " compiler_platform=\"QISKIT\",\n", + " optimization_level=0,\n", + " statevector_sampling=True,\n", + " execution_backend=AerSimulator(method=\"statevector\"),\n", + " sampling_backend=AerSimulator(method=\"statevector\"),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57240678", + "metadata": {}, + "outputs": [], + "source": [ + "cfg.prepare_for_simulation()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1da18eb8", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of shots to simulate for each timestep when running the circuit\n", + "NUM_SHOTS = 2**12\n", + "\n", + "# Number of timesteps to simulate\n", + "NUM_STEPS = 20" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c8c7d1e", + "metadata": {}, + "outputs": [], + "source": [ + "runner = QiskitRunner(\n", + " cfg,\n", + " lattice,\n", + ")\n", + "\n", + "\n", + "# Simulate the circuits using both snapshots\n", + "runner.run(\n", + " NUM_STEPS, # Number of time steps\n", + " NUM_SHOTS, # Number of shots per time step\n", + " output_dir,\n", + " statevector_snapshots=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52a7ae28", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qlbm-cpu-venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/demos/simulation/ab_simulation_parallel_ics.ipynb b/demos/simulation/ab_simulation_parallel_ics.ipynb new file mode 100644 index 0000000..42837d6 --- /dev/null +++ b/demos/simulation/ab_simulation_parallel_ics.ipynb @@ -0,0 +1,177 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "348de144", + "metadata": {}, + "source": [ + "# Simulating the Amplitude-Based Collisionless QLBM" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "181af34e", + "metadata": {}, + "outputs": [], + "source": [ + "from qiskit_aer import AerSimulator\n", + "\n", + "from qlbm.components import (\n", + " CQLBM,\n", + " ABGridMeasurement,\n", + " ABParallelDiscreteUniformInitialConditions,\n", + " EmptyPrimitive,\n", + ")\n", + "from qlbm.infra import QiskitRunner, SimulationConfig\n", + "from qlbm.lattice import ABLattice\n", + "from qlbm.tools.utils import create_directory_and_parents" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "05e4915d", + "metadata": {}, + "outputs": [], + "source": [ + "lattice = ABLattice(\n", + " {\n", + " \"lattice\": {\"dim\": {\"x\": 16, \"y\": 16}, \"velocities\": \"d2q9\"},\n", + " \"geometry\": [\n", + " {\"shape\": \"cuboid\", \"x\": [10, 13], \"y\": [6, 14], \"boundary\": \"bounceback\"},\n", + " ],\n", + " }\n", + ")\n", + "\n", + "output_dir = \"qlbm-output/ab-pic-d2q9-16x16-1-obstacle-qiskit\"\n", + "create_directory_and_parents(output_dir)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e274b50", + "metadata": {}, + "outputs": [], + "source": [ + "# Set the number of marker registers, such that we can have up to 2 parallel initial conditions\n", + "lattice.set_num_marker_qubits(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1aaa1787", + "metadata": {}, + "outputs": [], + "source": [ + "# ICs moving E and NE, assembled on a rectangle [0, 1, 2, 3] x [0, ..., 15] on marker |0>\n", + "# and W and SW, assembled on a rectangle [0, ..., 15] x [0, 1, 2, 3] on marker |1>\n", + "ics = ABParallelDiscreteUniformInitialConditions(\n", + " lattice,\n", + " [[1, 5], [3, 7]],\n", + " [([0, 1], [0, 1, 2, 3]), ([0, 1, 2, 3], [0, 1])],\n", + ")\n", + "\n", + "ics.draw(\"mpl\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "deb472a0", + "metadata": {}, + "outputs": [], + "source": [ + "cfg = SimulationConfig(\n", + " initial_conditions=ics,\n", + " algorithm=CQLBM(lattice),\n", + " postprocessing=EmptyPrimitive(lattice),\n", + " measurement=ABGridMeasurement(lattice),\n", + " target_platform=\"QISKIT\",\n", + " compiler_platform=\"QISKIT\",\n", + " optimization_level=0,\n", + " statevector_sampling=True,\n", + " execution_backend=AerSimulator(method=\"statevector\"),\n", + " sampling_backend=AerSimulator(method=\"statevector\"),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57240678", + "metadata": {}, + "outputs": [], + "source": [ + "cfg.prepare_for_simulation()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1da18eb8", + "metadata": {}, + "outputs": [], + "source": [ + "# Number of shots to simulate for each timestep when running the circuit\n", + "NUM_SHOTS = 2**12\n", + "\n", + "# Number of timesteps to simulate\n", + "NUM_STEPS = 20" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c8c7d1e", + "metadata": {}, + "outputs": [], + "source": [ + "runner = QiskitRunner(\n", + " cfg,\n", + " lattice,\n", + ")\n", + "\n", + "\n", + "# Simulate the circuits using both snapshots\n", + "runner.run(\n", + " NUM_STEPS, # Number of time steps\n", + " NUM_SHOTS, # Number of shots per time step\n", + " output_dir,\n", + " statevector_snapshots=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52a7ae28", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qlbm-cpu-venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/demos/simulation/ms_simulation.ipynb b/demos/simulation/ms_simulation.ipynb index 9dc16b3..692e13a 100644 --- a/demos/simulation/ms_simulation.ipynb +++ b/demos/simulation/ms_simulation.ipynb @@ -38,20 +38,20 @@ "lattice = MSLattice(\n", " {\n", " \"lattice\": {\n", - " \"dim\": {\"x\": 64, \"y\": 32},\n", + " \"dim\": {\"x\": 16, \"y\": 16},\n", " \"velocities\": {\n", " \"x\": 4,\n", " \"y\": 4,\n", " },\n", " },\n", " \"geometry\": [\n", - " {\"shape\": \"cuboid\", \"x\": [10, 13], \"y\": [14, 17], \"boundary\": \"bounceback\"},\n", + " {\"shape\": \"cuboid\", \"x\": [10, 12], \"y\": [6, 12], \"boundary\": \"bounceback\"},\n", " ],\n", " }\n", ")\n", "\n", "\n", - "output_dir = \"qlbm-output/ms-d2q9-64x32-1-obstacle-qiskit\"\n", + "output_dir = \"qlbm-output/ms-4x4-16x16-1-obstacle-qiskit\"\n", "create_directory_and_parents(output_dir)" ] }, diff --git a/demos/simulation/spacetime_simulation.ipynb b/demos/simulation/spacetime_simulation.ipynb index e135c7c..559557b 100644 --- a/demos/simulation/spacetime_simulation.ipynb +++ b/demos/simulation/spacetime_simulation.ipynb @@ -32,24 +32,16 @@ "metadata": {}, "outputs": [], "source": [ - "# Load example with mixed boundary conditions and create output directory\n", "lattice = SpaceTimeLattice(\n", " num_timesteps=1,\n", " lattice_data={\n", - " \"lattice\": {\"dim\": {\"x\": 64, \"y\": 64}, \"velocities\": \"D2Q4\"},\n", - " \"geometry\": [\n", - " {\n", - " \"shape\": \"sphere\",\n", - " \"center\": [30, 30],\n", - " \"radius\": 15,\n", - " \"boundary\": \"bounceback\",\n", - " }\n", - " ],\n", + " \"lattice\": {\"dim\": {\"x\": 8, \"y\": 8}, \"velocities\": \"D2Q4\"},\n", + " \"geometry\": [],\n", " },\n", " use_volumetric_ops=False,\n", ")\n", "\n", - "output_dir = \"qlbm-output/spacetime-d2q4-64x64-1-sphere-qiskit\"\n", + "output_dir = \"qlbm-output/spacetime-d2q4-8x8-qiskit\"\n", "create_directory_and_parents(output_dir)" ] }, diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css index 94f94ac..c75f98c 100644 --- a/docs/source/_static/css/custom.css +++ b/docs/source/_static/css/custom.css @@ -10,4 +10,9 @@ .bd-sidebar-primary { max-width: 230px +} + +table.center-align-col { + margin-left: auto; + margin-right: auto; } \ No newline at end of file diff --git a/docs/source/code/comps_cqlbm.rst b/docs/source/code/comps_cqlbm.rst index ac97ebe..e88d6fe 100644 --- a/docs/source/code/comps_cqlbm.rst +++ b/docs/source/code/comps_cqlbm.rst @@ -13,7 +13,7 @@ Amplitude-Based Circuits MSStreamingOperator, ControlledIncrementer, SpecularReflectionOperator, - SpeedSensitivePhaseShift, + ParameterizedPhaseShift, ) from qlbm.lattice import MSLattice print("ok") @@ -75,6 +75,10 @@ Initial Conditions .. autoclass:: qlbm.components.ms.primitives.MSInitialConditions3DSlim +.. autoclass:: qlbm.components.ab.initial.ABDiscreteUniformInitialConditions + +.. autoclass:: qlbm.components.ab.initial.ABParallelDiscreteUniformInitialConditions + .. autoclass:: qlbm.components.ab.initial.ABInitialConditions .. _cqlbm_streaming: @@ -88,10 +92,6 @@ Streaming .. autoclass:: qlbm.components.ms.streaming.ControlledIncrementer -.. autoclass:: qlbm.components.ms.primitives.SpeedSensitiveAdder - -.. autoclass:: qlbm.components.ms.streaming.SpeedSensitivePhaseShift - .. autoclass:: qlbm.components.ms.streaming.PhaseShift .. autoclass:: qlbm.components.ab.streaming.ABStreamingOperator @@ -113,11 +113,11 @@ Reflection .. autoclass:: qlbm.components.ms.primitives.EdgeComparator -.. autoclass:: qlbm.components.ms.primitives.Comparator +.. autoclass:: qlbm.components.ab.reflection.ABReflectionOperator -.. autoclass:: qlbm.components.ms.primitives.ComparatorMode +.. autoclass:: qlbm.components.ab.reflection.ABZoneAgnosticReflectionOperator -.. autoclass:: qlbm.components.ab.reflection.ABReflectionOperator +.. autoclass:: qlbm.components.ab.reflection.ABZoneAgnosticReflectionOracle .. _cqlbm_measurement: diff --git a/docs/source/code/comps_lga.rst b/docs/source/code/comps_lga.rst index 71a16a2..c7a129d 100644 --- a/docs/source/code/comps_lga.rst +++ b/docs/source/code/comps_lga.rst @@ -19,7 +19,7 @@ QLGA Circuits MSStreamingOperator, ControlledIncrementer, SpecularReflectionOperator, - SpeedSensitivePhaseShift, + ParameterizedPhaseShift, ) from qlbm.lattice import MSLattice, LQLGALattice print("ok") diff --git a/docs/source/code/comps_other.rst b/docs/source/code/comps_other.rst new file mode 100644 index 0000000..a1243e8 --- /dev/null +++ b/docs/source/code/comps_other.rst @@ -0,0 +1,45 @@ +.. _misc_components: + +==================================== +Common and Misc Circuits +==================================== + +Circuits that are used throughout different algorithms, +have niche use cases, or are not encoding-specific. + +This page documents components that are shared throughout different +encodings and different stages of algorithms. + +Comparators +---------------------------------- + +.. autoclass:: qlbm.components.common.SingleRegisterComparator + +.. autoclass:: qlbm.components.common.TwoRegisterComparator + +.. autoclass:: qlbm.tools.ComparatorMode + +Arithmetic +---------------------------------- + +.. autoclass:: qlbm.components.common.ParameterizedDraperAdder + +.. autoclass:: qlbm.components.common.ParameterizedPhaseShift + + +Miscellaneous +---------------------------------- + +.. autoclass:: qlbm.components.common.EmptyPrimitive + +.. autoclass:: qlbm.components.common.MCSwap + +.. autoclass:: qlbm.components.common.HammingWeightAdder + +.. autoclass:: qlbm.components.common.TruncatedQFT + +.. autoclass:: qlbm.components.common.UniformStatePrep + +.. autoclass:: qlbm.components.common.AdditionConversion + +.. autoclass:: qlbm.components.common.StateSetter \ No newline at end of file diff --git a/docs/source/code/index.rst b/docs/source/code/index.rst index 5174384..0967420 100644 --- a/docs/source/code/index.rst +++ b/docs/source/code/index.rst @@ -4,7 +4,7 @@ Internal Documentation ================================ ``qlbm`` is made up of 4 main modules. -Together, the :ref:`base_components`, :ref:`amplitude_components`, and :ref:`qlga_components` +Together, the :ref:`base_components`, :ref:`amplitude_components`, :ref:`qlga_components`, and :ref:`misc_components` module handle the parameterized creation of quantum circuits that compose QBMs. The :ref:`lattice` module parses external information into quantum registers and provides uniform interfaces for underlying algorithms. @@ -12,12 +12,36 @@ The :ref:`infra` module integrates the quantum components with Tket, Qiskit, and Qulacs transpilers and runners. The :ref:`tools` module contains miscellaneous utilities. +.. rst-class:: center-align-col + ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| | Encodings | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| | Amplitude Encodings | Computational Basis State Encoding | ++============================+==================================+==================================+==================================+==================================+==================================+ +| | Ampl. Based | One-Hot | Multi-Speed | Space-Time | Linear | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| Algorithm | QLBM |QLGA | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| Reference | N/A | :cite:`erio` | :cite:`collisionless` | :cite:`spacetime` | :cite:`lqlga1, spacetime2` | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| Discretization | :math:`D_dQ_q` | MS :cite:`collisionless` | :math:`D_dQ_q` | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| Implementation | :math:`D_2Q_9` | 2D, 3D, :math:`\geq 4` speeds | :math:`D_1Q_2`, :math:`D_1Q_3`, :math:`D_2Q_4` | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ +| Required Qubits | :math:`{O}(\log(qN_g))` | :math:`{O}(\log(N_g)+q)` | :math:`{O}(\log(qN_g))` | :math:`{O}(\log(N_g)+N_t^d)` | :math:`qN_g` | ++----------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+----------------------------------+ + + + .. toctree:: + :caption: Tutorials + :maxdepth: 1 lattice comps_base comps_cqlbm comps_lga + comps_other infra tools - diff --git a/docs/source/code/lattice.rst b/docs/source/code/lattice.rst index b9bb399..ce8c0cd 100644 --- a/docs/source/code/lattice.rst +++ b/docs/source/code/lattice.rst @@ -93,6 +93,9 @@ The :class:`.SpaceTimeQLBM` algorithm on makes use of the following: .. autoclass:: qlbm.lattice.geometry.Circle :members: +.. autoclass:: qlbm.lattice.geometry.YMonomial + :members: + .. autoclass:: qlbm.lattice.geometry.DimensionalReflectionData .. autoclass:: qlbm.lattice.geometry.ReflectionPoint diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index d247629..f290535 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -18,4 +18,4 @@ Currently, the following visualization tutorials are available online: notebooks/spacetime_vis notebooks/lqlga_vis notebooks/geometry_vis - notebooks/flowfield_vis + notebooks/flowfield_vis \ No newline at end of file diff --git a/docs/source/refs.bib b/docs/source/refs.bib index ab56d73..78cf16e 100644 --- a/docs/source/refs.bib +++ b/docs/source/refs.bib @@ -42,10 +42,16 @@ @article{qlbm } @article{spacetime2, - title={Fully Quantum Lattice Gas Automata Building Blocks for Computational Basis State Encodings}, - author={Georgescu, C{\u{a}}lin A and Schalkers, Merel A and M{\"o}ller, Matthias}, - journal={arXiv preprint arXiv:2506.12662}, - year={2025} +title = {Fully quantum lattice gas automata building blocks for computational basis state encodings}, +journal = {Journal of Computational Physics}, +volume = {549}, +pages = {114595}, +year = {2026}, +issn = {0021-9991}, +doi = {https://doi.org/10.1016/j.jcp.2025.114595}, +url = {https://www.sciencedirect.com/science/article/pii/S0021999125008770}, +author = {C{\u{a}}lin A. Georgescu and Merel A. Schalkers and Matthias M{\"o}ller}, +keywords = {Quantum computing, Lattice gas automata, Computational fluid dynamics}, } @@ -79,3 +85,46 @@ @article{lqlga1 year={2019}, publisher={MDPI} } + +@article{qsearch, + title={Quantum Search in Superposed Quantum Lattice Gas Automata and Lattice Boltzmann Systems}, + author={Georgescu, C{\u{a}}lin A and M{\"o}ller, Matthias}, + journal={arXiv preprint arXiv:2510.14062}, + year={2025} +} + +@article{erio, +title={Quantum Algorithms for the Lattice Boltzmann Method: Encoding and Evolution}, +author={D.T. Duong}, +journal={TU Delft MSc. Thesis}, +year={2025}, +url={https://repository.tudelft.nl/record/uuid:a7b20729-46b7-42d1-aaf7-c001fc93efd9} +} + +@article{draper, + title={Addition on a quantum computer}, + author={Draper, Thomas G}, + journal={arXiv preprint quant-ph/0008033}, + year={2000} +} + +@article{qftadder, + title={Circuit for Shor's algorithm using 2n+ 3 qubits}, + author={Beauregard, Stephane}, + journal={arXiv preprint quant-ph/0205095}, + year={2002} +} + + +@article{uniprep, + abstract = {Quantum state preparation involving a uniform superposition over a non-empty subset of n-qubit computational basis states is an important and challenging step in many quantum computation algorithms and applications. In this work, we address the problem of preparation of a uniform superposition state of the form {\$}{\$}{$\backslash$}left{$|$} {\{}{$\backslash$}Psi {\}}{$\backslash$}right{$\backslash$}rangle = {$\backslash$}frac{\{}1{\}}{\{}{$\backslash$}sqrt{\{}M{\}}{\}}{$\backslash$}sum {\_}{\{}j = 0{\}}\^{}{\{}M - 1{\}} {$\backslash$}left{$|$} {\{}j{\}}{$\backslash$}right{$\backslash$}rangle {\$}{\$}, where M denotes the number of distinct states in the superposition state and {\$}{\$}2 {$\backslash$}le M {$\backslash$}le 2\^{}n{\$}{\$}. We show that the superposition state {\$}{\$}{$\backslash$}left{$|$} {\{}{$\backslash$}Psi {\}}{$\backslash$}right{$\backslash$}rangle {\$}{\$}can be efficiently prepared, using a deterministic approach, with a gate complexity and circuit depth of only {\$}{\$}O({$\backslash$}log {\_}2\~{}M){\$}{\$}for all M. This demonstrates an exponential reduction in gate complexity in comparison with other existing deterministic approaches in the literature for the general case of this problem. Another advantage of the proposed approach is that it requires only {\$}{\$}n={$\backslash$}lceil {$\backslash$}log {\_}2\~{}M{$\backslash$}rceil {\$}{\$}qubits. Furthermore, neither ancilla qubits nor any quantum gates with multiple controls are needed in our approach for creating the uniform superposition state {\$}{\$}{$\backslash$}left{$|$} {\{}{$\backslash$}Psi {\}}{$\backslash$}right{$\backslash$}rangle {\$}{\$}. It is also shown that a broad class of nonuniform superposition states that involve a mixture of uniform superposition states can also be efficiently created with the same circuit configuration that is used for creating the uniform superposition state {\$}{\$}{$\backslash$}left{$|$} {\{}{$\backslash$}Psi {\}}{$\backslash$}right{$\backslash$}rangle {\$}{\$}described earlier, but with modified parameters.}, + author = {Shukla, Alok and Vedula, Prakash}, + date = {2024/01/29}, + doi = {10.1007/s11128-024-04258-4}, + journal = {Quantum Information Processing}, + number = {2}, + pages = {38}, + title = {An efficient quantum algorithm for preparation of uniform quantum superposition states}, + url = {https://doi.org/10.1007/s11128-024-04258-4}, + volume = {23}, + year = {2024}} diff --git a/pyproject.toml b/pyproject.toml index 2ede310..23c9ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "pytket>=1.29.2", "pytket-qiskit", "pytket-qulacs>=0.33", - "qiskit>=2.0", + "qiskit==2.1.0", + "qiskit_ibm_runtime==0.41", "qiskit_qasm3_import>=0.4.2", "qiskit-qulacs>=0.1.0", "tqdm>=4.66", diff --git a/qlbm/__init__.py b/qlbm/__init__.py index 7e98639..3ffff97 100644 --- a/qlbm/__init__.py +++ b/qlbm/__init__.py @@ -24,6 +24,5 @@ "CQLBM", "CircuitCompiler", "QiskitRunner", - "QulacsRunner", "AmplitudeResult", ] diff --git a/qlbm/components/__init__.py b/qlbm/components/__init__.py index f969bbc..cb7fe52 100644 --- a/qlbm/components/__init__.py +++ b/qlbm/components/__init__.py @@ -2,11 +2,15 @@ from .ab import ( ABQLBM, + ABDiscreteUniformInitialConditions, ABGridMeasurement, ABInitialConditions, + ABParallelDiscreteUniformInitialConditions, ABReflectionOperator, ABReflectionPermutation, ABStreamingOperator, + ABZoneAgnosticReflectionOperator, + ABZoneAgnosticReflectionOracle, ) from .base import ( LBMAlgorithm, @@ -23,6 +27,8 @@ EQCRedistribution, HammingWeightAdder, ) +from .common.adders import ParameterizedDraperAdder, ParameterizedPhaseShift, PhaseShift +from .common.comparators import SingleRegisterComparator from .cqlbm import CQLBM from .lqlga import ( LQLGA, @@ -41,11 +47,8 @@ MSStreamingOperator, SpecularReflectionOperator, ) -from .ms.primitives import Comparator, ComparatorMode, SpeedSensitiveAdder from .ms.streaming import ( ControlledIncrementer, - PhaseShift, - SpeedSensitivePhaseShift, StreamingAncillaPreparation, ) @@ -57,11 +60,10 @@ "MSOperator", "SpaceTimeOperator", "LBMAlgorithm", - "ComparatorMode", - "Comparator", - "SpeedSensitiveAdder", + "SingleRegisterComparator", + "ParameterizedDraperAdder", "PhaseShift", - "SpeedSensitivePhaseShift", + "ParameterizedPhaseShift", "EmptyPrimitive", "StreamingAncillaPreparation", "ControlledIncrementer", @@ -89,4 +91,8 @@ "ABReflectionOperator", "ABReflectionPermutation", "ABStreamingOperator", + "ABDiscreteUniformInitialConditions", + "ABParallelDiscreteUniformInitialConditions", + "ABZoneAgnosticReflectionOperator", + "ABZoneAgnosticReflectionOracle", ] diff --git a/qlbm/components/ab/__init__.py b/qlbm/components/ab/__init__.py index c1f69b3..ce5a98a 100644 --- a/qlbm/components/ab/__init__.py +++ b/qlbm/components/ab/__init__.py @@ -2,17 +2,32 @@ from .ab import ABQLBM from .encodings import ABEncodingType -from .initial import ABInitialConditions +from .initial import ( + ABDiscreteUniformInitialConditions, + ABInitialConditions, + ABParallelDiscreteUniformInitialConditions, +) from .measurement import ABGridMeasurement -from .reflection import ABReflectionOperator, ABReflectionPermutation +from .reflection import ( + ABReflectionOperator, + ABReflectionPermutation, + ABZoneAgnosticReflectionOperator, + ABZoneAgnosticReflectionOracle, +) from .streaming import ABStreamingOperator +from .utils import BinaryToOHPermutation __all__ = [ "ABQLBM", + "ABDiscreteUniformInitialConditions", + "ABParallelDiscreteUniformInitialConditions", "ABInitialConditions", "ABGridMeasurement", "ABReflectionOperator", "ABReflectionPermutation", "ABStreamingOperator", "ABEncodingType", + "BinaryToOHPermutation", + "ABZoneAgnosticReflectionOperator", + "ABZoneAgnosticReflectionOracle", ] diff --git a/qlbm/components/ab/ab.py b/qlbm/components/ab/ab.py index a66b081..d4f8653 100644 --- a/qlbm/components/ab/ab.py +++ b/qlbm/components/ab/ab.py @@ -6,12 +6,11 @@ from qiskit import QuantumCircuit from typing_extensions import override -from qlbm.components.ab.reflection import ABReflectionOperator +from qlbm.components.ab.reflection import ( + ABZoneAgnosticReflectionOperator, +) from qlbm.components.base import LBMAlgorithm -from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.lattices.ab_lattice import ABLattice -from qlbm.tools.exceptions import LatticeException -from qlbm.tools.utils import flatten from .streaming import ABStreamingOperator @@ -73,20 +72,10 @@ def create_circuit(self): inplace=True, ) - for bc in ["bounceback", "specular"]: - if self.lattice.shapes[bc]: - if not all( - isinstance(shape, Block) - for shape in self.lattice.shapes["specular"] - ): - raise LatticeException( - f"All shapes with the {bc} boundary condition must be cuboids for the ABQLBM algorithm. " - ) - circuit.compose( - ABReflectionOperator( + ABZoneAgnosticReflectionOperator( self.lattice, - flatten(list(self.lattice.shapes.values())), # type: ignore + None, logger=self.logger, ).circuit, inplace=True, diff --git a/qlbm/components/ab/initial.py b/qlbm/components/ab/initial.py index bf294bb..6456201 100644 --- a/qlbm/components/ab/initial.py +++ b/qlbm/components/ab/initial.py @@ -2,17 +2,24 @@ from logging import Logger, getLogger from time import perf_counter_ns +from typing import List, Tuple import numpy as np from qiskit import QuantumCircuit -from qiskit.quantum_info import Operator +from qiskit.circuit.library import HGate, MCMTGate from typing_extensions import override from qlbm.components.ab.encodings import ABEncodingType +from qlbm.components.ab.utils import BinaryToOHPermutation from qlbm.components.base import LBMPrimitive -from qlbm.components.common.primitives import TruncatedQFT +from qlbm.components.common.primitives import ( + AdditionConversion, + StateSetter, + UniformStatePrep, +) from qlbm.lattice.lattices.ab_lattice import ABLattice -from qlbm.tools.exceptions import LatticeException +from qlbm.tools.exceptions import CircuitException, LatticeException +from qlbm.tools.utils import dimension_letter class ABInitialConditions(LBMPrimitive): @@ -20,7 +27,7 @@ class ABInitialConditions(LBMPrimitive): Initial conditions for the :class:`ABQLBM` algorithm. This component creates an equal magnitude superposition of all velocity - basis states at position ``(0, 0)`` using the :class:`TruncatedQFT`. + basis states at position ``(0, 0)`` using the :class:`.UniformStatePrep`. Example usage: @@ -57,6 +64,8 @@ class ABInitialConditions(LBMPrimitive): ABInitialConditions(lattice).circuit.decompose(reps=2).draw("mpl") """ + lattice: ABLattice + def __init__( self, lattice: ABLattice, @@ -76,31 +85,22 @@ def __init__( def create_circuit(self) -> QuantumCircuit: circuit = QuantumCircuit(*self.lattice.registers) + circuit.compose( + UniformStatePrep( + self.lattice.num_velocity_qubits, + self.lattice.num_velocities_per_point, + logger=self.logger, + ).circuit, + qubits=self.lattice.velocity_index()[: self.lattice.num_velocity_qubits], + inplace=True, + ) + match self.lattice.get_encoding(): case ABEncodingType.AB: - circuit.compose( - TruncatedQFT( - self.lattice.num_velocity_qubits, - self.lattice.num_velocities_per_point, - self.logger, - ).circuit, - qubits=self.lattice.velocity_index(), - inplace=True, - ) + circuit.h(self.lattice.grid_index(1)) case ABEncodingType.OH: - nq = int(np.ceil(np.log2(self.lattice.num_velocity_qubits))) circuit.compose( - TruncatedQFT( - nq, - self.lattice.num_velocity_qubits, - self.logger, - ).circuit, - qubits=self.lattice.velocity_index()[:nq], - inplace=True, - ) - - circuit.compose( - self.__oh_permutation(), + BinaryToOHPermutation(self.lattice, self.logger).circuit, qubits=self.lattice.velocity_index(), inplace=True, ) @@ -109,41 +109,366 @@ def create_circuit(self) -> QuantumCircuit: f"Encoding {self.lattice.get_encoding()} not supported." ) + if self.lattice.has_multiple_geometries(): + circuit.h(self.lattice.marker_index()) + return circuit - def __oh_permutation(self) -> QuantumCircuit: - circuit = QuantumCircuit(*self.lattice.registers) + @override + def __str__(self) -> str: + return f"[Primitive ABEInitialConditions with lattice {self.lattice}]" + - n = self.lattice.num_velocity_qubits - dim = 2**n +class ABDiscreteUniformInitialConditions(LBMPrimitive): + """ + Initial conditions for the :class:`ABQLBM` algorithm. - perm = [-1] * dim - used_rows = set() + This component creates an equal magnitude superposition of a configurable set of velocity and grid indices. + + Example usage: + + .. plot:: + :include-source: - for j in range(9): - row = 1 << j # 2^j - perm[j] = row - used_rows.add(row) + from qlbm.components.ab import ABDiscreteUniformInitialConditions + from qlbm.lattice import ABLattice - # Fill in the rest of the permutation arbitrarily but bijectively. - remaining_rows = [r for r in range(dim) if r not in used_rows] - k = 0 - for col in range(9, dim): - perm[col] = remaining_rows[k] - k += 1 + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 8}, "velocities": "d2q9"}, + } + ) - U = np.zeros((dim, dim), dtype=complex) - for col in range(dim): - row = perm[col] - U[row, col] = 1.0 + ABDiscreteUniformInitialConditions(lattice, [1, 3, 4], ([], [])).draw("mpl") - op = Operator(U) + The primitive can also applied to the :class:`.OHLattice`: - circuit = QuantumCircuit(n) - circuit.unitary(op, range(n), label="binary_to_onehot") + .. plot:: + :include-source: + + from qlbm.components.ab import ABDiscreteUniformInitialConditions + from qlbm.lattice import OHLattice + + lattice = OHLattice( + { + "lattice": {"dim": {"x": 16, "y": 8}, "velocities": "d2q9"}, + } + ) + + ABDiscreteUniformInitialConditions(lattice, [0, 1], ([0, 1], [0])).draw("mpl") + """ + + velocity_indices: List[int] + + grid_qubits_to_superpose: Tuple[List[int], ...] + + lattice: ABLattice + + def __init__( + self, + lattice: ABLattice, + velocity_indices: List[int], + grid_qubits_to_superpose: Tuple[List[int], ...], + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.lattice = lattice + + if any( + map( + lambda x: x + not in list(range(0, self.lattice.num_velocities_per_point)), + velocity_indices, + ) + ): + raise LatticeException( + f"Velocity indices should be in the interval 0..{self.lattice.num_velocities_per_point}" + ) + + if len(grid_qubits_to_superpose) != self.lattice.num_dims: + raise LatticeException( + f"Lattice has {self.lattice.num_dims} dimensions, but provided grid qubit information has {len(grid_qubits_to_superpose)} entries." + ) + + for dim in range(self.lattice.num_dims): + if any( + map( + lambda x: x + not in list(range(self.lattice.num_gridpoints[dim].bit_length())), + grid_qubits_to_superpose[dim], + ), + ): + raise LatticeException( + f"Grid qubit specification in dimension {dimension_letter(dim)} out of range." + ) + + self.velocity_indices = sorted(velocity_indices) + self.grid_qubits_to_superpose = grid_qubits_to_superpose + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(*self.lattice.registers) + + nq = int(np.ceil(np.log2(len(self.velocity_indices)))) + + circuit.compose( + UniformStatePrep( + nq, + len(self.velocity_indices), + logger=self.logger, + ).circuit, + qubits=self.lattice.velocity_index()[:nq], + inplace=True, + ) + + states_from = list(range(len(self.velocity_indices))) + states_to = self.velocity_indices.copy() + + # Remove indices that are already in place + for v in self.velocity_indices: + if v < len(self.velocity_indices): + states_from.remove(v) + states_to.remove(v) + + for v_from, v_to in zip(states_from, states_to): + circuit.compose( + AdditionConversion( + self.lattice.num_velocity_qubits, v_from, v_to, logger=self.logger + ).circuit, + qubits=self.lattice.velocity_index()[ + : self.lattice.num_velocities_per_point + ] # Additional guard necessary of OH + + self.lattice.ancillae_obstacle_index(0), + inplace=True, + ) + + if self.lattice.get_encoding() == ABEncodingType.OH: + circuit.compose( + BinaryToOHPermutation(self.lattice, self.logger).circuit, + qubits=self.lattice.velocity_index(), + inplace=True, + ) + + for dim in range(self.lattice.num_dims): + if self.grid_qubits_to_superpose[dim]: + circuit.h( + [ + self.lattice.grid_index(dim)[0] + q + for q in self.grid_qubits_to_superpose[dim] + ] + ) return circuit @override def __str__(self) -> str: - return f"[Primitive ABEInitialConditions with lattice {self.lattice}]" + return f"[Primitive ABDiscreteUniformInitialConditions with lattice {self.lattice}, v={self.velocity_indices}, g={self.grid_qubits_to_superpose}]" + + +class ABParallelDiscreteUniformInitialConditions(LBMPrimitive): + """ + Marker-sensitive initial conditions for the :class:`ABQLBM` algorithm. + + This component creates an equal magnitude superposition of a configurable set of velocity and grid indices, + entangled with the state of the marker register. + Used in parallel realizations of configurations. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.ab import ABParallelDiscreteUniformInitialConditions + from qlbm.lattice import ABLattice + + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 8}, "velocities": "d2q9"}, + } + ) + + lattice.set_num_marker_qubits(2) + + ABParallelDiscreteUniformInitialConditions( + lattice, + [[0, 1], [0, 3], [0], [0, 5]], + [([0], [0])] * 4, + ).draw("mpl") + + """ + + velocity_indices: List[List[int]] + + grid_qubits_to_superpose: List[Tuple[List[int], ...]] + + lattice: ABLattice + + def __init__( + self, + lattice: ABLattice, + velocity_indices_list: List[List[int]], + grid_qubits_to_superpose_list: List[Tuple[List[int], ...]], + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.lattice = lattice + + if self.lattice.get_encoding() == ABEncodingType.OH: + raise LatticeException( + "OHLattice does not currently support parallel initial conditions." + ) + + if len(velocity_indices_list) != len(grid_qubits_to_superpose_list): + raise CircuitException("Input lists have mismatched lengths.") + + if len(velocity_indices_list) > 2**self.lattice.num_marker_qubits: + raise LatticeException( + f"{self.lattice.num_marker_qubits} cannot encode {len(velocity_indices_list)} configurations." + ) + + for velocity_indices, grid_qubits_to_superpose in zip( + velocity_indices_list, grid_qubits_to_superpose_list + ): + if any( + map( + lambda x: x + not in list(range(0, self.lattice.num_velocities_per_point)), + velocity_indices, + ) + ): + raise LatticeException( + f"Velocity indices should be in the interval 0..{self.lattice.num_velocities_per_point}" + ) + + if len(grid_qubits_to_superpose) != self.lattice.num_dims: + raise LatticeException( + f"Lattice has {self.lattice.num_dims} dimensions, but provided grid qubit information has {len(grid_qubits_to_superpose)} entries." + ) + + for dim in range(self.lattice.num_dims): + if any( + map( + lambda x: x + not in list( + range(self.lattice.num_gridpoints[dim].bit_length()) + ), + grid_qubits_to_superpose[dim], + ), + ): + raise LatticeException( + f"Grid qubit specification in dimension {dimension_letter(dim)} out of range." + ) + + self.velocity_indices_list = [ + sorted(velocity_indices) for velocity_indices in velocity_indices_list + ] + self.grid_qubits_to_superpose_list = grid_qubits_to_superpose_list + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(*self.lattice.registers) + + # Uniform superposition over the marker index + circuit.compose( + UniformStatePrep( + self.lattice.num_marker_qubits, + len(self.velocity_indices_list), + logger=self.logger, + ).circuit, + qubits=self.lattice.marker_index(), + inplace=True, + ) + + for marker_index, velocity_indices, grid_qubits_to_superpose in zip( + list(range(len(self.velocity_indices_list))), + self.velocity_indices_list, + self.grid_qubits_to_superpose_list, + ): + nq = int(np.ceil(np.log2(len(velocity_indices)))) + + state_setter_circ = StateSetter( + self.lattice.num_marker_qubits, marker_index, self.logger + ).circuit + + circuit.compose( + state_setter_circ, qubits=self.lattice.marker_index(), inplace=True + ) + + circuit.compose( + UniformStatePrep( + nq, + len(velocity_indices), + num_ctrl_qubits=self.lattice.num_marker_qubits, + logger=self.logger, + ).circuit, + qubits=self.lattice.velocity_index()[:nq] + self.lattice.marker_index(), + inplace=True, + ) + + states_from: List[int] = list(range(len(velocity_indices))) + states_to: List[int] = velocity_indices.copy() + + # Remove indices that are already in place + for v in velocity_indices: + if v < len(velocity_indices): + states_from.remove(v) + states_to.remove(v) + + for v_from, v_to in zip(states_from, states_to): + circuit.compose( + AdditionConversion( + self.lattice.num_velocity_qubits, + v_from, + v_to, + num_ctrl_qubits=self.lattice.num_marker_qubits, + logger=self.logger, + ).circuit, + qubits=self.lattice.velocity_index()[ + : self.lattice.num_velocities_per_point + ] # Additional guard necessary of OH + + self.lattice.ancillae_obstacle_index(0) + + self.lattice.marker_index(), + inplace=True, + ) + + for dim in range(self.lattice.num_dims): + if grid_qubits_to_superpose[dim]: + qs_to_superpose = [ + self.lattice.grid_index(dim)[0] + q + for q in grid_qubits_to_superpose[dim] + ] + circuit.compose( + MCMTGate( + HGate(), + self.lattice.num_marker_qubits, + len(qs_to_superpose), + ), + qubits=self.lattice.marker_index() + qs_to_superpose, + inplace=True, + ) + + circuit.compose( + state_setter_circ, qubits=self.lattice.marker_index(), inplace=True + ) + + return circuit.decompose(reps=2) + + @override + def __str__(self) -> str: + return f"[Primitive ABParallelDiscreteUniformInitialConditions with lattice {self.lattice}, v={self.velocity_indices_list}, g={self.grid_qubits_to_superpose_list}]" diff --git a/qlbm/components/ab/reflection/__init__.py b/qlbm/components/ab/reflection/__init__.py new file mode 100644 index 0000000..4b2a9e2 --- /dev/null +++ b/qlbm/components/ab/reflection/__init__.py @@ -0,0 +1,15 @@ +"""Reflection for the :class:`.ABQLBM` algorithm.""" + +from .agnosotic_reflection import ( + ABZoneAgnosticReflectionOperator, + ABZoneAgnosticReflectionOracle, +) +from .common import ABReflectionPermutation +from .standard_reflection import ABReflectionOperator + +__all__ = [ + "ABZoneAgnosticReflectionOperator", + "ABZoneAgnosticReflectionOracle", + "ABReflectionPermutation", + "ABReflectionOperator", +] diff --git a/qlbm/components/ab/reflection/agnosotic_reflection.py b/qlbm/components/ab/reflection/agnosotic_reflection.py new file mode 100644 index 0000000..3b2da04 --- /dev/null +++ b/qlbm/components/ab/reflection/agnosotic_reflection.py @@ -0,0 +1,538 @@ +"""Zone-agnostic reflection utilities for the :class:`.ABQLBM` algorithm.""" + +from logging import Logger, getLogger +from time import perf_counter_ns +from typing import List, cast + +from qiskit import QuantumCircuit +from qiskit.circuit.library import RGQFTMultiplier +from typing_extensions import override + +from qlbm.components.ab.reflection.common import ABReflectionPermutation +from qlbm.components.ab.streaming import ABStreamingOperator +from qlbm.components.base import LBMOperator, LBMPrimitive +from qlbm.components.common.adders import ParameterizedDraperAdder +from qlbm.components.common.comparators import ( + SingleRegisterComparator, + TwoRegisterComparator, +) +from qlbm.lattice.geometry.shapes import Block, Circle, YMonomial +from qlbm.lattice.geometry.shapes.base import Shape +from qlbm.lattice.lattices.ab_lattice import ABLattice +from qlbm.lattice.lattices.base import AmplitudeLattice +from qlbm.lattice.spacetime.properties_base import LatticeDiscretization +from qlbm.tools.exceptions import CircuitException, LatticeException +from qlbm.tools.utils import ComparatorMode, flatten, get_qubits_to_invert + + +class ABZoneAgnosticReflectionOperator(LBMOperator): + """ + Implements bounceback reflection in the amplitude-based encoding of :class:`.ABQLBM` for :math:`D_dQ_q` discretizations. + + Uses a zone-agnostic approach that relies on the existence of an oracle that marks the + basis states belonging to the inside of the solid geometry. + For more details on the oracle, see :class:`.ABZoneAgnosticReflectionOracle`. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.ab import ABZoneAgnosticReflectionOperator + from qlbm.lattice import ABLattice + + lattice = ABLattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": "d2q9"}, + "geometry": [ + { + "shape": "cuboid", + "x": [1, 3], + "y": [1, 3], + "boundary": "bounceback", + } + ], + } + ) + + ABZoneAgnosticReflectionOperator(lattice, shapes=lattice.shapes["bounceback"]).draw("mpl") + + """ + + lattice: AmplitudeLattice + + def __init__( + self, + lattice: ABLattice, + shapes: List[Shape] | None = None, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(lattice, logger) + + self.shapes = ( + ( + flatten(list(self.lattice.geometries[0].values())) + if not self.lattice.has_multiple_geometries() + else [ + gdict["bounceback"] + gdict["specular"] # type: ignore + for gdict in self.lattice.geometries # type: ignore + ] + ) + if shapes is None + else shapes + ) + + supported_shapes = ["cuboid", "ymonomial"] + + # For multi-geometry, self.shapes is a list of lists; + # for single geometry, it is a flat list. + all_shapes = ( + flatten(self.shapes) + if self.lattice.has_multiple_geometries() and shapes is None + else self.shapes + ) + + if any([x.name() not in supported_shapes for x in all_shapes]): # type: ignore + raise CircuitException( + f"Agnostic reflection operator only supports the following shapes: {supported_shapes}." + ) + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + if self.lattice.discretization not in [LatticeDiscretization.D2Q9]: + raise LatticeException("AB reflection only currently supported in D2Q9") + + if not self.lattice.has_multiple_geometries(): + return self.__create_circuit_single_geometry() + else: + return self.__create_circuit_multi_geometry() + + def __create_circuit_single_geometry(self) -> QuantumCircuit: + r"""Create the zone-agnostic reflection circuit for a single geometry. + + The circuit structure is: + + .. math:: + + U = S \cdot O \cdot S^{-1} \cdot (Perm \cdot S)_{\text{ctrl}\ a_o} \cdot O + + where :math:`O` is the oracle, :math:`S` is the streaming operator, + and :math:`Perm` is the velocity permutation. + """ + circuit = self.lattice.circuit.copy() + + oracle = self.lattice.circuit.copy() + for shape in self.shapes: + oracle.compose( + ABZoneAgnosticReflectionOracle( + self.lattice, shape, logger=self.logger # type: ignore + ).circuit, + inplace=True, + ) + + circuit.compose(oracle, inplace=True) + circuit.compose(self.permute_and_stream(), inplace=True) + circuit.compose( + ABStreamingOperator(self.lattice, logger=self.logger).circuit.inverse(), + inplace=True, + ) + circuit.compose(oracle, inplace=True) + circuit.compose( + ABStreamingOperator(self.lattice, logger=self.logger).circuit, + inplace=True, + ) + + return circuit + + def __create_circuit_multi_geometry(self) -> QuantumCircuit: + r"""Create the zone-agnostic reflection circuit for multiple geometries. + + For :math:`m` geometries, a combined oracle :math:`O_{\text{combined}}` + is built by applying each geometry's oracle :math:`O_c` controlled on + the marker register being in state :math:`\ket{c}`. + Since different marker states occupy orthogonal subspaces, the oracles + do not interfere and the obstacle ancilla is correctly set for each + geometry independently. + + The circuit structure is: + + .. math:: + + U = S \cdot O_\text{combined} \cdot S^{-1} + \cdot (Perm \cdot S)_{\text{ctrl}\ a_o} + \cdot O_\text{combined} + + Only the oracle is controlled on the marker state; the permutation, + streaming, and inverse streaming are shared across all geometries. + The permutation and streaming are implicitly geometry-specific because + they are controlled on the obstacle ancilla, which the marker-controlled + oracle has already set correctly. + """ + circuit = self.lattice.circuit.copy() + + oracle = self.build_combined_oracle() + + circuit.compose(oracle, inplace=True) + circuit.compose(self.permute_and_stream(), inplace=True) + circuit.compose( + ABStreamingOperator(self.lattice, logger=self.logger).circuit.inverse(), + inplace=True, + ) + circuit.compose(oracle, inplace=True) + circuit.compose( + ABStreamingOperator(self.lattice, logger=self.logger).circuit, + inplace=True, + ) + + return circuit + + def build_combined_oracle(self) -> QuantumCircuit: + r"""Build the combined oracle for all geometries. + + For each geometry index :math:`c`, the marker register qubits are + flipped so that geometry :math:`c` maps to the all-ones state. + The oracle for that geometry is then applied with its central MCX gate + additionally controlled on the marker register. + Finally, the marker qubits are unflipped to restore the original state. + + Returns + ------- + QuantumCircuit + The combined oracle circuit. + """ + oracle = self.lattice.circuit.copy() + + for c, shapes_for_geometry in enumerate(self.shapes): + qubits_to_invert = [ + q + self.lattice.marker_index()[0] + for q in get_qubits_to_invert(c, self.lattice.num_marker_qubits) + ] + + if qubits_to_invert: + oracle.x(qubits_to_invert) + + for shape in shapes_for_geometry: + oracle.compose( + ABZoneAgnosticReflectionOracle( + self.lattice, # type: ignore[arg-type] + shape, + control_on_marker_state=True, + logger=self.logger, + ).circuit, + inplace=True, + ) + + if qubits_to_invert: + oracle.x(qubits_to_invert) + + return oracle + + def permute_and_stream(self) -> QuantumCircuit: + """ + Performs the permutation of basis states that implements bounceback reflection in the amplitude-based encoding. + + Returns + ------- + QuantumCircuit + The permutation acting on only the velocity register. + """ + circuit = self.lattice.circuit.copy() + + # Permute the velocities according to reflection rules + circuit.compose( + ABReflectionPermutation( + self.lattice.num_velocity_qubits, + self.lattice.discretization, + self.lattice.get_encoding(), + self.logger, + ) + .circuit.control(1) + .decompose(), + qubits=self.lattice.ancillae_obstacle_index() + + self.lattice.velocity_index(), + inplace=True, + ) + + circuit.compose( + ABStreamingOperator( + self.lattice, self.lattice.ancillae_obstacle_index(), self.logger + ).circuit, + inplace=True, + ) + + return circuit + + @override + def __str__(self) -> str: + return f"[Operator ABZoneAgnosticReflection with lattice {self.lattice}]" + + +class ABZoneAgnosticReflectionOracle(LBMPrimitive): + r""" + Implementation of the oracle required for :class:`.ABZoneAgnosticReflectionOperator`. + + An oracle is an operator :math:`U_{\Omega}` for an obstacle's region + :math:`\Omega` such that, in the amplitude-based encoding, + :math:`U_\Omega\ket{x}\ket{v}\ket{0}_\mathbb{o} = \ket{x}\ket{v}\ket{x \in \Omega}_\mathbb{o}`. + Intuitively, the operator flips the object ancilla qubit if and only if the position :math:`x` + falls within the bounds of the object. + + Currently, the only available implementation is for 2D axis-aligned :class:`.Block` + and :class:`.YMonomial` objects. + + .. important:: + + The ``YMonomial`` implementation is a work in progress. + At present, only the :math:`x^2` monomial case is supported, + and only when the monomial result register width matches the :math:`y` + grid register width. + + This is an improvement in asymptotic and practical complexity compared to + the methods described in :cite:`collisionless`. + This operation relies on basic arithmetic through the :class:`.ParameterizedDraperAdder` class + and comparison operation through the :class:`Comparator` circuits. + + When ``control_on_marker_state`` is ``True``, the oracle additionally conditions + the obstacle ancilla flip on the marker register being in the all-ones state. + This is used for parallel boundary conditions where multiple geometries + are simulated on the same lattice, each identified by a marker state. + Only the central MCX gate (for cuboids) is controlled on the marker, + since the surrounding adder and comparator operations are self-inverse + and their net effect on the grid register is zero. + + .. important:: + + Marker-controlled oracles for :class:`.YMonomial` shapes are not yet supported. + Passing ``control_on_marker_state=True`` with a ``YMonomial`` shape will raise + a :class:`.CircuitException`. + + Example usage for a cuboid :class:`.Block`: + + .. plot:: + :include-source: + + from qlbm.components.ab.reflection import ABZoneAgnosticReflectionOracle + from qlbm.lattice import ABLattice + + lattice = ABLattice( + { + "lattice": {"dim": {"x": 4, "y": 16}, "velocities": "d2q9"}, + "geometry": [ + { + "shape": "cuboid", + "x": [1, 3], + "y": [1, 3], + "boundary": "bounceback", + } + ], + } + ) + + ABZoneAgnosticReflectionOracle(lattice, shape=lattice.shapes["bounceback"][0]).draw("mpl") + + And for a :class:`.YMonomial`: + + .. plot:: + :include-source: + + from qlbm.components.ab.reflection import ABZoneAgnosticReflectionOracle + from qlbm.lattice import ABLattice + + lattice = ABLattice( + { + "lattice": {"dim": {"x": 4, "y": 16}, "velocities": "d2q9"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<", + "boundary": "bounceback", + } + ], + } + ) + + ABZoneAgnosticReflectionOracle(lattice, shape=lattice.shapes["bounceback"][0]).draw("mpl") + + + """ + + lattice: ABLattice + + control_on_marker_state: bool + """Whether the oracle is additionally controlled on the marker register.""" + + def __init__( + self, + lattice: ABLattice, + shape: Shape, + control_on_marker_state: bool = False, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.lattice = lattice + self.shape = shape + self.control_on_marker_state = control_on_marker_state + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + if isinstance(self.shape, Block): + return self.__create_circuit_block() + elif isinstance(self.shape, Circle): + return self.__create_circuit_circle() + elif isinstance(self.shape, YMonomial): + if self.control_on_marker_state: + raise CircuitException( + "Marker-controlled oracles for YMonomial shapes are not yet supported. " + "Parallel boundary conditions with YMonomial geometries require a future extension." + ) + return self.__create_circuit_ymonomial() + + def __create_circuit_block(self) -> QuantumCircuit: + circuit = self.lattice.circuit.copy() + + block: Block = cast(Block, self.shape) + + for dim in range(self.lattice.num_dims): + circuit.compose( + ParameterizedDraperAdder( + len(self.lattice.grid_index(dim)), + block.bounds[dim][0], + positive=False, + ).circuit, + qubits=self.lattice.grid_index(dim), + inplace=True, + ) + + circuit.compose( + SingleRegisterComparator( + num_qubits=len(self.lattice.grid_index(dim)) + 1, + num_to_compare=block.bounds[dim][1] - block.bounds[dim][0], + mode=ComparatorMode.LE, + ).circuit, + qubits=self.lattice.grid_index(dim) + + [self.lattice.ancillae_comparator_index(0)[dim]], + inplace=True, + ) + + control_qubits = self.lattice.ancillae_comparator_index(0)[ + : self.lattice.num_dims + ] + + if self.control_on_marker_state: + control_qubits = control_qubits + self.lattice.marker_index() + + circuit.mcx( + control_qubits, + self.lattice.ancillae_obstacle_index()[0], + ) + + for dim in range(self.lattice.num_dims): + circuit.compose( + SingleRegisterComparator( + num_qubits=len(self.lattice.grid_index(dim)) + 1, + num_to_compare=block.bounds[dim][1] - block.bounds[dim][0], + mode=ComparatorMode.LE, + ).circuit, + qubits=self.lattice.grid_index(dim) + + [self.lattice.ancillae_comparator_index(0)[dim]], + inplace=True, + ) + + circuit.compose( + ParameterizedDraperAdder( + len(self.lattice.grid_index(dim)), + block.bounds[dim][0], + positive=True, + ).circuit, + qubits=self.lattice.grid_index(dim), + inplace=True, + ) + + return circuit + + def __create_circuit_circle(self) -> QuantumCircuit: + raise CircuitException("Not implemented") + + def __create_circuit_ymonomial(self) -> QuantumCircuit: + circuit = self.lattice.circuit.copy() + + ym: YMonomial = cast(YMonomial, self.shape) + + if ym.exponent != 2: + raise CircuitException( + "YMonomial oracle is a work in progress: only exponent=2 (x^2) is currently supported." + ) + + # Qubits used in this oracle + grid_x_qubits = self.lattice.grid_index(0) + grid_y_qubits = self.lattice.grid_index(1) + copy_qubits = self.lattice.ancillae_copy_index() + result_qubits = self.lattice.ancillae_monomial_index() + + if len(result_qubits) != len(grid_y_qubits): + raise CircuitException( + "YMonomial oracle is a work in progress: only configurations with equal y and monomial result register sizes are currently supported." + ) + + # circuits used more than once + multiplication_circuit = RGQFTMultiplier( + num_state_qubits=len(grid_x_qubits), + num_result_qubits=len(result_qubits), + ) + + comparator_circuit = TwoRegisterComparator( + len(grid_y_qubits), ym.comparator_mode + ).circuit + + # Copy x into the copy register + for qc, qt in zip(grid_x_qubits, copy_qubits): + circuit.cx(qc, qt) + + # Do the multiplication + circuit.compose( + multiplication_circuit, + qubits=grid_x_qubits + copy_qubits + result_qubits, + inplace=True, + ) + + # Comparator + circuit.compose( + comparator_circuit, + qubits=grid_y_qubits + + result_qubits + + self.lattice.ancillae_obstacle_index(), + inplace=True, + ) + + # Undo multiplication + circuit.compose( + multiplication_circuit.inverse(), + qubits=grid_x_qubits + copy_qubits + result_qubits, + inplace=True, + ) + + # Undo copy + for qc, qt in zip(grid_x_qubits, copy_qubits): + circuit.cx(qc, qt) + + return circuit + + @override + def __str__(self) -> str: + return f"[Primitive ABZoneAgnosticReflectionOracle with lattice {self.lattice}, shape={self.shape}]" diff --git a/qlbm/components/ab/reflection/common.py b/qlbm/components/ab/reflection/common.py new file mode 100644 index 0000000..bc570dc --- /dev/null +++ b/qlbm/components/ab/reflection/common.py @@ -0,0 +1,116 @@ +"""Common utilities for reflection in the :class:`.ABQLBM` algorithm.""" + +from logging import Logger, getLogger +from time import perf_counter_ns + +from qiskit import QuantumCircuit +from typing_extensions import override + +from qlbm.components.ab.encodings import ABEncodingType +from qlbm.components.base import LBMPrimitive +from qlbm.lattice.spacetime.properties_base import LatticeDiscretization +from qlbm.tools.exceptions import LatticeException + + +class ABReflectionPermutation(LBMPrimitive): + """ + Permutes velocity state to implement reflection in the amplitude-based encoding for :math:`D_dQ_q` discretizations. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.ab import ABEncodingType, ABReflectionPermutation + from qlbm.lattice import LatticeDiscretization + + ABReflectionPermutation(4, LatticeDiscretization.D2Q9, ABEncodingType.AB).draw("mpl") + + """ + + num_qubits: int + """ + The number of qubits that encode the velocity state. + """ + + discretization: LatticeDiscretization + """ + The lattice discretization the permutation adheres to. + """ + + encoding: ABEncodingType + """ + The type of encoding to permute for. + """ + + def __init__( + self, + num_qubits: int, + discretization: LatticeDiscretization, + encoding: ABEncodingType, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.num_qubits = num_qubits + self.discretization = discretization + self.encoding = encoding + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + if self.discretization == LatticeDiscretization.D2Q9: + return self.__create_circuit_d2q9() + + raise LatticeException("AB reflection only currently supported in D2Q9") + + def __create_circuit_d2q9(self): + circuit = QuantumCircuit(self.num_qubits) + match self.encoding: + case ABEncodingType.OH: + circuit.swap(1, 3) + circuit.swap(2, 4) + circuit.swap(5, 7) + circuit.swap(6, 8) + + case ABEncodingType.AB: + # 1 <-> 3 + circuit.x([0, 1]) + circuit.mcx([0, 1, 3], 2) + circuit.x([0, 1]) + + # 2 <-> 4 + circuit.x([0, 3]) + circuit.cx(1, 2) + circuit.mcx([0, 2, 3], 1) + circuit.cx(1, 2) + circuit.x([0, 3]) + + # 5 <-> 7 + circuit.x(0) + circuit.mcx([0, 1, 3], 2) + circuit.x(0) + + # 6 <-> 8 + circuit.cx(0, 1) + circuit.cx(0, 2) + circuit.x(3) + circuit.mcx([1, 2, 3], 0) + circuit.cx(0, 2) + circuit.cx(0, 1) + circuit.x(3) + + case _: + raise LatticeException(f"Unsupported lattice encoding: {self.encoding}") + + return circuit.reverse_bits() if self.encoding == ABEncodingType.AB else circuit + + @override + def __str__(self) -> str: + return f"[Primitive ABReflectionPermutation with {self.num_qubits} qubits on {self.discretization}]" diff --git a/qlbm/components/ab/reflection.py b/qlbm/components/ab/reflection/standard_reflection.py similarity index 76% rename from qlbm/components/ab/reflection.py rename to qlbm/components/ab/reflection/standard_reflection.py index 92aa411..c5d8235 100644 --- a/qlbm/components/ab/reflection.py +++ b/qlbm/components/ab/reflection/standard_reflection.py @@ -1,4 +1,4 @@ -"""Quantum circuits used for reflection in the :class:`ABQLBM` algorithm.""" +"""Reflection utilities for the :class:`.ABQLBM` algorithm; generalizations of :cite:`collisionless`.""" from itertools import product from logging import Logger, getLogger @@ -10,10 +10,12 @@ from typing_extensions import override from qlbm.components.ab.encodings import ABEncodingType +from qlbm.components.ab.reflection.common import ABReflectionPermutation from qlbm.components.ab.streaming import ABStreamingOperator -from qlbm.components.base import LBMOperator, LBMPrimitive +from qlbm.components.base import LBMOperator from qlbm.components.ms.specular_reflection import SpecularWallComparator from qlbm.lattice.geometry.encodings.ms import ReflectionPoint +from qlbm.lattice.geometry.shapes.base import Shape from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.lattices.ab_lattice import ABLattice from qlbm.lattice.lattices.base import AmplitudeLattice @@ -57,12 +59,23 @@ class ABReflectionOperator(LBMOperator): def __init__( self, lattice: ABLattice, - blocks: List[Block], + shapes: List[Shape] | None = None, logger: Logger = getLogger("qlbm"), ) -> None: super().__init__(lattice, logger) - self.blocks = blocks + self.shapes = ( + ( + flatten(list(self.lattice.geometries[0].values())) + if not self.lattice.has_multiple_geometries() + else [ + gdict["bounceback"] + gdict["specular"] # type: ignore + for gdict in self.lattice.geometries # type: ignore + ] + ) + if shapes is None + else shapes + ) self.logger.info(f"Creating circuit {str(self)}...") circuit_creation_start_time = perf_counter_ns() @@ -73,24 +86,57 @@ def __init__( @override def create_circuit(self) -> QuantumCircuit: + if self.lattice.discretization not in [LatticeDiscretization.D2Q9]: + raise LatticeException("AB reflection only currently supported in D2Q9") + if self.lattice.discretization == LatticeDiscretization.D2Q9: - return self.__create_circuit_d2q9() + if not self.lattice.has_multiple_geometries(): + return self.__create_circuit_d2q9( + self.shapes, control_on_marker_state=False + ) + else: + circuit = self.lattice.circuit.copy() + for c, blocks in enumerate(self.shapes): + # Prepare the /ket{1} state in the marker register + qubits_to_invert = [ + q + self.lattice.marker_index()[0] + for q in get_qubits_to_invert(c, self.lattice.num_marker_qubits) + ] + + if qubits_to_invert: + circuit.x(qubits_to_invert) + + circuit.compose( + self.__create_circuit_d2q9( + blocks, control_on_marker_state=True + ), + inplace=True, + ) - raise LatticeException("AB reflection only currently supported in D2Q9") + if qubits_to_invert: + circuit.x(qubits_to_invert) + return circuit - def __create_circuit_d2q9(self): + def __create_circuit_d2q9(self, blocks, control_on_marker_state: bool = False): + # Ignore accumulation and marker registers circuit = self.lattice.circuit.copy() # Mark populations inside the object - for block in self.blocks: - circuit.compose(self.set_inside_wall_ancilla_state(block), inplace=True) + for block in blocks: + circuit.compose( + self.set_inside_wall_ancilla_state( + block, control_on_marker_state=control_on_marker_state + ), + inplace=True, + ) circuit.compose( self.set_ancilla_of_point_state( flatten( - [[(p, None) for p in block.corners_inside] for block in self.blocks] + [[(p, None) for p in block.corners_inside] for block in blocks] ), ignore_velocity_data=True, + control_on_marker_state=control_on_marker_state, ), inplace=True, ) @@ -99,13 +145,18 @@ def __create_circuit_d2q9(self): circuit.compose(self.permute_and_stream(), inplace=True) # Reset the ancilla state of reflected populations - for block in self.blocks: - circuit.compose(self.reset_outside_wall_ancilla_state(block), inplace=True) + for block in blocks: + circuit.compose( + self.reset_outside_wall_ancilla_state( + block, control_on_marker_state=control_on_marker_state + ), + inplace=True, + ) # Re-reset near corner point ancillas point_data: List[Tuple[ReflectionPoint, List[int]]] = [] - for block in self.blocks: + for block in blocks: for dim in range(self.lattice.num_dims): for c, bounds in enumerate( product(*[[False, True]] * self.lattice.num_dims) @@ -132,13 +183,19 @@ def __create_circuit_d2q9(self): # Re-reset the ancilla state of the populations that # Shouldn't have been flipped in the previous step circuit.compose( - self.set_ancilla_of_point_state(point_data, ignore_velocity_data=False), + self.set_ancilla_of_point_state( + point_data, + ignore_velocity_data=False, + control_on_marker_state=control_on_marker_state, + ), inplace=True, ) return circuit - def set_inside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: + def set_inside_wall_ancilla_state( + self, block: Block, control_on_marker_state: bool = False + ) -> QuantumCircuit: """ Sets the state of the ancilla qubit for all the gridpoints lying inside the walls of the block. @@ -179,6 +236,9 @@ def set_inside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: + self.lattice.ancillae_comparator_index() ) + if control_on_marker_state: + control_qubits.extend(self.lattice.marker_index()) + target_qubits = self.lattice.ancillae_obstacle_index(0) circuit.compose( @@ -198,7 +258,9 @@ def set_inside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: return circuit - def reset_outside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: + def reset_outside_wall_ancilla_state( + self, block: Block, control_on_marker_state: bool = False + ) -> QuantumCircuit: """ Resets the state of the obstacle ancilla qubit for all the gridpoints that are directly adjacent to the object, but in the fluid domain. @@ -258,6 +320,9 @@ def reset_outside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: + self.lattice.velocity_index() # The reset step is additionally controlled on the velocity register ) + if control_on_marker_state: + control_qubits.extend(self.lattice.marker_index()) + target_qubits = self.lattice.ancillae_obstacle_index(0) circuit.compose( @@ -283,6 +348,9 @@ def reset_outside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: ] ) + if control_on_marker_state: + control_qubits.extend(self.lattice.marker_index()) + target_qubits = self.lattice.ancillae_obstacle_index(0) circuit.compose( @@ -304,13 +372,13 @@ def reset_outside_wall_ancilla_state(self, block: Block) -> QuantumCircuit: circuit.x(grid_qubit_indices_to_invert) circuit.compose(comparator_circuit, inplace=True) - return circuit def set_ancilla_of_point_state( self, points_data: List[Tuple[ReflectionPoint, List[int]]], ignore_velocity_data: bool, + control_on_marker_state: bool = False, ) -> QuantumCircuit: """ Sets the state of the obstacle ancilla qubit of a given gridpoint, conditioned on the velocity profile. @@ -358,14 +426,14 @@ def set_ancilla_of_point_state( if velocity_qubit_indices_to_invert: circuit.x(velocity_qubit_indices_to_invert) - control_qubits = ( - self.lattice.grid_index() - + ( - self.lattice.velocity_index() - if not ignore_velocity_data - else [] - ) # The reset step is additionally controlled on the velocity register - ) + control_qubits = self.lattice.grid_index() + ( + self.lattice.velocity_index() + if not ignore_velocity_data + else [] + ) # The reset step is additionally controlled on the velocity register + + if control_on_marker_state: + control_qubits.extend(self.lattice.marker_index()) target_qubits = self.lattice.ancillae_obstacle_index(0) @@ -382,24 +450,29 @@ def set_ancilla_of_point_state( circuit.x(velocity_qubit_indices_to_invert) case ABEncodingType.OH: if ignore_velocity_data: + control_qubits = self.lattice.grid_index() + ( + self.lattice.marker_index() + if control_on_marker_state + else [] + ) circuit.compose( MCMTGate( XGate(), - len(self.lattice.grid_index()), + len(control_qubits), len(self.lattice.ancillae_obstacle_index(0)), ), - qubits=self.lattice.grid_index() + qubits=control_qubits + self.lattice.ancillae_obstacle_index(0), inplace=True, ) else: for v in velocities: - control_qubits = ( - self.lattice.grid_index() - + ( - [self.lattice.velocity_index()[v]] - ) # Only one velocity control - ) + control_qubits = self.lattice.grid_index() + ( + [self.lattice.velocity_index()[v]] + ) # Only one velocity control + + if control_on_marker_state: + control_qubits.extend(self.lattice.marker_index()) target_qubits = self.lattice.ancillae_obstacle_index(0) @@ -458,108 +531,4 @@ def permute_and_stream(self) -> QuantumCircuit: @override def __str__(self) -> str: - return f"[Operator ABStreaming with lattice {self.lattice}]" - - -class ABReflectionPermutation(LBMPrimitive): - """ - Permutes velocity state to implement reflection in the amplitude-based encoding for :math:`D_dQ_q` discretizations. - - Example usage: - - .. plot:: - :include-source: - - from qlbm.components.ab import ABEncodingType, ABReflectionPermutation - from qlbm.lattice import LatticeDiscretization - - ABReflectionPermutation(4, LatticeDiscretization.D2Q9, ABEncodingType.AB).draw("mpl") - - """ - - num_qubits: int - """ - The number of qubits that encode the velocity state. - """ - - discretization: LatticeDiscretization - """ - The lattice discretization the permutation adheres to. - """ - - encoding: ABEncodingType - """ - The type of encoding to permute for. - """ - - def __init__( - self, - num_qubits: int, - discretization: LatticeDiscretization, - encoding: ABEncodingType, - logger: Logger = getLogger("qlbm"), - ) -> None: - super().__init__(logger) - - self.num_qubits = num_qubits - self.discretization = discretization - self.encoding = encoding - - self.logger.info(f"Creating circuit {str(self)}...") - circuit_creation_start_time = perf_counter_ns() - self.circuit = self.create_circuit() - self.logger.info( - f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" - ) - - @override - def create_circuit(self) -> QuantumCircuit: - if self.discretization == LatticeDiscretization.D2Q9: - return self.__create_circuit_d2q9() - - raise LatticeException("AB reflection only currently supported in D2Q9") - - def __create_circuit_d2q9(self): - circuit = QuantumCircuit(self.num_qubits) - match self.encoding: - case ABEncodingType.OH: - circuit.swap(1, 3) - circuit.swap(2, 4) - circuit.swap(5, 7) - circuit.swap(6, 8) - - case ABEncodingType.AB: - # 1 <-> 3 - circuit.x([0, 1]) - circuit.mcx([0, 1, 3], 2) - circuit.x([0, 1]) - - # 2 <-> 4 - circuit.x([0, 3]) - circuit.cx(1, 2) - circuit.mcx([0, 2, 3], 1) - circuit.cx(1, 2) - circuit.x([0, 3]) - - # 5 <-> 7 - circuit.x(0) - circuit.mcx([0, 1, 3], 2) - circuit.x(0) - - # 6 <-> 8 - circuit.cx(0, 1) - circuit.cx(0, 2) - circuit.x(3) - circuit.mcx([1, 2, 3], 0) - circuit.cx(0, 2) - circuit.cx(0, 1) - circuit.x(3) - - case _: - raise LatticeException(f"Unsupported lattice encoding: {self.encoding}") - - return circuit.reverse_bits() if self.encoding == ABEncodingType.AB else circuit - - @override - def __str__(self) -> str: - return f"[Primitive ABReflectionPermutation with {self.num_qubits} qubits on {self.discretization}]" + return f"[Operator ABReflection with lattice {self.lattice}]" diff --git a/qlbm/components/ab/streaming.py b/qlbm/components/ab/streaming.py index 745606e..12935cc 100644 --- a/qlbm/components/ab/streaming.py +++ b/qlbm/components/ab/streaming.py @@ -10,7 +10,7 @@ from qlbm.components.ab.encodings import ABEncodingType from qlbm.components.base import LBMOperator -from qlbm.components.ms.streaming import PhaseShift +from qlbm.components.common.adders import PhaseShift from qlbm.lattice.lattices.base import AmplitudeLattice from qlbm.lattice.spacetime.properties_base import LatticeDiscretization from qlbm.tools.exceptions import LatticeException diff --git a/qlbm/components/ab/utils.py b/qlbm/components/ab/utils.py new file mode 100644 index 0000000..541db78 --- /dev/null +++ b/qlbm/components/ab/utils.py @@ -0,0 +1,92 @@ +"""Utilities for the Amplitude-Based QLBM.""" + +from logging import Logger, getLogger +from time import perf_counter_ns + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.quantum_info import Operator +from typing_extensions import override + +from qlbm.components.base import LBMPrimitive +from qlbm.lattice.lattices.ab_lattice import ABLattice + + +class BinaryToOHPermutation(LBMPrimitive): + """ + Permutes the first :math:`q` basis states of the binary encoding into the :math:`q` one-hot states of the OH encoding. + + This operator is implemented as a decomposed permutation matrix. + As such, its decomposition will be exponentially expensive in the number of qubits. + By default, the unitary acts the :math:`q` qubits of the of the one hot encoding (in a :math:`D_dQ_q` discretization). + + Example usage: + .. plot:: + :include-source: + + from qlbm.components.ab import BinaryToOHPermutation + from qlbm.lattice import OHLattice + + lattice = OHLattice( + { + "lattice": {"dim": {"x": 16, "y": 8}, "velocities": "d2q9"}, + } + ) + + BinaryToOHPermutation(lattice).draw("mpl") + """ + + lattice: ABLattice + + def __init__( + self, + lattice: ABLattice, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + self.lattice = lattice + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(*self.lattice.registers) + + n = self.lattice.num_velocity_qubits + dim = 2**n + + perm = [-1] * dim + used_rows = set() + + for j in range(n): + row = 1 << j # 2^j + perm[j] = row + used_rows.add(row) + + # Fill in the rest of the permutation arbitrarily but bijectively. + remaining_rows = [r for r in range(dim) if r not in used_rows] + k = 0 + for col in range(n, dim): + perm[col] = remaining_rows[k] + k += 1 + + U = np.zeros((dim, dim), dtype=complex) + for col in range(dim): + row = perm[col] + U[row, col] = 1.0 + + op = Operator(U) + + circuit = QuantumCircuit(n) + circuit.unitary(op, range(n), label="binary_to_onehot") + + return circuit + + @override + def __str__(self) -> str: + return f"[Primitive BinaryOHPermutation with lattice {self.lattice}]" diff --git a/qlbm/components/common/__init__.py b/qlbm/components/common/__init__.py index 139a116..3c9f2fd 100644 --- a/qlbm/components/common/__init__.py +++ b/qlbm/components/common/__init__.py @@ -1,12 +1,32 @@ """Common primitives used for multiple encodings.""" +from .adders import ParameterizedDraperAdder, ParameterizedPhaseShift, PhaseShift from .cbse_collision import EQCCollisionOperator, EQCPermutation, EQCRedistribution -from .primitives import EmptyPrimitive, HammingWeightAdder +from .comparators import SingleRegisterComparator, TwoRegisterComparator +from .primitives import ( + AdditionConversion, + EmptyPrimitive, + HammingWeightAdder, + MCSwap, + StateSetter, + TruncatedQFT, + UniformStatePrep, +) __all__ = [ + "AdditionConversion", "EmptyPrimitive", "EQCCollisionOperator", "EQCPermutation", "EQCRedistribution", "HammingWeightAdder", + "ParameterizedDraperAdder", + "ParameterizedPhaseShift", + "PhaseShift", + "StateSetter", + "TruncatedQFT", + "UniformStatePrep", + "MCSwap", + "SingleRegisterComparator", + "TwoRegisterComparator", ] diff --git a/qlbm/components/common/adders.py b/qlbm/components/common/adders.py new file mode 100644 index 0000000..6405086 --- /dev/null +++ b/qlbm/components/common/adders.py @@ -0,0 +1,281 @@ +"""Circuits implementing componenets of quantum adders. See :cite:`draper` and :cite:`adder`.""" + +from logging import Logger, getLogger +from math import pi +from time import perf_counter_ns + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.synthesis import synth_qft_full as QFT +from typing_extensions import override + +from qlbm.components.base import LBMPrimitive +from qlbm.tools import bit_value + + +class ParameterizedPhaseShift(LBMPrimitive): + r"""A primitive that applies the phase-shift as part of the :class:`.ParameterizedDraperAdder` used in :class:`.Comparator`\ s. + + The rotation applied is :math:`\pm \frac{\pi}{2^{n_q - 1 - j}}`, with :math:`j` the position of the qubit (indexed starting with 0). + Unlike the regular :class:`.PhaseShift`, the parameterized version additionally adds a phase relative to the number supplied. + For an in-depth mathematical explanation of the procedure, consult Sections 4 and 5.5 of :cite:t:`collisionless`. + + ========================= ====================================================================== + Attribute Summary + ========================= ====================================================================== + :attr:`num_qubits` The number of qubits to perform the phase shift for. + :attr:`positive` Whether the phase shift is applied to increment (T) + or decrement (F) the position of the particles. + Defaults to ``False``. + :attr:`num_to_add` The specific number to add as part of the Draper Adder. + :attr:`logger` The performance logger, by default ``getLogger("qlbm")``. + :attr:`num_ctrl_qubits` The number of qubits to control the PhaseShift. + ========================= ====================================================================== + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common.adders import ParameterizedPhaseShift + + # A phase shift of 5 qubits, adding the number 2 + ParameterizedPhaseShift(num_qubits=5, num_to_add=2, positive=True).draw("mpl") + + Including control qubits: + + .. plot:: + :include-source: + + from qlbm.components.common.adders import ParameterizedPhaseShift + + # A phase shift of 5 qubits, controlled subtracting the number 1 + ParameterizedPhaseShift(num_qubits=5, num_to_add=1, positive=False, num_ctrl_qubits=3).draw("mpl") + """ + + num_qubits: int + """The number of qubits the phase shift is performed on.""" + + num_to_add: int + """The number to add to the basis states encoded in the qubits.""" + + positive: bool + """Whether the operation is an addition or a subtraction.""" + + num_ctrl_qubits: int + """Optional additional qubits to control the operation on. If any, the control qubits trail the target qubits.""" + + def __init__( + self, + num_qubits: int, + num_to_add: int, + positive: bool = False, + num_ctrl_qubits: int = 0, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.num_qubits = num_qubits + self.num_to_add = num_to_add + self.positive = positive + self.num_ctrl_qubits = num_ctrl_qubits + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(self.num_qubits + self.num_ctrl_qubits) + angles = np.zeros(self.num_qubits) + + for qubit_index in range(self.num_qubits): + dig = bit_value(self.num_to_add, qubit_index) + for i in range(self.num_qubits - qubit_index): + # (2 * positive - 1) will flip the sign if positive is False + # This effectively inverts the circuit + angles[i] += ( + (2 * self.positive - 1) + * dig + * pi + / (2 ** (self.num_qubits - qubit_index - i - 1)) + ) + + for qubit_index in range(self.num_qubits): + if self.num_ctrl_qubits == 0: + circuit.p(angles[qubit_index], qubit_index) + else: + circuit.mcp( + angles[qubit_index], + list( + range(self.num_qubits, self.num_qubits + self.num_ctrl_qubits) + ), + qubit_index, + ) + + return circuit + + @override + def __str__(self) -> str: + return f"[Primitive ParameterizedPhaseShift of {self.num_qubits} qubits, num {self.num_to_add}, in direction {self.positive}, ctrl {self.num_ctrl_qubits}]" + + +class ParameterizedDraperAdder(LBMPrimitive): + r"""A QFT-based incrementer used to perform streaming in the algorithms based on amplitude encodings. + + Incrementation and decerementation are performed as rotations on grid qubits + that have been previously mapped to the Fourier basis. + This happens by nesting a :class:`.ParameterizedPhaseShift` primitive + between regular and inverse :math:`QFT`\ s. + + ========================= ====================================================================== + Attribute Summary + ========================= ====================================================================== + :attr:`num_qubits` Number of qubits of the circuit. + :attr:`num_to_add`. The number to add. + :attr:`positive` Whether to increment in in the positive (T) or negative (F) direction. + :attr:`num_ctrl_qubits` The number of qubits to control the PhaseShift. + :attr:`logger` The performance logger, by default getLogger("qlbm") + ========================= ====================================================================== + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common.adders import ParameterizedDraperAdder + + ParameterizedDraperAdder(4, 1, True).draw("mpl") + """ + + num_qubits: int + """The number of qubits the phase shift is performed on.""" + + num_to_add: int + """The number to add to the basis states encoded in the qubits.""" + + positive: bool + """Whether the operation is an addition or a subtraction.""" + + num_ctrl_qubits: int + """Optional additional qubits to control the operation on. If any, the control qubits trail the target qubits.""" + + def __init__( + self, + num_qubits: int, + num_to_add: int, + positive: bool, + num_ctrl_qubits: int = 0, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + self.num_qubits = num_qubits + self.num_to_add = num_to_add + self.positive = positive + self.num_ctrl_qubits = num_ctrl_qubits + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(self.num_qubits + self.num_ctrl_qubits) + + circuit.compose( + QFT(self.num_qubits), inplace=True, qubits=list(range(self.num_qubits)) + ) + circuit.compose( + ParameterizedPhaseShift( + self.num_qubits, + self.num_to_add, + self.positive, + num_ctrl_qubits=self.num_ctrl_qubits, + logger=self.logger, + ).circuit, + inplace=True, + ) + circuit.compose( + QFT(self.num_qubits, inverse=True), + inplace=True, + qubits=list(range(self.num_qubits)), + ) + + return circuit + + @override + def __str__(self) -> str: + return f"[Primitive SimpleAdder on {self.num_qubits} qubits, on velocity {self.num_to_add}, in direction {self.positive}]" + + +class PhaseShift(LBMPrimitive): + r""" + A primitive that applies the phase-shift as part of the :class:`.ControlledIncrementer` used in the :class:`.MSStreamingOperator`. + + The rotation applied is :math:`\pm\frac{\pi}{2^{n_q - 1 - j}}`, with :math:`j` the position of the qubit (indexed starting with 0). + For an in-depth mathematical explanation of the procedure and its use within QLBM, + consult Section 4 of :cite:t:`collisionless`. + The Draper adder was originally formulated in :cite:`draper`, while the version implemented + here uses the one-register approach, which was + first described in :cite:`qftadder`. + + ========================= ====================================================================== + Attribute Summary + ========================= ====================================================================== + :attr:`num_qubits` The number of qubits to perform the phase shift for. + :attr:`positive` Whether the phase shift is applied to increment (T) + or decrement (F) the position of the particles. + Defaults to ``False``. + :attr:`logger` The performance logger, by default ``getLogger("qlbm")``. + ========================= ====================================================================== + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common.adders import PhaseShift + + # A phase shift of 5 qubits + PhaseShift(num_qubits=5, positive=False).draw("mpl") + """ + + def __init__( + self, + num_qubits: int, + positive: bool = False, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.num_qubits = num_qubits + self.positive = positive + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(self.num_qubits) + + for c, qubit_index in enumerate(range(self.num_qubits)): + # (2 * positive - 1) will flip the sign if positive is False + # This effectively inverts the circuit + phase = (2 * self.positive - 1) * pi / (2 ** (self.num_qubits - 1 - c)) + circuit.p(phase, qubit_index) + + return circuit + + @override + def __str__(self) -> str: + return f"[Primitive PhaseShift of {self.num_qubits} qubits, in direction {self.positive}]" diff --git a/qlbm/components/common/comparators.py b/qlbm/components/common/comparators.py new file mode 100644 index 0000000..122d03f --- /dev/null +++ b/qlbm/components/common/comparators.py @@ -0,0 +1,203 @@ +"""Quantum circuits that perform arithmetic comparison operations.""" + +from logging import Logger, getLogger +from time import perf_counter_ns +from typing import List + +from qiskit import QuantumCircuit +from qiskit.circuit.library import DraperQFTAdder +from typing_extensions import override + +from qlbm.components.base import LBMPrimitive +from qlbm.components.common.adders import ParameterizedDraperAdder +from qlbm.tools.utils import ComparatorMode + + +class TwoRegisterComparator(LBMPrimitive): + """ + Quantum comparator primitive that compares the states of 2 registers of ``num_qubits`` qubits a :class:`.ComparatorMode`. + + The generate circuit is of size ``2*num_qubits+1``, where the last qubit of the register holds the boolean result. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common.comparators import TwoRegisterComparator + from qlbm.tools.utils import ComparatorMode + + # Compare two registers of size 4 + TwoRegisterComparator(num_qubits=4, mode=ComparatorMode.LT).draw("mpl") + """ + + def __init__( + self, + num_qubits: int, + mode: ComparatorMode, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.num_qubits = num_qubits + self.mode = mode + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + circuit = QuantumCircuit(2 * self.num_qubits + 1) + x_register = list(range(self.num_qubits)) + y_register = list(range(self.num_qubits, 2 * self.num_qubits)) + output_qubit = 2 * self.num_qubits + + match self.mode: + case ComparatorMode.GT: + self.__compose_gt(circuit, x_register, y_register, output_qubit) + case ComparatorMode.LE: + self.__compose_gt(circuit, x_register, y_register, output_qubit) + circuit.x(output_qubit) + case ComparatorMode.LT: + self.__compose_gt(circuit, y_register, x_register, output_qubit) + case ComparatorMode.GE: + self.__compose_gt(circuit, y_register, x_register, output_qubit) + circuit.x(output_qubit) + case _: + raise ValueError("Invalid Comparator Mode") + + return circuit + + def __compose_gt( + self, + circuit: QuantumCircuit, + x_register: List[int], + y_register: List[int], + output_qubit: int, + ) -> None: + add_half = DraperQFTAdder(self.num_qubits, kind="half") + add_fixed_inv = DraperQFTAdder(self.num_qubits, kind="fixed").inverse() + + circuit.x(y_register) + circuit.compose( + add_half, + qubits=x_register + y_register + [output_qubit], + inplace=True, + ) + circuit.compose( + add_fixed_inv, + qubits=x_register + y_register, + inplace=True, + ) + circuit.x(y_register) + + @override + def __str__(self) -> str: + return f"[Primitive TwoRegisterComparator of {self.num_qubits} qubits, mode={self.mode}]" + + +class SingleRegisterComparator(LBMPrimitive): + """ + Quantum comparator primitive that compares a quantum state of ``num_qubits`` qubits and an integer ``num_to_compare`` with respect to a :class:`.ComparatorMode`. + + ========================= ====================================================================== + Attribute Summary + ========================= ====================================================================== + :attr:`num_qubits` Number of qubits encoding the integer to compare. + :attr:`num_to_compare` The integer to compare against. + :attr:`mode` The :class:`.ComparatorMode` used to compare the two numbers. + :attr:`logger` The performance logger, by default getLogger("qlbm") + ========================= ====================================================================== + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common.comparators import SingleRegisterComparator + from qlbm.tools.utils import ComparatorMode + + # On a 5 qubit register, compare the number 3 + SingleRegisterComparator(num_qubits=5, + num_to_compare=3, + mode=ComparatorMode.LT).draw("mpl") + """ + + def __init__( + self, + num_qubits: int, + num_to_compare: int, + mode: ComparatorMode, + logger: Logger = getLogger("qlbm"), + ) -> None: + super().__init__(logger) + + self.num_qubits = num_qubits + self.num_to_compare = num_to_compare + self.mode = mode + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self) -> QuantumCircuit: + return self.__create_circuit(self.num_qubits, self.num_to_compare, self.mode) + + def __create_circuit( + self, num_qubits: int, num_to_compare: int, mode: ComparatorMode + ) -> QuantumCircuit: + circuit = QuantumCircuit(num_qubits) + + match mode: + case ComparatorMode.LT: + circuit.compose( + ParameterizedDraperAdder( + num_qubits, num_to_compare, positive=False, logger=self.logger + ).circuit, + inplace=True, + ) + circuit.compose( + ParameterizedDraperAdder( + num_qubits - 1, + num_to_compare, + positive=True, + logger=self.logger, + ).circuit, + inplace=True, + qubits=range(num_qubits - 1), + ) + return circuit + case ComparatorMode.LE: + if num_to_compare == 2 ** (num_qubits - 1) - 1: + return self.__create_circuit(num_qubits, 0, ComparatorMode.GE) + + return self.__create_circuit( + num_qubits, num_to_compare + 1, ComparatorMode.LT + ) + case ComparatorMode.GT: + if num_to_compare == 2 ** (num_qubits - 1) - 1: + return circuit + else: + return self.__create_circuit( + num_qubits, num_to_compare + 1, ComparatorMode.GE + ) + case ComparatorMode.GE: + circuit = self.__create_circuit( + num_qubits, num_to_compare, ComparatorMode.LT + ) + circuit.x(num_qubits - 1) + return circuit + case _: + raise ValueError("Invalid Comparator Mode") + + @override + def __str__(self) -> str: + return f"[Primitive Comparator of {self.num_qubits} and {self.num_to_compare}, mode={self.mode}]" diff --git a/qlbm/components/common/primitives.py b/qlbm/components/common/primitives.py index cb2a4cf..d16256b 100644 --- a/qlbm/components/common/primitives.py +++ b/qlbm/components/common/primitives.py @@ -7,13 +7,16 @@ import numpy as np from numpy import pi from qiskit import QuantumCircuit -from qiskit.circuit.library import MCMTGate, XGate +from qiskit.circuit.library import HGate, MCMTGate, XGate from qiskit.quantum_info import Operator from qiskit.synthesis import synth_qft_full as QFT from typing_extensions import override from qlbm.components.base import LBMPrimitive +from qlbm.components.common.adders import ParameterizedDraperAdder from qlbm.lattice import Lattice +from qlbm.tools.exceptions import CircuitException +from qlbm.tools.utils import get_qubits_to_invert class EmptyPrimitive(LBMPrimitive): @@ -26,7 +29,7 @@ class EmptyPrimitive(LBMPrimitive): ========================= ====================================================================== Attribute Summary ========================= ====================================================================== - :attr:`lattice` The :class:`.MSLattice` or :class:`.SpaceTimeLattice` based on which the number of qubits is inferred. + :attr:`lattice` The :class:`.Lattice` based on which the number of qubits is inferred. :attr:`logger` The performance logger, by default ``getLogger("qlbm")``. ========================= ====================================================================== """ @@ -118,6 +121,16 @@ class HammingWeightAdder(LBMPrimitive): This primitive adds the hamming weight (number of 1s) in a given register :math:`x` to the binary-encoded value of a second register :math:`y`. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common import HammingWeightAdder + + # Add the Hamming weight of a 3-qubit register onto a 5-qubit register + HammingWeightAdder(3, 5).draw("mpl") """ x_register_size: int @@ -187,11 +200,11 @@ class TruncatedQFT(LBMPrimitive): For a superposition of the first :math:`k` basis states encoded in :math:`n` qubits, the operator consists of discrete fourier transform block of size :math:`k\times k`, - padded with :math:`2^n - k` :math:`1`s on the main diagonal. + padded with :math:`2^n - k` :math:`1`\ s on the main diagonal. The rationale and properties of this operator are described in :cite:`spacetime2`. This primitive is used in both amplitude-based and computational basis state encodings. - In the :class:`ABInitialConditions`, it creates an equal magnitude superposition over the velocity space. - In the :class:`EQCRedistribution`, the superposition is over all basis states with an equivalent mass and momenta. + In the :class:`.ABInitialConditions`, it creates an equal magnitude superposition over the velocity space. + In the :class:`.EQCRedistribution`, the superposition is over all basis states with an equivalent mass and momenta. Example usage: @@ -200,7 +213,7 @@ class TruncatedQFT(LBMPrimitive): from qlbm.components.common import TruncatedQFT - TruncatedQFT(4, 7).decompose(reps=2).draw("mpl") + TruncatedQFT(4, 5).circuit.decompose(reps=2).draw("mpl") """ num_qubits: int @@ -252,3 +265,370 @@ def create_circuit(self): @override def __str__(self): return f"[Primitive TuncatedQFT({self.num_qubits}, {self.dft_size})]" + + +class UniformStatePrep(LBMPrimitive): + r"""Efficient uniform state preparation primitive used to create an equal magnitude superposition over the first :math:`k` basis states. + + This is an implementation of Algorithm 1 described by :cite:t:`uniprep`. + It is used to create an uniform magnitude superposition over arbitrary + velocity states in :class:`.ABDiscreteUniformInitialConditions`. + + Example usage: + + .. plot:: + :include-source: + + from qlbm.components.common import UniformStatePrep + + UniformStatePrep(4, 5).draw("mpl") + """ + + num_qubits: int + """The number of qubits the operator acts on.""" + + num_states: int + """The number of states to generate.""" + + def __init__( + self, + num_qubits: int, + num_states: int, + num_ctrl_qubits: int = 0, + logger: Logger = getLogger("qlbm"), + ): + super().__init__(logger) + self.num_qubits = num_qubits + self.num_states = num_states + self.num_ctrl_qubits = num_ctrl_qubits + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self): + circuit = QuantumCircuit( + self.num_qubits + self.num_ctrl_qubits, + name=f"UniformStatePrep{self.num_states}", + ) + + ctrl_qubits = ( + list(range(self.num_qubits, self.num_qubits + self.num_ctrl_qubits)) + if self.num_ctrl_qubits > 0 + else [] + ) + + # M = 1 : do nothing, stays in |0...0> + if self.num_states == 1: + return circuit + + # If M is a power of two, the solution is trivial: Hadamards on log2(M) qubits + is_power_of_two = (self.num_states & (self.num_states - 1)) == 0 + if is_power_of_two: + r = int(np.log2(self.num_states)) + for q in range(r): + if ctrl_qubits: + circuit.compose( + MCMTGate(HGate(), self.num_ctrl_qubits, 1), + qubits=ctrl_qubits + [q], + inplace=True, + ) + else: + circuit.h(q) + + return circuit + + # --- General case: Algorithm 1 (Section 2.1 of the paper) --- + + # We only need n_eff = ceil(log2 M) active qubits; the rest stay in |0> + n_eff = self._ceil_log2_M(self.num_states) + if n_eff > self.num_qubits: + raise CircuitException("Internal error: n_eff > num_qubits.") + + # Binary decomposition: M = \Sum_j 2^{l_j}, with 0 <= l0 < l1 < ... < lk + bit_positions = [i for i in range(n_eff) if (self.num_states >> i) & 1] + bit_positions.sort() + l0 = bit_positions[0] + k = len(bit_positions) - 1 # number of "higher" bits + + # Helper: safe acos for numerical stability + def safe_acos(x: float) -> float: + return np.acos(max(-1.0, min(1.0, x))) + + # Step 4: Apply X on qubits at positions l1, l2, ..., lk + for j in range(1, len(bit_positions)): + if ctrl_qubits: + circuit.mcx(control_qubits=ctrl_qubits, target_qubit=bit_positions[j]) + else: + circuit.x(bit_positions[j]) + + # Step 5: M0 = 2^{l0} + M_prev = 2**l0 # This is M_0 in the paper + + # Step 6–7: If l0 > 0, apply H on qubits 0..(l0-1) + if l0 > 0: + for q in range(l0): + if ctrl_qubits: + circuit.compose( + MCMTGate(HGate(), self.num_ctrl_qubits, 1), + qubits=ctrl_qubits + [q], + inplace=True, + ) + else: + circuit.h(q) + + # Step 8: Apply RY(theta0) on |q_{l1}>, theta0 = -2 arccos( sqrt(M0 / M) ) + l1 = bit_positions[1] + theta0 = -2.0 * safe_acos(np.sqrt(M_prev / self.num_states)) + + if ctrl_qubits: + circuit.mcry(theta0, ctrl_qubits, l1) + else: + circuit.ry(theta0, l1) + + # Step 9: Controlled H on qubits i in [l0, l1) with open control on q_{l1} == |0> + ctrl = l1 + + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl) + else: + circuit.x(ctrl) # convert open control (on |0>) to normal control (on |1>) + + for i in range(l0, l1): + circuit.compose( + MCMTGate(HGate(), self.num_ctrl_qubits + 1, 1), + qubits=ctrl_qubits + [ctrl, i], + inplace=True, + ) + + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl) + else: + circuit.x(ctrl) + + # Steps 10–13: For-loop over remaining bits + for m in range(1, k): + l_m = bit_positions[m] + l_next = bit_positions[m + 1] + + # Step 11: Controlled RY(theta_m) on q_{l_{m+1}} with open control on q_{l_m} == |0> + numerator = 2**l_m + denominator = self.num_states - M_prev + theta_m = -2.0 * safe_acos(np.sqrt(numerator / denominator)) + + # open control on q_{l_m} + ctrl = l_m + target = l_next + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl) + else: + circuit.x(ctrl) + circuit.mcry(theta_m, ctrl_qubits + [ctrl], target) + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl) + else: + circuit.x(ctrl) + + # Step 12: Controlled H on qubits i in [l_m, l_{m+1}) with open control on q_{l_{m+1}} == |0> + ctrl_next = l_next + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl_next) + else: + circuit.x(ctrl_next) + for i in range(l_m, l_next): + circuit.compose( + MCMTGate(HGate(), self.num_ctrl_qubits + 1, 1), + qubits=ctrl_qubits + [ctrl_next] + [i], + inplace=True, + ) + if ctrl_qubits: + circuit.mcx(ctrl_qubits, ctrl_next) + else: + circuit.x(ctrl_next) + + # Step 13: M_m = M_{m-1} + 2^{l_m} + M_prev += 2**l_m + + return circuit + + def _ceil_log2_M(self, M: int) -> int: + """Minimal number of qubits n such that M <= 2**n.""" + if M <= 1: + return 1 + # Power of two? + if M & (M - 1) == 0: + return int(np.log2(M)) + # Non power-of-two + return M.bit_length() + + @override + def __str__(self): + return f"[Primitive UniformStatePrep({self.num_qubits}, {self.num_states})]" + + +class AdditionConversion(LBMPrimitive): + """ + Converts one basis state to another by incrementation/decrementation. + + Useful for performing permutations in which the initial superposition contains no basis states + of the target superposition. + + The circuit utilizes a :class:`.ParameterizedDraperAdder` which controlled on the state + of an ancilla qubit to add the difference only to the target basis state. + + + .. plot:: + :include-source: + + from qlbm.components.common import AdditionConversion + + AdditionConversion(4, 2, 7).draw("mpl") + """ + + num_qubits: int + """The number of qubits the states are encoded in.""" + + state_from: int + """The starting state to convert.""" + + state_to: int + """The state to convert to.""" + + num_ctrl_qubits: int + """The number of qubits to control the operation.""" + + def __init__( + self, + num_qubits: int, + state_from: int, + state_to: int, + num_ctrl_qubits: int = 0, + logger: Logger = getLogger("qlbm"), + ): + super().__init__(logger) + self.num_qubits = num_qubits + self.state_from = state_from + self.state_to = state_to + self.num_ctrl_qubits = num_ctrl_qubits + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self): + circuit = QuantumCircuit(self.num_qubits + self.num_ctrl_qubits + 1) + + state_setter_circ = StateSetter( + self.num_qubits, self.state_from, self.logger + ).circuit + + circuit.compose( + state_setter_circ, qubits=list(range(self.num_qubits)), inplace=True + ) + circuit.mcx( + list(range(self.num_qubits)) + + list( + range(self.num_qubits + 1, self.num_qubits + 1 + self.num_ctrl_qubits) + ), + self.num_qubits, + ) + circuit.compose( + state_setter_circ, qubits=list(range(self.num_qubits)), inplace=True + ) + + circuit.compose( + ParameterizedDraperAdder( + self.num_qubits, + abs(self.state_to - self.state_from), + self.state_to > self.state_from, + self.num_ctrl_qubits + 1, + self.logger, + ).circuit, + inplace=True, + ) + + state_setter_circ = StateSetter( + self.num_qubits, self.state_to, self.logger + ).circuit + + circuit.compose( + state_setter_circ, qubits=list(range(self.num_qubits)), inplace=True + ) + circuit.mcx( + list(range(self.num_qubits)) + + list( + range(self.num_qubits + 1, self.num_qubits + 1 + self.num_ctrl_qubits) + ), + self.num_qubits, + ) + circuit.compose( + state_setter_circ, qubits=list(range(self.num_qubits)), inplace=True + ) + + return circuit + + @override + def __str__(self): + return f"[Primitive AdditionConversion({self.num_qubits}, {self.state_from}, {self.state_to})]" + + +class StateSetter(LBMPrimitive): + r""" + Permutes the superposition such that a target state :math:`\ket{k}` is permuted to :math:`\ket{1}^{\otimes n}`. + + The primitive acts a single layer of :math:`\mathrm{X}` gates on the qubit + indices that have value :math:`\ket{0}` for the input state. + + .. plot:: + :include-source: + + from qlbm.components.common import StateSetter + + StateSetter(4, 6).draw("mpl") + """ + + num_qubits: int + """The number of qubits the state is encoded in.""" + + state_to_set: int + """The state to convert to :math:`\ket{1}^{\otimes n}`""" + + def __init__( + self, + num_qubits: int, + state_to_set: int, + logger: Logger = getLogger("qlbm"), + ): + super().__init__(logger) + self.num_qubits = num_qubits + self.state_to_set = state_to_set + + self.logger.info(f"Creating circuit {str(self)}...") + circuit_creation_start_time = perf_counter_ns() + self.circuit = self.create_circuit() + self.logger.info( + f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" + ) + + @override + def create_circuit(self): + circuit = QuantumCircuit(self.num_qubits) + + qs = get_qubits_to_invert(self.state_to_set, self.num_qubits) + + if qs: + circuit.x(qs) + + return circuit + + @override + def __str__(self): + return f"[Primitive StateSetter({self.num_qubits}, {self.state_to_set}]" diff --git a/qlbm/components/ms/__init__.py b/qlbm/components/ms/__init__.py index 8652182..136dc72 100644 --- a/qlbm/components/ms/__init__.py +++ b/qlbm/components/ms/__init__.py @@ -6,35 +6,25 @@ ) from .msqlbm import MSQLBM from .primitives import ( - Comparator, - ComparatorMode, EdgeComparator, GridMeasurement, MSInitialConditions, MSInitialConditions3DSlim, - SpeedSensitiveAdder, ) from .specular_reflection import SpecularReflectionOperator, SpecularWallComparator from .streaming import ( ControlledIncrementer, MSStreamingOperator, - PhaseShift, - SpeedSensitivePhaseShift, StreamingAncillaPreparation, ) __all__ = [ - "ComparatorMode", - "Comparator", - "SpeedSensitiveAdder", "StreamingAncillaPreparation", "ControlledIncrementer", "GridMeasurement", "EdgeComparator", "MSInitialConditions", "MSInitialConditions3DSlim", - "PhaseShift", - "SpeedSensitivePhaseShift", "MSStreamingOperator", "SpecularReflectionOperator", "SpecularWallComparator", diff --git a/qlbm/components/ms/bounceback_reflection.py b/qlbm/components/ms/bounceback_reflection.py index 5acc32b..dae0ec4 100644 --- a/qlbm/components/ms/bounceback_reflection.py +++ b/qlbm/components/ms/bounceback_reflection.py @@ -9,9 +9,8 @@ from typing_extensions import override from qlbm.components.base import LBMPrimitive, MSOperator -from qlbm.components.ms.primitives import ( - Comparator, - ComparatorMode, +from qlbm.components.common.comparators import ( + SingleRegisterComparator, ) from qlbm.components.ms.specular_reflection import SpecularWallComparator from qlbm.lattice import MSLattice @@ -22,7 +21,7 @@ ) from qlbm.lattice.geometry.shapes.block import Block from qlbm.tools.exceptions import CircuitException -from qlbm.tools.utils import flatten +from qlbm.tools.utils import ComparatorMode, flatten from .primitives import EdgeComparator from .streaming import ControlledIncrementer @@ -84,7 +83,7 @@ def create_circuit(self) -> QuantumCircuit: # If the wall is inside the object, we build the comparators # Differently, as to not overlap lb_comparators = [ - Comparator( + SingleRegisterComparator( self.lattice.num_gridpoints[wall_alignment_dim].bit_length() + 1, self.wall.lower_bounds[c], ComparatorMode.GE @@ -96,7 +95,7 @@ def create_circuit(self) -> QuantumCircuit: ] ub_comparators = [ - Comparator( + SingleRegisterComparator( self.lattice.num_gridpoints[wall_alignment_dim].bit_length() + 1, self.wall.upper_bounds[c], ComparatorMode.LE diff --git a/qlbm/components/ms/primitives.py b/qlbm/components/ms/primitives.py index 07a3491..f1d8515 100644 --- a/qlbm/components/ms/primitives.py +++ b/qlbm/components/ms/primitives.py @@ -1,19 +1,18 @@ """Primitives for the implementation of the Collisionless Quantum Lattice Boltzmann Method introduced in :cite:t:`collisionless`.""" -from enum import Enum from logging import Logger, getLogger from time import perf_counter_ns from typing import List from qiskit import ClassicalRegister, QuantumCircuit -from qiskit.synthesis import synth_qft_full as QFT from typing_extensions import override from qlbm.components.base import LBMPrimitive -from qlbm.components.ms.streaming import SpeedSensitivePhaseShift +from qlbm.components.common.comparators import SingleRegisterComparator from qlbm.lattice import MSLattice from qlbm.lattice.geometry.encodings.ms import ReflectionResetEdge from qlbm.tools import flatten +from qlbm.tools.utils import ComparatorMode class GridMeasurement(LBMPrimitive): @@ -268,194 +267,6 @@ def __str__(self) -> str: return f"[Primitive InitialConditions with lattice {self.lattice}]" -class ComparatorMode(Enum): - r"""Enumerator for the modes of quantum comparator circuits. - - The modes are as follows: - - * (1, ``ComparatorMode.LT``, :math:`<`); - * (2, ``ComparatorMode.LE``, :math:`\leq`); - * (3, ``ComparatorMode.GT``, :math:`>`); - * (4, ``ComparatorMode.GE``, :math:`\geq`). - """ - - LT = (1,) - LE = (2,) - GT = (3,) - GE = (4,) - - -class SpeedSensitiveAdder(LBMPrimitive): - r"""A QFT-based incrementer used to perform streaming in the algorithms based on amplitude encodings. - - Incrementation and decerementation are performed as rotations on grid qubits - that have been previously mapped to the Fourier basis. - This happens by nesting a :class:`.SpeedSensitivePhaseShift` primitive - between regular and inverse :math:`QFT`\ s. - - ========================= ====================================================================== - Attribute Summary - ========================= ====================================================================== - :attr:`num_qubits` Number of qubits of the circuit. - :attr:`speed` The index of the speed to increment. - :attr:`positive` Whether to increment the particles traveling at this speed in the positive (T) or negative (F) direction. - :attr:`logger` The performance logger, by default getLogger("qlbm") - ========================= ====================================================================== - - Example usage: - - .. plot:: - :include-source: - - from qlbm.components.ms import SpeedSensitiveAdder - - SpeedSensitiveAdder(4, 1, True).draw("mpl") - """ - - def __init__( - self, - num_qubits: int, - speed: int, - positive: bool, - logger: Logger = getLogger("qlbm"), - ) -> None: - super().__init__(logger) - self.num_qubits = num_qubits - self.speed = speed - self.positive = positive - - self.logger.info(f"Creating circuit {str(self)}...") - circuit_creation_start_time = perf_counter_ns() - self.circuit = self.create_circuit() - self.logger.info( - f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" - ) - - @override - def create_circuit(self) -> QuantumCircuit: - circuit = QuantumCircuit(self.num_qubits) - - circuit.compose(QFT(self.num_qubits), inplace=True) - circuit.compose( - SpeedSensitivePhaseShift( - self.num_qubits, - self.speed, - self.positive, - logger=self.logger, - ).circuit, - inplace=True, - ) - circuit.compose(QFT(self.num_qubits, inverse=True), inplace=True) - - return circuit - - @override - def __str__(self) -> str: - return f"[Primitive SimpleAdder on {self.num_qubits} qubits, on velocity {self.speed}, in direction {self.positive}]" - - -class Comparator(LBMPrimitive): - """ - Quantum comparator primitive that compares two a quantum state of ``num_qubits`` qubits and an integer ``num_to_compare`` with respect to a :class:`.ComparatorMode`. - - ========================= ====================================================================== - Attribute Summary - ========================= ====================================================================== - :attr:`num_qubits` Number of qubits encoding the integer to compare. - :attr:`num_to_compare` The integer to compare against. - :attr:`mode` The :class:`.ComparatorMode` used to compare the two numbers. - :attr:`logger` The performance logger, by default getLogger("qlbm") - ========================= ====================================================================== - - Example usage: - - .. plot:: - :include-source: - - from qlbm.components.ms import Comparator, ComparatorMode - - # On a 5 qubit register, compare the number 3 - Comparator(num_qubits=5, - num_to_compare=3, - mode=ComparatorMode.LT).draw("mpl") - """ - - def __init__( - self, - num_qubits: int, - num_to_compare: int, - mode: ComparatorMode, - logger: Logger = getLogger("qlbm"), - ) -> None: - super().__init__(logger) - - self.num_qubits = num_qubits - self.num_to_compare = num_to_compare - self.mode = mode - - self.logger.info(f"Creating circuit {str(self)}...") - circuit_creation_start_time = perf_counter_ns() - self.circuit = self.create_circuit() - self.logger.info( - f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" - ) - - @override - def create_circuit(self) -> QuantumCircuit: - return self.__create_circuit(self.num_qubits, self.num_to_compare, self.mode) - - def __create_circuit( - self, num_qubits: int, num_to_compare: int, mode: ComparatorMode - ) -> QuantumCircuit: - circuit = QuantumCircuit(num_qubits) - - match mode: - case ComparatorMode.LT: - circuit.compose( - SpeedSensitiveAdder( - num_qubits, num_to_compare, positive=False, logger=self.logger - ).circuit, - inplace=True, - ) - circuit.compose( - SpeedSensitiveAdder( - num_qubits - 1, - num_to_compare, - positive=True, - logger=self.logger, - ).circuit, - inplace=True, - qubits=range(num_qubits - 1), - ) - return circuit - case ComparatorMode.LE: - if num_to_compare == 2 ** (num_qubits - 1) - 1: - return self.__create_circuit(num_qubits, 0, ComparatorMode.GE) - - return self.__create_circuit( - num_qubits, num_to_compare + 1, ComparatorMode.LT - ) - case ComparatorMode.GT: - if num_to_compare == 2 ** (num_qubits - 1) - 1: - return circuit - else: - return self.__create_circuit( - num_qubits, num_to_compare + 1, ComparatorMode.GE - ) - case ComparatorMode.GE: - circuit = self.__create_circuit( - num_qubits, num_to_compare, ComparatorMode.LT - ) - circuit.x(num_qubits - 1) - return circuit - case _: - raise ValueError("Invalid Comparator Mode") - - @override - def __str__(self) -> str: - return f"[Primitive Comparator of {self.num_qubits} and {self.num_to_compare}, mode={self.mode}]" - - class EdgeComparator(LBMPrimitive): """ A primitive used in the 3D collisionless :class:`SpecularReflectionOperator` and :class:`BounceBackReflectionOperator` described in :cite:t:`collisionless`. @@ -506,13 +317,13 @@ def __init__( @override def create_circuit(self) -> QuantumCircuit: circuit = self.lattice.circuit.copy() - lb_comparator = Comparator( + lb_comparator = SingleRegisterComparator( self.lattice.num_gridpoints[self.edge.dim_disconnected].bit_length() + 1, self.edge.bounds_disconnected_dim[0], ComparatorMode.GE, logger=self.logger, ).circuit - ub_comparator = Comparator( + ub_comparator = SingleRegisterComparator( self.lattice.num_gridpoints[self.edge.dim_disconnected].bit_length() + 1, self.edge.bounds_disconnected_dim[1], ComparatorMode.LE, diff --git a/qlbm/components/ms/specular_reflection.py b/qlbm/components/ms/specular_reflection.py index 1930ad3..31c4682 100644 --- a/qlbm/components/ms/specular_reflection.py +++ b/qlbm/components/ms/specular_reflection.py @@ -9,9 +9,8 @@ from typing_extensions import override from qlbm.components.base import LBMPrimitive, MSOperator -from qlbm.components.ms.primitives import ( - Comparator, - ComparatorMode, +from qlbm.components.common.comparators import ( + SingleRegisterComparator, ) from qlbm.lattice import ( MSLattice, @@ -24,7 +23,7 @@ from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.lattices.base import AmplitudeLattice from qlbm.tools.exceptions import CircuitException -from qlbm.tools.utils import flatten +from qlbm.tools.utils import ComparatorMode, flatten from .primitives import EdgeComparator from .streaming import ControlledIncrementer @@ -84,7 +83,7 @@ def create_circuit(self) -> QuantumCircuit: circuit = self.lattice.circuit.copy() lb_comparators = [ - Comparator( + SingleRegisterComparator( self.lattice.num_gridpoints[wall_alignment_dim].bit_length() + 1, self.wall.lower_bounds[c], ComparatorMode.GE, @@ -94,7 +93,7 @@ def create_circuit(self) -> QuantumCircuit: ] ub_comparators = [ - Comparator( + SingleRegisterComparator( self.lattice.num_gridpoints[wall_alignment_dim].bit_length() + 1, self.wall.upper_bounds[c], ComparatorMode.LE, diff --git a/qlbm/components/ms/streaming.py b/qlbm/components/ms/streaming.py index 984a87c..8d99daa 100644 --- a/qlbm/components/ms/streaming.py +++ b/qlbm/components/ms/streaming.py @@ -1,17 +1,16 @@ """Quantum circuits for the implementation of QFT-based streaming as described in :cite:t:`collisionless`.""" from logging import Logger, getLogger -from math import pi from time import perf_counter_ns from typing import List -import numpy as np from qiskit import QuantumCircuit from qiskit.circuit.library import MCXGate from qiskit.synthesis import synth_qft_full as QFT from typing_extensions import override from qlbm.components.base import LBMPrimitive, MSOperator +from qlbm.components.common.adders import PhaseShift from qlbm.lattice import MSLattice from qlbm.tools import CircuitException, bit_value @@ -326,142 +325,3 @@ def __str__(self) -> str: return ( f"[Operator StreamingOperator for velocities {self.velocities_to_stream}]" ) - - -class PhaseShift(LBMPrimitive): - r""" - A primitive that applies the phase-shift as part of the :class:`.ControlledIncrementer` used in the :class:`.MSStreamingOperator`. - - The rotation applied is :math:`\pm\frac{\pi}{2^{n_q - 1 - j}}`, with :math:`j` the position of the qubit (indexed starting with 0). - For an in-depth mathematical explanation of the procedure, consult Section 4 of :cite:t:`collisionless`. - - ========================= ====================================================================== - Attribute Summary - ========================= ====================================================================== - :attr:`num_qubits` The number of qubits to perform the phase shift for. - :attr:`positive` Whether the phase shift is applied to increment (T) - or decrement (F) the position of the particles. - Defaults to ``False``. - :attr:`logger` The performance logger, by default ``getLogger("qlbm")``. - ========================= ====================================================================== - - Example usage: - - .. plot:: - :include-source: - - from qlbm.components.ms import PhaseShift - - # A phase shift of 5 qubits - PhaseShift(num_qubits=5, positive=False).draw("mpl") - """ - - def __init__( - self, - num_qubits: int, - positive: bool = False, - logger: Logger = getLogger("qlbm"), - ) -> None: - super().__init__(logger) - - self.num_qubits = num_qubits - self.positive = positive - - self.logger.info(f"Creating circuit {str(self)}...") - circuit_creation_start_time = perf_counter_ns() - self.circuit = self.create_circuit() - self.logger.info( - f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" - ) - - @override - def create_circuit(self) -> QuantumCircuit: - circuit = QuantumCircuit(self.num_qubits) - - for c, qubit_index in enumerate(range(self.num_qubits)): - # (2 * positive - 1) will flip the sign if positive is False - # This effectively inverts the circuit - phase = (2 * self.positive - 1) * pi / (2 ** (self.num_qubits - 1 - c)) - circuit.p(phase, qubit_index) - - return circuit - - @override - def __str__(self) -> str: - return f"[Primitive PhaseShift of {self.num_qubits} qubits, in direction {self.positive}]" - - -class SpeedSensitivePhaseShift(LBMPrimitive): - r"""A primitive that applies the phase-shift as part of the :class:`.SpeedSensitiveAdder` used in :class:`.Comparator`\ s. - - The rotation applied is :math:`\pm \frac{\pi}{2^{n_q - 1 - j}}`, with :math:`j` the position of the qubit (indexed starting with 0). - Unlike the regular :class:`.PhaseShift`, the speed-sensitive version additionally depends on a specific speed index. - For an in-depth mathematical explanation of the procedure, consult Sections 4 and 5.5 of :cite:t:`collisionless`. - - ========================= ====================================================================== - Attribute Summary - ========================= ====================================================================== - :attr:`num_qubits` The number of qubits to perform the phase shift for. - :attr:`positive` Whether the phase shift is applied to increment (T) - or decrement (F) the position of the particles. - Defaults to ``False``. - :attr:`speed` The specific speed index to perform the phase shift for. - :attr:`logger` The performance logger, by default ``getLogger("qlbm")``. - ========================= ====================================================================== - - Example usage: - - .. plot:: - :include-source: - - from qlbm.components.ms import SpeedSensitivePhaseShift - - # A phase shift of 5 qubits, controlled on speed index 2 - SpeedSensitivePhaseShift(num_qubits=5, speed=2, positive=True).draw("mpl") - """ - - def __init__( - self, - num_qubits: int, - speed: int, - positive: bool = False, - logger: Logger = getLogger("qlbm"), - ) -> None: - super().__init__(logger) - - self.num_qubits = num_qubits - self.speed = speed - self.positive = positive - - self.logger.info(f"Creating circuit {str(self)}...") - circuit_creation_start_time = perf_counter_ns() - self.circuit = self.create_circuit() - self.logger.info( - f"Creating circuit {str(self)} took {perf_counter_ns() - circuit_creation_start_time} (ns)" - ) - - @override - def create_circuit(self) -> QuantumCircuit: - circuit = QuantumCircuit(self.num_qubits) - angles = np.zeros(self.num_qubits) - - for qubit_index in range(self.num_qubits): - dig = bit_value(self.speed, qubit_index) - for i in range(self.num_qubits - qubit_index): - # (2 * positive - 1) will flip the sign if positive is False - # This effectively inverts the circuit - angles[i] += ( - (2 * self.positive - 1) - * dig - * pi - / (2 ** (self.num_qubits - qubit_index - i - 1)) - ) - - for qubit_index in range(self.num_qubits): - circuit.p(angles[qubit_index], qubit_index) - - return circuit - - @override - def __str__(self) -> str: - return f"[Primitive SpeedSensitivePhaseShift of {self.num_qubits} qubits, speed {self.speed}, in direction {self.positive}]" diff --git a/qlbm/components/spacetime/initial/volumetric.py b/qlbm/components/spacetime/initial/volumetric.py index b8f8868..49ca0b3 100644 --- a/qlbm/components/spacetime/initial/volumetric.py +++ b/qlbm/components/spacetime/initial/volumetric.py @@ -9,9 +9,9 @@ from typing_extensions import override from qlbm.components.base import LBMPrimitive -from qlbm.components.ms.primitives import Comparator, ComparatorMode +from qlbm.components.common.comparators import SingleRegisterComparator from qlbm.lattice.lattices.spacetime_lattice import SpaceTimeLattice -from qlbm.tools.utils import flatten +from qlbm.tools.utils import ComparatorMode, flatten class VolumetricSpaceTimeInitialConditions(LBMPrimitive): @@ -83,7 +83,7 @@ def create_circuit(self) -> QuantumCircuit: comparators = [ [ - Comparator( + SingleRegisterComparator( self.lattice.properties.get_num_grid_qubits() + 1, pvb[0][bound], self.__adjusted_comparator_mode(bound), diff --git a/qlbm/components/spacetime/reflection/volumetric.py b/qlbm/components/spacetime/reflection/volumetric.py index f0bcd86..92774df 100644 --- a/qlbm/components/spacetime/reflection/volumetric.py +++ b/qlbm/components/spacetime/reflection/volumetric.py @@ -8,11 +8,11 @@ from typing_extensions import override from qlbm.components.base import SpaceTimeOperator -from qlbm.components.ms.primitives import Comparator, ComparatorMode +from qlbm.components.common.comparators import SingleRegisterComparator from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.lattices.spacetime_lattice import SpaceTimeLattice from qlbm.tools.exceptions import CircuitException -from qlbm.tools.utils import flatten +from qlbm.tools.utils import ComparatorMode, flatten class VolumetricSpaceTimeReflectionOperator(SpaceTimeOperator): @@ -100,7 +100,7 @@ def create_circuit(self) -> QuantumCircuit: # Assemble the comparators only once comparators = [ [ - Comparator( + SingleRegisterComparator( self.lattice.properties.get_num_grid_qubits() + 1, pvb[0][bound], self.__adjusted_comparator_mode(bound), diff --git a/qlbm/infra/result/base.py b/qlbm/infra/result/base.py index fce9140..2fb4a2b 100644 --- a/qlbm/infra/result/base.py +++ b/qlbm/infra/result/base.py @@ -63,7 +63,7 @@ def visualize_geometry(self): """ Creates ``stl`` files for each block in the lattice. - Output files are formatted as ``output_dir/paraview_dir/cube_.stl``. + Output files are formatted as ``output_dir/paraview_dir/shape_.stl``. The output is created through the :class:`.Shape`'s :meth:`.Shape.stl_mesh` method. """ if not self.lattice.has_multiple_geometries(): diff --git a/qlbm/infra/runner/base.py b/qlbm/infra/runner/base.py index 1263312..10ae78b 100644 --- a/qlbm/infra/runner/base.py +++ b/qlbm/infra/runner/base.py @@ -2,29 +2,17 @@ from abc import ABC, abstractmethod from logging import Logger, getLogger -from typing import List, cast +from typing import List from qiskit import QuantumCircuit as QiskitQC from qiskit.circuit.library import Initialize from qiskit.quantum_info import Statevector from qiskit_aer import AerSimulator -from qlbm.infra.reinitialize import ( - Reinitializer, - SpaceTimeReinitializer, -) -from qlbm.infra.reinitialize.identity_reinitializer import IdentityReinitializer -from qlbm.infra.result import ( - AmplitudeResult, - LQLGAResult, - QBMResult, - SpaceTimeResult, -) -from qlbm.lattice import Lattice, MSLattice -from qlbm.lattice.lattices.ab_lattice import ABLattice -from qlbm.lattice.lattices.lqlga_lattice import LQLGALattice -from qlbm.lattice.lattices.spacetime_lattice import SpaceTimeLattice -from qlbm.tools.exceptions import CircuitException, ResultsException +from qlbm.infra.reinitialize.base import Reinitializer +from qlbm.infra.result.base import QBMResult +from qlbm.lattice import Lattice +from qlbm.tools.exceptions import CircuitException from .simulation_config import SimulationConfig @@ -107,6 +95,8 @@ def new_result(self, output_directory: str, output_file_name: str) -> QBMResult: """ Get a new result object for the current runner. + Delegates to the lattice's :meth:`.Lattice.create_result` factory method. + Parameters ---------- output_directory : str @@ -118,62 +108,23 @@ def new_result(self, output_directory: str, output_file_name: str) -> QBMResult: ------- QBMResult An empty result object. - - Raises - ------ - ResultsException - If there is no matching result object for the runner's lattice. """ - if isinstance(self.lattice, MSLattice) or isinstance( - self.lattice, ABLattice - ): - return AmplitudeResult( - self.lattice, # type: ignore - output_directory, - output_file_name, - ) - elif isinstance(self.lattice, SpaceTimeLattice): - return SpaceTimeResult( - cast(SpaceTimeLattice, self.lattice), output_directory, output_file_name - ) - elif isinstance(self.lattice, LQLGALattice): - return LQLGAResult( - cast(LQLGALattice, self.lattice), output_directory, output_file_name - ) - else: - raise ResultsException(f"Unsupported lattice: {self.lattice}.") + return self.lattice.create_result(output_directory, output_file_name) def new_reinitializer(self) -> Reinitializer: """ Creates a new reinitializer for a simulated algorithm. + Delegates to the lattice's :meth:`.Lattice.create_reinitializer` factory method. + Returns ------- Reinitializer A suitable reinitializer. - - Raises - ------ - ResultsException - If the underlying algorithm does not support reinitialization. """ - if ( - isinstance(self.lattice, MSLattice) - or isinstance(self.lattice, LQLGALattice) - or isinstance(self.lattice, ABLattice) - ): - return IdentityReinitializer( - self.lattice, - self.config.get_execution_compiler(), - self.logger, - ) - elif isinstance(self.lattice, SpaceTimeLattice): - return SpaceTimeReinitializer( - cast(SpaceTimeLattice, self.lattice), - self.config.get_execution_compiler(), - ) - else: - raise ResultsException(f"Unsupported lattice: {self.lattice}.") + return self.lattice.create_reinitializer( + self.config.get_execution_compiler(), self.logger + ) def statevector_to_circuit(self, statevector: Statevector) -> QiskitQC: """ diff --git a/qlbm/lattice/__init__.py b/qlbm/lattice/__init__.py index 5d5620e..8b46f27 100644 --- a/qlbm/lattice/__init__.py +++ b/qlbm/lattice/__init__.py @@ -12,6 +12,7 @@ from .geometry.shapes.circle import ( Circle, ) +from .geometry.shapes.ymonomial import YMonomial from .lattices import Lattice, MSLattice from .lattices.ab_lattice import ABLattice from .lattices.lqlga_lattice import LQLGALattice @@ -37,4 +38,5 @@ "Circle", "LatticeDiscretization", "LatticeDiscretizationProperties", + "YMonomial", ] diff --git a/qlbm/lattice/geometry/__init__.py b/qlbm/lattice/geometry/__init__.py index d78094e..498a881 100644 --- a/qlbm/lattice/geometry/__init__.py +++ b/qlbm/lattice/geometry/__init__.py @@ -11,7 +11,7 @@ SpaceTimePWReflectionData, SpaceTimeVolumetricReflectionData, ) -from .shapes import Block, Circle +from .shapes import Block, Circle, YMonomial __all__ = [ "DimensionalReflectionData", @@ -25,4 +25,5 @@ "Circle", "LQLGAPointwiseReflectionData", "LQLGAReflectionData", + "YMonomial", ] diff --git a/qlbm/lattice/geometry/shapes/__init__.py b/qlbm/lattice/geometry/shapes/__init__.py index 11c2a6d..beb839c 100644 --- a/qlbm/lattice/geometry/shapes/__init__.py +++ b/qlbm/lattice/geometry/shapes/__init__.py @@ -2,5 +2,6 @@ from .block import Block from .circle import Circle +from .ymonomial import YMonomial -__all__ = ["Block", "Circle"] +__all__ = ["Block", "Circle", "YMonomial"] diff --git a/qlbm/lattice/geometry/shapes/base.py b/qlbm/lattice/geometry/shapes/base.py index 8bac33d..6d7de93 100644 --- a/qlbm/lattice/geometry/shapes/base.py +++ b/qlbm/lattice/geometry/shapes/base.py @@ -17,7 +17,40 @@ class Shape(ABC): """Base class for all geometrical shapes.""" + num_grid_qubits: List[int] + """Number of grid-encoding qubits for each spatial dimension.""" + + boundary_condition: str + """Boundary condition mode associated with this shape.""" + + num_dims: int + """Number of spatial dimensions inferred from :attr:`num_grid_qubits`.""" + + previous_qubits: List[int] + """Cumulative per-dimension qubit offsets used for flattened indexing.""" + def __init__(self, num_grid_qubits: List[int], boundary_condition: str): + """ + Initialize a geometrical shape base object. + + Parameters + ---------- + num_grid_qubits : List[int] + Number of grid-encoding qubits per dimension. + boundary_condition : str + Boundary condition mode associated with this shape. + + Attributes + ---------- + num_grid_qubits : List[int] + Number of grid-encoding qubits per dimension. + boundary_condition : str + Boundary condition mode associated with this shape. + num_dims : int + Number of spatial dimensions inferred from ``num_grid_qubits``. + previous_qubits : List[int] + Cumulative qubit offsets used to flatten dimension-local qubit indices. + """ super().__init__() self.num_grid_qubits = num_grid_qubits @@ -65,6 +98,18 @@ def to_dict(self): """ pass + @abstractmethod + def name(self) -> str: + """ + The name of the shape. + + Returns + ------- + str + The name of the shape. + """ + pass + class LQLGAShape(Shape): """Base class for all shapes compatible with the :class:`.LQLGA` algorithm.""" @@ -326,15 +371,3 @@ def get_d2q4_volumetric_reflection_data( The information encoding the reflections to be performed. """ pass - - @abstractmethod - def name(self) -> str: - """ - The name of the shape. - - Returns - ------- - str - The name of the shape. - """ - pass diff --git a/qlbm/lattice/geometry/shapes/block.py b/qlbm/lattice/geometry/shapes/block.py index c3a0f62..de5324e 100644 --- a/qlbm/lattice/geometry/shapes/block.py +++ b/qlbm/lattice/geometry/shapes/block.py @@ -79,8 +79,64 @@ class Block(SpaceTimeShape, LQLGAShape): - The ``List[ReflectionResetEdge]`` data encoding edges on the outside of the object that are adjacent either side of :attr:`corner_edges_3d`. These edges require additional logic in the quantum circuit for particles that have streamed without reflecting off the obstacle. There are 24 near-corner :class:`ReflectionResetEdge` \s per obstacle. * - :attr:`overlapping_near_corner_edge_points_3d` - The ``List[ReflectionPoint]`` data encoding the set of points at the intersections of :attr:`near_corner_edges_3d`. These points require additional logic in to account for the fact that the state of obstacle ancilla qubits was doubly reset (once by each edge). There are 24 such :class:`ReflectionPoint` \s per obstacle. + * - :attr:`num_gridpoints` + - The ``List[int]`` of gridpoint counts per dimension. + * - :attr:`mesh_vertices` + - The ``np.ndarray`` of obstacle mesh vertices used to build the ``stl`` representation. + * - :attr:`mesh_indices` + - The ``np.ndarray`` of triangle indices selecting faces from :attr:`mesh_vertices`. + * - :attr:`mesh_indices_list` + - A class-level ``List[np.ndarray]`` containing precomputed triangulations by dimensionality. + * - :attr:`ab_wall_indices_to_reset` + - A class-level lookup mapping wall configurations to velocity indices to reset for bounce-back schemes. + * - :attr:`ab_near_corner_indices_to_reset` + - A class-level lookup mapping near-corner configurations to velocity indices to reset. + * - :attr:`ab_corner_indices_to_reset` + - A class-level lookup mapping corner configurations to velocity indices to reset. """ + bounds: List[Tuple[int, int]] + """Lower and upper bounds of the block in each spatial dimension.""" + + num_gridpoints: List[int] + """Number of gridpoints in each spatial dimension.""" + + mesh_vertices: np.ndarray + """Vertices of the polygonal surface representation used for ``stl`` export.""" + + mesh_indices: np.ndarray + """Triangle index array selecting faces from :attr:`mesh_vertices`.""" + + inside_points_data: List[Tuple[DimensionalReflectionData, ...]] + """Per-dimension lower/upper reflection metadata for points inside the obstacle.""" + + outside_points_data: List[Tuple[DimensionalReflectionData, ...]] + """Per-dimension lower/upper reflection metadata for points outside the obstacle.""" + + walls_inside: List[List[ReflectionWall]] + """Reflection wall metadata for interior-facing obstacle walls by dimension.""" + + walls_outside: List[List[ReflectionWall]] + """Reflection wall metadata for exterior-facing obstacle walls by dimension.""" + + corners_inside: List[ReflectionPoint] + """Corner reflection points located on the interior boundary of the obstacle.""" + + corners_outside: List[ReflectionPoint] + """Corner reflection points located on the exterior boundary of the obstacle.""" + + near_corner_points_2d: List[ReflectionPoint] + """2D points adjacent to interior corners that require extra non-reflection logic.""" + + corner_edges_3d: List[ReflectionResetEdge] + """3D exterior edges adjacent to corners, used to reset ancilla state.""" + + near_corner_edges_3d: List[ReflectionResetEdge] + """3D exterior edges adjacent to :attr:`corner_edges_3d` requiring extra handling.""" + + overlapping_near_corner_edge_points_3d: List[ReflectionPoint] + """3D points where near-corner edge reset effects overlap and need compensation.""" + mesh_indices_list: List[np.ndarray] = [ np.array([[0, 1, 2], [1, 2, 3]]), np.array( @@ -100,6 +156,7 @@ class Block(SpaceTimeShape, LQLGAShape): ] ), ] + """Precomputed triangle-index templates by dimensionality for ``stl`` generation.""" ab_wall_indices_to_reset: Dict[ LatticeDiscretization, Dict[Tuple[int, bool], List[int]] @@ -111,6 +168,7 @@ class Block(SpaceTimeShape, LQLGAShape): (1, True): [2, 5, 6], } } + """Lookup of bounce-back velocity indices to reset for wall reflections.""" ab_near_corner_indices_to_reset: Dict[ LatticeDiscretization, Dict[int, Dict[Tuple[bool, ...], List[int]]] @@ -130,6 +188,7 @@ class Block(SpaceTimeShape, LQLGAShape): }, } } + """Lookup of bounce-back velocity indices to reset for near-corner reflections.""" ab_corner_indices_to_reset: Dict[ LatticeDiscretization, Dict[Tuple[bool, ...], List[int]] @@ -141,6 +200,7 @@ class Block(SpaceTimeShape, LQLGAShape): (True, True): [5], } } + """Lookup of bounce-back velocity indices to reset for corner reflections.""" def __init__( self, @@ -873,7 +933,6 @@ def get_lbm_outside_corner_indices_to_reflect( @override def get_lqlga_reflection_data_d1q2(self): - print(self.num_grid_qubits[0]) return self.get_lqlga_reflection_data_1d_from_points( [tuple([self.bounds[0][0]])], 0, diff --git a/qlbm/lattice/geometry/shapes/circle.py b/qlbm/lattice/geometry/shapes/circle.py index 8979b34..fc9b657 100644 --- a/qlbm/lattice/geometry/shapes/circle.py +++ b/qlbm/lattice/geometry/shapes/circle.py @@ -47,11 +47,29 @@ class Circle(SpaceTimeShape): * - Attribute - Description + * - :attr:`center` + - The ``Tuple[int, ...]`` center coordinate of the circle. + * - :attr:`radius` + - The ``int`` radius of the circle in gridpoint units. + * - :attr:`num_mesh_segments` + - The ``int`` number of angular segments used for smooth ``stl`` mesh generation. * - :attr:`perimeter_points` - The ``List[Tuple[int, int]]`` of all gridpoints that lie on the perimeter of the circle, and are therefore relevant for boundary conditions. """ + center: Tuple[int, ...] + """Center coordinate of the circle in lattice grid coordinates.""" + + radius: int + """Circle radius in lattice gridpoint units.""" + + num_mesh_segments: int + """Number of angular segments used for smooth ``stl`` mesh construction.""" + + perimeter_points: List[Tuple[int, int]] + """All discrete gridpoints on the circle perimeter used for boundary handling.""" + def __init__( self, center: Tuple[int, ...], diff --git a/qlbm/lattice/geometry/shapes/ymonomial.py b/qlbm/lattice/geometry/shapes/ymonomial.py new file mode 100644 index 0000000..8635ae7 --- /dev/null +++ b/qlbm/lattice/geometry/shapes/ymonomial.py @@ -0,0 +1,137 @@ +"""Base classes for geometrical shapes.""" + +from json import dumps +from typing import Dict, List, override + +import numpy as np +from stl import mesh + +from qlbm.tools.utils import ComparatorMode + +from .base import Shape + + +class YMonomial(Shape): + r""" + Shape representing boundaries shaped by comparisons to monomials. + + Represents a 2D discretized shape defined by a comparison between the :math:`y` + coordinate and the :math:`x` coordinate raised to a given exponent (i.e. the set of + points satisfying ``y [comparator] x**exp``). The comparison operator is + provided via a ComparatorMode instance and the spatial resolution is determined + by the number of grid qubits per dimension. + + .. important:: + + The ``YMonomial`` implementation is a work in progress. + At present, only the :math:`x^2` monomial case is supported, + and only when the monomial result register width matches the :math:`y` + grid register width. + + .. list-table:: Class attributes + :widths: 25 50 + :header-rows: 1 + + * - Attribute + - Description + * - :attr:`comparator_mode` + - The :class:`.ComparatorMode` used to compare :math:`y` and :math:`x^\mathrm{exp}`. + * - :attr:`exponent` + - The monomial exponent used to evaluate :math:`x^\mathrm{exp}`. + * - :attr:`boundary_points` + - A ``List[List[bool]]`` occupancy grid indicating whether each point satisfies the monomial comparator. + """ + + comparator_mode: ComparatorMode + """Comparator mode defining the boundary predicate ``y [op] x**exponent``.""" + + exponent: int + """Exponent of the monomial evaluated on the :math:`x` coordinate register.""" + + boundary_points: List[List[bool]] + """Boolean occupancy grid indicating which lattice points satisfy the monomial boundary.""" + + def __init__( + self, + num_grid_qubits: List[int], + boundary_condition: str, + exponent: int, + comparator_mode: ComparatorMode, + ): + super().__init__(num_grid_qubits, boundary_condition) + + self.num_grid_qubits = num_grid_qubits + self.boundary_condition = boundary_condition + self.num_dims = len(num_grid_qubits) + # The number of qubits used to offset "higher" dimensions + self.previous_qubits: List[int] = [ + sum(num_grid_qubits[previous_dim] for previous_dim in range(dim)) + for dim in range(self.num_dims) + ] + self.comparator_mode = comparator_mode + + self.exponent = exponent + self.boundary_points: List[List[bool]] = [ + [ + ComparatorMode.to_operator(comparator_mode)(y, x**exponent) + for x in range(2 ** num_grid_qubits[0]) + ] + for y in range(2 ** num_grid_qubits[1]) + ] + + @override + def stl_mesh(self) -> mesh.Mesh: + """ + Provides the ``stl`` representation of the shape. + + Returns + ------- + ``stl.mesh.Mesh`` + The mesh representing the shape. + """ + triangles: List[np.ndarray] = [] + x_segments = 2 ** self.num_grid_qubits[0] + y_segments = 2 ** self.num_grid_qubits[1] + x_segment_width = (x_segments - 1) / x_segments + y_segment_height = (y_segments - 1) / y_segments + + for y, row in enumerate(self.boundary_points): + for x, is_inside in enumerate(row): + if not is_inside: + continue + + x0 = x * x_segment_width + x1 = (x + 1) * x_segment_width + y0 = y * y_segment_height + y1 = (y + 1) * y_segment_height + + v00 = np.array([x0, y0, 1.0]) + v10 = np.array([x1, y0, 1.0]) + v01 = np.array([x0, y1, 1.0]) + v11 = np.array([x1, y1, 1.0]) + + triangles.append(np.array([v00, v10, v11])) + triangles.append(np.array([v00, v11, v01])) + + ymonomial_mesh = mesh.Mesh(np.zeros(len(triangles), dtype=mesh.Mesh.dtype)) + for i, triangle in enumerate(triangles): + ymonomial_mesh.vectors[i] = triangle + + return ymonomial_mesh + + @override + def to_json(self) -> str: + return dumps(self.to_dict()) + + @override + def name(self) -> str: + return "ymonomial" + + @override + def to_dict(self) -> Dict[str, int | str]: + return { + "shape": self.name(), + "exponent": self.exponent, + "comparator": self.comparator_mode.to_string(), + "boundary": self.boundary_condition, + } diff --git a/qlbm/lattice/lattices/ab_lattice.py b/qlbm/lattice/lattices/ab_lattice.py index db8e601..5a06887 100644 --- a/qlbm/lattice/lattices/ab_lattice.py +++ b/qlbm/lattice/lattices/ab_lattice.py @@ -1,7 +1,7 @@ """Implementation of the Amplitude-Based (AB) encoding lattice for generic DdQq discretizations.""" from logging import getLogger -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, cast from numpy import ceil, log2 from qiskit import QuantumCircuit, QuantumRegister @@ -9,6 +9,7 @@ from qlbm.components.ab.encodings import ABEncodingType from qlbm.lattice.geometry.shapes.base import Shape +from qlbm.lattice.geometry.shapes.ymonomial import YMonomial from qlbm.lattice.spacetime.properties_base import ( LatticeDiscretization, LatticeDiscretizationProperties, @@ -113,6 +114,19 @@ class ABLattice(AmplitudeLattice): num_base_qubits: int """The number of qubits required to represent the lattice.""" + num_monomial_qubits: int + r"""The number of qubits used for the imposition of monomially-shaped BCs. + Currently, only the :class:`.YMonomial` is supported. + The number of qubits for a monomial with exponent :math:`n` + is :math:`n\lceil \log_2 N_{g_x}\rceil`. + This does not include copy-register qubits, which are tracked separately + in :attr:`num_copy_qubits`.""" + + num_copy_qubits: int + r"""The number of qubits used to copy the :math:`x` coordinate register for monomial BCs. + If at least one :class:`.YMonomial` is present, this is :math:`\lceil \log_2 N_{g_x}\rceil`, + otherwise it is ``0``.""" + registers: Tuple[QuantumRegister, ...] """The registers of the lattice.""" @@ -144,22 +158,129 @@ def __init__( self.num_base_qubits = self.num_grid_qubits + self.num_velocity_qubits self.num_obstacle_qubits = self.__num_obstacle_qubits() - self.num_comparator_qubits = 2 * (self.num_dims - 1) + self.num_copy_qubits = self.__num_copy_qubits() + self.num_monomial_qubits = self.__num_monomial_qubits() + self.num_comparator_qubits = self.__num_comparator_qubits() + self.num_ancilla_qubits = self.num_comparator_qubits + self.num_obstacle_qubits + + self.num_marker_qubits = ( + int(ceil(log2(len(self.geometries)))) + if self.has_multiple_geometries() + else 0 + ) + + self.num_total_qubits = ( + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits + ) + + self.num_accumulation_qubits = 0 + self.shape_list = flatten(self.__geometry_shape_lists()) + + self.__update_registers() + + def __update_registers(self): + self.num_obstacle_qubits = self.__num_obstacle_qubits() + self.num_copy_qubits = self.__num_copy_qubits() + self.num_monomial_qubits = self.__num_monomial_qubits() + self.num_comparator_qubits = self.__num_comparator_qubits() self.num_ancilla_qubits = self.num_comparator_qubits + self.num_obstacle_qubits - self.num_total_qubits = self.num_base_qubits + self.num_ancilla_qubits + self.num_total_qubits = ( + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits + ) + + temp_registers = self.get_registers() + + if len(temp_registers) == 8: + ( + self.grid_registers, + self.velocity_registers, + self.ancilla_comparator_register, + self.ancilla_object_register, + self.marker_register, + self.accumulation_register, + self.copy_register, + self.monomial_register, + ) = temp_registers + elif len(temp_registers) == 6: + ( + self.grid_registers, + self.velocity_registers, + self.ancilla_comparator_register, + self.ancilla_object_register, + self.marker_register, + self.accumulation_register, + ) = temp_registers + self.copy_register = [] + self.monomial_register = [] + else: + raise LatticeException( + "Invalid register tuple returned by get_registers()." + ) - temporary_registers = self.get_registers() - ( - self.grid_registers, - self.velocity_registers, - self.ancilla_comparator_register, - self.ancilla_object_register, - ) = temporary_registers + self.registers = tuple(flatten(temp_registers)) - self.registers = tuple(flatten(temporary_registers)) self.circuit = QuantumCircuit(*self.registers) + def set_num_marker_qubits(self, num_marker_qubits: int): + """ + Sets the number of marker qubits and updates the registers accordingly. + + Note that the previous marker logic, inferred by the geometry, is overwritten, + and therefore might be inconsistent. + + Parameters + ---------- + num_marker_qubits : int + The number of marker qubits that lattice circuits use. + """ + if num_marker_qubits < 0: + raise LatticeException("Cannot set a negative number of markers.") + self.num_marker_qubits = num_marker_qubits + + self.__update_registers() + + def set_geometries(self, geometries): + """ + Updates the geometry setup of the lattice. + + For a given lattice (set number of gridpoints and velocity discretization), + set multiple geometry configurations to simulate simultaneously. + + .. plot:: + :include-source: + + from qlbm.lattice import ABLattice + + lattice = ABLattice( + { + "lattice": { + "dim": {"x": 16, "y": 16}, + "velocities": "D2Q9", + }, + }, + ) + + lattice.circuit.draw("mpl") + + Parameters + ---------- + geometries : Dict + A list of geometries to simulate on the same lattice. + """ + self.geometries = [self.parse_geometry_dict(g) for g in geometries] + if len(self.geometries) == 1: + # Remove this in the future... + self.shapes = self.geometries[0] + self.shape_list = flatten(self.__geometry_shape_lists()) + + self.num_marker_qubits = ( + int(ceil(log2(len(self.geometries)))) + if self.has_multiple_geometries() + else 0 + ) + self.__update_registers() + @override def grid_index(self, dim: int | None = None) -> List[int]: if dim is None: @@ -197,22 +318,30 @@ def velocity_index(self, dim: int | None = None) -> List[int]: @override def ancillae_comparator_index(self, index: int | None = None) -> List[int]: + if self.num_comparator_qubits == 0: + if index is None: + return [] + raise LatticeException( + "Cannot index ancilla comparator register because this lattice has no comparator qubits." + ) + if index is None: return list( range( self.num_base_qubits, - self.num_base_qubits + 2 * (self.num_dims - 1), + self.num_base_qubits + self.num_comparator_qubits, ) ) - if index >= self.num_dims - 1 or index < 0: + if index != 0: raise LatticeException( - f"Cannot index ancilla comparator register for index {index} in {self.num_dims}-dimensional lattice. Maximum is {self.num_dims - 2}." + f"Cannot index ancilla comparator register for index {index} in {self.num_dims}-dimensional lattice. Maximum is 0." ) return list( range( - self.num_base_qubits, self.num_base_qubits + self.num_comparator_qubits + self.num_base_qubits, + self.num_base_qubits + self.num_comparator_qubits, ) ) @@ -235,22 +364,113 @@ def ancillae_obstacle_index(self, index: int | None = None) -> List[int]: return [self.num_base_qubits + self.num_comparator_qubits + index] + def ancillae_copy_index(self) -> List[int]: + """ + Gets the index of the ancilla qubits used to copy the state of the x grid qubits. + + Returns + ------- + List[int] + The indices of the copy register qubits. + """ + if self.num_copy_qubits == 0: + raise LatticeException( + "This lattice does not have any copy register qubits." + ) + + return list( + range( + self.num_base_qubits + + self.num_comparator_qubits + + self.num_obstacle_qubits, + self.num_base_qubits + + self.num_comparator_qubits + + self.num_obstacle_qubits + + self.num_copy_qubits, + ) + ) + + def ancillae_monomial_index(self) -> List[int]: + """ + Gets the index of the ancilla qubits used to compute the function of the x register for BC purposes. + + Returns + ------- + List[int] + The indices of the monomial register qubits. + """ + if self.num_monomial_qubits == 0: + raise LatticeException("This lattice does not have any monomial BC qubits.") + + return list( + range( + self.num_base_qubits + + self.num_comparator_qubits + + self.num_obstacle_qubits + + self.num_copy_qubits, + self.num_base_qubits + + self.num_comparator_qubits + + self.num_obstacle_qubits + + self.num_copy_qubits + + self.num_monomial_qubits, + ) + ) + def __num_obstacle_qubits(self) -> int: - all_obstacle_bounceback: bool = len( + return max( [ - b - for b in flatten(list(self.shapes.values())) - if b.boundary_condition == "bounceback" + ( + 1 + if len( + [ + shape + for shape in geometry_shapes + if shape.boundary_condition == "bounceback" + ] + ) + == len(geometry_shapes) + else self.num_dims + ) + for geometry_shapes in self.__geometry_shape_lists() ] - ) == len(flatten(list(self.shapes.values()))) - if all_obstacle_bounceback: - # A single qubit suffices to determine - # Whether particles have streamed inside the object - return 1 - # If there is at least one object with specular reflection - # 2 ancilla qubits are required for velocity inversion - else: - return self.num_dims + + [1] + ) + + def __num_comparator_qubits(self) -> int: + return ( + self.num_dims + if any( + shape.name() == "cuboid" + for shape in flatten(self.__geometry_shape_lists()) + ) + else 0 + ) + + def __num_copy_qubits(self) -> int: + return ( + self.num_gridpoints[0].bit_length() + if any( + shape.name() == "ymonomial" + for shape in flatten(self.__geometry_shape_lists()) + ) + else 0 + ) + + def __num_monomial_qubits(self) -> int: + monomial_shapes_exponent = [ + cast(YMonomial, x).exponent + for x in flatten(self.__geometry_shape_lists()) + if x.name() == "ymonomial" + ] + # ! This only works for the y monomial example + return ( + 0 + if not monomial_shapes_exponent + else max(monomial_shapes_exponent) * self.num_gridpoints[0].bit_length() + ) + + def __geometry_shape_lists(self) -> List[List[Shape]]: + return [flatten(list(geometry.values())) for geometry in self.geometries] @override def get_registers(self) -> Tuple[List[QuantumRegister], ...]: @@ -260,7 +480,8 @@ def get_registers(self) -> Tuple[List[QuantumRegister], ...]: (i) the logarithmically compressed grid, (ii) the logarithmically compressed discrete velocities, (iii) the comparator qubits, - (iv) the object qubits. + (iv) the object qubit(s), + (v) the monomial BC qubits, if present. Returns ------- @@ -273,9 +494,11 @@ def get_registers(self) -> Tuple[List[QuantumRegister], ...]: ] # 2(d-1) ancilla qubits - ancilla_comparator_register = [ - QuantumRegister(self.num_comparator_qubits, name="a_c") - ] + ancilla_comparator_register = ( + [QuantumRegister(self.num_comparator_qubits, name="a_c")] + if self.num_comparator_qubits > 0 + else [] + ) # Velocity qubits velocity_registers = [QuantumRegister(self.num_velocity_qubits, name="v")] @@ -286,11 +509,58 @@ def get_registers(self) -> Tuple[List[QuantumRegister], ...]: for c, gp in enumerate(self.num_gridpoints) ] + # Monomial qubits + # ! Only works for Ymonomials + copy_register = ( + [QuantumRegister(self.num_copy_qubits, name="a_copy")] + if self.num_copy_qubits > 0 + else [] + ) + + # ! Only works for Ymonomials + monomial_register = ( + [ + QuantumRegister( + self.num_monomial_qubits, + name="monomial", + ) + ] + if self.num_monomial_qubits > 0 + else [] + ) + + if self.has_multiple_geometries(): + marker_register = [ + QuantumRegister( + self.num_marker_qubits, + name="m", + ) + ] + elif self.num_marker_qubits > 0: + marker_register = [ + QuantumRegister( + self.num_marker_qubits, + name="m", + ) + ] + else: + marker_register = [] + + accumulation_register = ( + [QuantumRegister(self.num_accumulation_qubits, name="acc")] + if self.has_accumulation_register() + else [] + ) + return ( grid_registers, velocity_registers, ancilla_comparator_register, ancilla_object_register, + marker_register, + accumulation_register, + copy_register, + monomial_register, ) @override @@ -303,9 +573,66 @@ def logger_name(self) -> str: return f"ablattice-{self.num_dims}d-{gp_string}-{len(flatten(list(self.shapes.values())))}-obstacle" @override - def has_multiple_geometries(self): - return False # multiple geometries unsupported for ABQLBM right now + def has_multiple_geometries(self) -> bool: + return len(self.geometries) > 1 + + def has_accumulation_register(self) -> bool: + """ + Whether the lattice has a register that accumulates quantities at each step. + + Returns + ------- + bool + Whether the lattice has a register that accumulates quantities at each step. + """ + return self.num_accumulation_qubits > 0 + + def use_accumulation_register(self): + """ + Sets up the accumulation register of the lattice. + + The amplitude-based accumulation method is only currently supported for 1 time step, + at the end of the simulation. More detail on amplitude accumulation can be found + in :cite:t:`qsearch`. + """ + self.num_accumulation_qubits = 1 + + self.__update_registers() + + @override + def marker_index(self) -> List[int]: + return list( + range( + self.num_base_qubits + self.num_ancilla_qubits, + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits, + ) + ) + + @override + def accumulation_index(self) -> List[int]: + return list( + range( + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits, + self.num_base_qubits + + self.num_ancilla_qubits + + self.num_marker_qubits + + self.num_accumulation_qubits, + ) + ) @override def get_encoding(self) -> ABEncodingType: return ABEncodingType.AB + + @override + def get_base_circuit(self): + return QuantumCircuit( + *flatten( + [ + self.grid_registers, + self.velocity_registers, + self.ancilla_comparator_register, + self.ancilla_object_register, + ] + ), + ) diff --git a/qlbm/lattice/lattices/base.py b/qlbm/lattice/lattices/base.py index cc4ad15..e056c33 100644 --- a/qlbm/lattice/lattices/base.py +++ b/qlbm/lattice/lattices/base.py @@ -1,22 +1,31 @@ """Base class for all algorithm-specific Lattices.""" +from __future__ import annotations + import json from abc import ABC, abstractmethod from logging import Logger, getLogger -from typing import Dict, List, Tuple +from typing import TYPE_CHECKING, Dict, List, Tuple from qiskit import QuantumCircuit, QuantumRegister +from typing_extensions import override + +if TYPE_CHECKING: + from qlbm.infra.compiler import CircuitCompiler + from qlbm.infra.reinitialize.base import Reinitializer + from qlbm.infra.result.base import QBMResult from qlbm.components.ab.encodings import ABEncodingType from qlbm.lattice.geometry.shapes.base import Shape from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.geometry.shapes.circle import Circle +from qlbm.lattice.geometry.shapes.ymonomial import YMonomial from qlbm.lattice.spacetime.properties_base import ( LatticeDiscretization, LatticeDiscretizationProperties, ) from qlbm.tools.exceptions import LatticeException -from qlbm.tools.utils import dimension_letter, flatten +from qlbm.tools.utils import ComparatorMode, dimension_letter, flatten class Lattice(ABC): @@ -257,14 +266,12 @@ def __parse_input_dict( # Set for access to the geometry parsing utilities self.num_dims = num_dimensions - grid_list: List[int] = [ + self.num_gridpoints: List[int] = [ # -1 because the bit_length() would "overshoot" for powers of 2 lattice_dict["dim"][dimension_letter(dim)] - 1 for dim in range(num_dimensions) ] - self.num_gridpoints = grid_list - discretization: LatticeDiscretization = LatticeDiscretization.CFLDISCRETIZATION velocity_list: List[int] = [] @@ -311,7 +318,7 @@ def __parse_input_dict( if "geometry" not in input_dict: return ( - grid_list, + self.num_gridpoints, velocity_list, {"specular": [], "bounceback": []}, discretization, @@ -321,7 +328,7 @@ def __parse_input_dict( parsed_obstacles = self.parse_geometry_dict(geometry_list) - return grid_list, velocity_list, parsed_obstacles, discretization + return self.num_gridpoints, velocity_list, parsed_obstacles, discretization def parse_geometry_dict(self, geometry_list) -> Dict[str, List[Shape]]: """ @@ -356,9 +363,9 @@ def parse_geometry_dict(self, geometry_list) -> Dict[str, List[Shape]]: f"Obstacle {c + 1} specification includes no shape." ) - if obstacle_dict["shape"] not in ["cuboid", "sphere"]: + if obstacle_dict["shape"] not in ["cuboid", "sphere", "ymonomial"]: raise LatticeException( - f'Obstacle {c + 1} has unsupported shape "{obstacle_dict["shape"]}". Supported shapes are cuboid and sphere.' + f'Obstacle {c + 1} has unsupported shape "{obstacle_dict["shape"]}". Supported shapes are cuboid, sphere, and ymonomial.' ) # Parsing blocks if obstacle_dict["shape"] == "cuboid": @@ -434,6 +441,50 @@ def parse_geometry_dict(self, geometry_list) -> Dict[str, List[Shape]]: obstacle_dict["boundary"], # type: ignore ) ) + elif obstacle_dict["shape"] == "ymonomial": + if self.num_dims != 2: + raise LatticeException( + f"Obstacle {c + 1}: ymonomial is only supported for 2-dimensional lattices." + ) + + if "exponent" not in obstacle_dict: + raise LatticeException( + f"Obstacle {c + 1}: ymonomial obstacle does not specify an exponent." + ) + + try: + exponent = int(obstacle_dict["exponent"]) + except (ValueError, TypeError): + raise LatticeException( + f"Obstacle {c + 1}: ymonomial exponent {obstacle_dict['exponent']} is not an integer." + ) + + if exponent < 0: + raise LatticeException( + f"Obstacle {c + 1}: ymonomial exponent {obstacle_dict['exponent']} must be non-negative." + ) + + if "comparator" not in obstacle_dict: + raise LatticeException( + f"Obstacle {c + 1}: ymonomial obstacle does not specify a comparator." + ) + + if not isinstance(obstacle_dict["comparator"], str): + raise LatticeException( + f"Obstacle {c + 1}: ymonomial comparator must be a string." + ) + + parsed_obstacles[obstacle_dict["boundary"]].append( # type: ignore + YMonomial( + [ + (self.num_gridpoints[numeric_dim_index]).bit_length() + for numeric_dim_index in range(self.num_dims) + ], + obstacle_dict["boundary"], # type: ignore + exponent, + ComparatorMode.from_string(obstacle_dict["comparator"]), + ) + ) return parsed_obstacles @@ -452,14 +503,16 @@ def to_json(self) -> str: dimension_letter(dim): self.num_gridpoints[dim] + 1 for dim in range(self.num_dims) }, - "velocities": { - dimension_letter(dim): self.num_velocities[dim] + 1 - for dim in range(self.num_dims) - } - if self.discretization == LatticeDiscretization.CFLDISCRETIZATION - else LatticeDiscretizationProperties.string_representation[ - self.discretization - ], # type: ignore + "velocities": ( + { + dimension_letter(dim): self.num_velocities[dim] + 1 + for dim in range(self.num_dims) + } + if self.discretization == LatticeDiscretization.CFLDISCRETIZATION + else LatticeDiscretizationProperties.string_representation[ + self.discretization + ] + ), # type: ignore }, } @@ -508,6 +561,48 @@ def has_multiple_geometries(self) -> bool: """ pass + @abstractmethod + def create_result(self, output_directory: str, output_file_name: str) -> QBMResult: + """ + Create the appropriate result object for this lattice type. + + Parameters + ---------- + output_directory : str + The directory where the result data will be stored. + output_file_name : str + The file name of the result data within the directory. + + Returns + ------- + QBMResult + A result object specific to this lattice type. + """ + pass + + @abstractmethod + def create_reinitializer( + self, + compiler: CircuitCompiler, + logger: Logger = getLogger("qlbm"), + ) -> Reinitializer: + """ + Create the appropriate reinitializer for this lattice type. + + Parameters + ---------- + compiler : CircuitCompiler + The compiler that converts the novel initial conditions circuits. + logger : Logger, optional + The performance logger, by default ``getLogger("qlbm")``. + + Returns + ------- + Reinitializer + A reinitializer specific to this lattice type. + """ + pass + class AmplitudeLattice(Lattice, ABC): r""" @@ -521,6 +616,18 @@ class AmplitudeLattice(Lattice, ABC): ``qlbm`` currently has 2 amplitude-based lattices: the :class:`.MSLattice` and :class:`.ABLattice` used in the :class:`.MSQLBM` and :class:`.ABQLBM`, respectively. """ + num_base_qubits: int + """The number of qubits required to represent the lattice.""" + + num_ancilla_qubits: int + """The number of ancillary qubits used to perform the algorithm for, i.e., boundary conditions.""" + + num_marker_qubits: int + """The number of qubits used to identify geometries, if parallel lattices are being simulated.""" + + geometries: List[Dict[str, List[Shape]]] + """The list of geometries, if multiple geometries are simulated in parallel on this lattice.""" + def __init__( self, lattice_data, @@ -528,6 +635,24 @@ def __init__( ): super(AmplitudeLattice, self).__init__(lattice_data, logger) + @override + def create_result(self, output_directory: str, output_file_name: str) -> QBMResult: + from qlbm.infra.result import AmplitudeResult + + return AmplitudeResult(self, output_directory, output_file_name) + + @override + def create_reinitializer( + self, + compiler: CircuitCompiler, + logger: Logger = getLogger("qlbm"), + ) -> Reinitializer: + from qlbm.infra.reinitialize.identity_reinitializer import ( + IdentityReinitializer, + ) + + return IdentityReinitializer(self, compiler, logger) + @abstractmethod def grid_index(self, dim: int | None = None) -> List[int]: """Get the indices of the qubits used that encode the grid values for the specified dimension. @@ -624,6 +749,32 @@ def velocity_index(self, dim: int | None = None) -> List[int]: """ pass + @abstractmethod + def marker_index(self) -> List[int]: + """ + Get the indices of the qubits addressing the marker. + + This is only useful if multiple lattice geometries are addressed simultaneously. + + Returns + ------- + List[int] + The absolute indices of the marker qubits. + """ + pass + + @abstractmethod + def accumulation_index(self) -> List[int]: + """ + Get the indices of the qubits used for the accumulation register. + + Returns + ------- + List[int] + The absolute indices of the accumulation qubits. + """ + pass + @abstractmethod def get_encoding(self) -> ABEncodingType: """ @@ -635,3 +786,8 @@ def get_encoding(self) -> ABEncodingType: The encoding of this lattice. """ pass + + @abstractmethod + def get_base_circuit(self) -> QuantumCircuit: + """Get the base quantum circuit, without any multi-geometry or accumulation qubits.""" + pass diff --git a/qlbm/lattice/lattices/lqlga_lattice.py b/qlbm/lattice/lattices/lqlga_lattice.py index a8ba755..1690298 100644 --- a/qlbm/lattice/lattices/lqlga_lattice.py +++ b/qlbm/lattice/lattices/lqlga_lattice.py @@ -1,7 +1,7 @@ """Implementation of the :class:`.Lattice` base specific to the 2D and 3D :class:`.LQLGA` algorithm.""" from itertools import product -from logging import getLogger +from logging import Logger, getLogger from math import prod from typing import Dict, List, Tuple, cast, override @@ -331,6 +331,24 @@ def get_velocity_qubits_of_line(self, line_index: int) -> Tuple[int, int]: + self.num_velocities_per_point // 2, ) + @override + def create_result(self, output_directory, output_file_name): + from qlbm.infra.result import LQLGAResult + + return LQLGAResult(self, output_directory, output_file_name) + + @override + def create_reinitializer( + self, + compiler, + logger: Logger = getLogger("qlbm"), + ): + from qlbm.infra.reinitialize.identity_reinitializer import ( + IdentityReinitializer, + ) + + return IdentityReinitializer(self, compiler, logger) + @override def logger_name(self) -> str: gp_string = "" diff --git a/qlbm/lattice/lattices/ms_lattice.py b/qlbm/lattice/lattices/ms_lattice.py index 93530f0..c440e73 100644 --- a/qlbm/lattice/lattices/ms_lattice.py +++ b/qlbm/lattice/lattices/ms_lattice.py @@ -384,6 +384,14 @@ def velocity_dir_index(self, dim: int | None = None) -> List[int]: return [previous_qubits + dim] + @override + def marker_index(self): + raise LatticeException("Multiple geometries not yet supported for MSLattice.") + + @override + def accumulation_index(self): + raise LatticeException("Accumulation not yet supported for MSLattice.") + def get_registers(self) -> Tuple[List[QuantumRegister], ...]: """Generates the encoding-specific register required for the streaming step. @@ -469,3 +477,18 @@ def has_multiple_geometries(self): @override def get_encoding(self): return ABEncodingType.MS + + @override + def get_base_circuit(self): + return QuantumCircuit( + *flatten( + [ + self.ancilla_velocity_register, + self.ancilla_object_register, + self.ancilla_comparator_register, + self.grid_registers, + self.velocity_registers, + self.velocity_dir_registers, + ] + ) + ) diff --git a/qlbm/lattice/lattices/oh_lattice.py b/qlbm/lattice/lattices/oh_lattice.py index 1583b65..f57abf2 100644 --- a/qlbm/lattice/lattices/oh_lattice.py +++ b/qlbm/lattice/lattices/oh_lattice.py @@ -22,12 +22,12 @@ class OHLattice(ABLattice): r""" Implementation of the :class:`.Lattice` base specific to the 2D and 3D :class:`.ABQLBM` for the One-Hot (OH) encoding. - + In the OH encoding, the grid is compressed into logarithmically many qubits, while the the velocity register is not. For a :math:`1024 \times 1024` lattice with a :math:`D_2Q_9` discretization, the OH encoding requires :math:`2\log_2 1024 + 9 = 29` qubits. - + Each of the discrete velocities is assigned a vector :math:`\ket{\mathbf{e}_j}`, with entry :math:`1` at index :math:`j` and :math:`0` everywhere else. @@ -156,19 +156,42 @@ def __init__( self.num_comparator_qubits = 2 * (self.num_dims - 1) self.num_ancilla_qubits = self.num_comparator_qubits + self.num_obstacle_qubits - self.num_total_qubits = self.num_base_qubits + self.num_ancilla_qubits + self.num_total_qubits = ( + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits + ) + + self.num_accumulation_qubits = 0 + + self.__update_registers() + + def __update_registers(self): + self.num_total_qubits = ( + self.num_base_qubits + self.num_ancilla_qubits + self.num_marker_qubits + ) + + temp_registers = self.get_registers() - temporary_registers = self.get_registers() ( self.grid_registers, self.velocity_registers, self.ancilla_comparator_register, self.ancilla_object_register, - ) = temporary_registers + self.marker_register, + self.accumulation_register, + ) = temp_registers + + self.registers = tuple(flatten(temp_registers)) - self.registers = tuple(flatten(temporary_registers)) self.circuit = QuantumCircuit(*self.registers) + @override + def marker_index(self): + raise LatticeException("Multiple geometries not yet supported for OHLattice.") + + @override + def accumulation_index(self): + raise LatticeException("Accumulation not yet supported for OHLattice.") + @override def get_registers(self) -> Tuple[List[QuantumRegister], ...]: """Generates the encoding-specific register required for the streaming step. @@ -203,11 +226,30 @@ def get_registers(self) -> Tuple[List[QuantumRegister], ...]: for c, gp in enumerate(self.num_gridpoints) ] + marker_register = ( + [ + QuantumRegister( + int(ceil(log2(len(self.geometries)))), + name="m", + ) + ] + if self.has_multiple_geometries() + else [] + ) + + accumulation_register = ( + [QuantumRegister(self.num_accumulation_qubits, name="acc")] + if self.has_accumulation_register() + else [] + ) + return ( grid_registers, velocity_registers, ancilla_comparator_register, ancilla_object_register, + marker_register, + accumulation_register, ) @override diff --git a/qlbm/lattice/lattices/spacetime_lattice.py b/qlbm/lattice/lattices/spacetime_lattice.py index ef993b8..a95887c 100644 --- a/qlbm/lattice/lattices/spacetime_lattice.py +++ b/qlbm/lattice/lattices/spacetime_lattice.py @@ -470,6 +470,18 @@ def comparator_periodic_volume_bounds( return adjusted_bounds + @override + def create_result(self, output_directory, output_file_name): + from qlbm.infra.result import SpaceTimeResult + + return SpaceTimeResult(self, output_directory, output_file_name) + + @override + def create_reinitializer(self, compiler, logger=getLogger("qlbm")): + from qlbm.infra.reinitialize import SpaceTimeReinitializer + + return SpaceTimeReinitializer(self, compiler, logger) + @override def has_multiple_geometries(self): return False # multiple geometries unsupported for STQBM diff --git a/qlbm/tools/__init__.py b/qlbm/tools/__init__.py index 94019bf..52f275f 100644 --- a/qlbm/tools/__init__.py +++ b/qlbm/tools/__init__.py @@ -8,6 +8,7 @@ ResultsException, ) from .utils import ( + ComparatorMode, bit_value, create_directory_and_parents, dimension_letter, @@ -34,4 +35,5 @@ "dimension_letter", "is_two_pow", "get_time_series", + "ComparatorMode", ] diff --git a/qlbm/tools/exceptions.py b/qlbm/tools/exceptions.py index 41cdb96..c79aabb 100644 --- a/qlbm/tools/exceptions.py +++ b/qlbm/tools/exceptions.py @@ -1,7 +1,7 @@ """Contains custom exceptions for the QLBM package.""" -class LatticeException(BaseException): +class LatticeException(Exception): """Exception raised when encountering invalid or misaligned lattice properties.""" def __init__(self, message: str) -> None: @@ -9,7 +9,7 @@ def __init__(self, message: str) -> None: super().__init__(self.message) -class ResultsException(BaseException): +class ResultsException(Exception): """Exception raised during the processing of :class:`QBMResults` objects.""" def __init__(self, message: str) -> None: @@ -17,7 +17,7 @@ def __init__(self, message: str) -> None: super().__init__(self.message) -class CompilerException(BaseException): +class CompilerException(Exception): """Exception raised when encountering a circuit compilation exception.""" def __init__(self, message: str) -> None: @@ -25,7 +25,7 @@ def __init__(self, message: str) -> None: super().__init__(self.message) -class CircuitException(BaseException): +class CircuitException(Exception): """Exception raised when attempting to compile to an unsupported target.""" def __init__(self, message: str) -> None: @@ -33,7 +33,7 @@ def __init__(self, message: str) -> None: super().__init__(self.message) -class ExecutionException(BaseException): +class ExecutionException(Exception): """Exception raised when attempting to execute circuits with mismatched properties.""" def __init__(self, message: str) -> None: diff --git a/qlbm/tools/utils.py b/qlbm/tools/utils.py index 3a56be4..c7d5b73 100644 --- a/qlbm/tools/utils.py +++ b/qlbm/tools/utils.py @@ -1,9 +1,11 @@ """General qlbm utilities.""" import re +from enum import Enum from math import pi +from operator import ge, gt, le, lt from pathlib import Path -from typing import List, Tuple +from typing import Callable, List, Tuple import numpy as np from pytket.extensions.qiskit import qiskit_to_tk @@ -13,6 +15,8 @@ from qulacs import QuantumCircuit as QulacsQC from qulacs.converter import convert_QASM_to_qulacs_circuit +from qlbm.tools.exceptions import LatticeException + def create_directory_and_parents(directory: str) -> None: """ @@ -272,3 +276,86 @@ def get_qubits_to_invert(number_encoded: int, num_qubits: int) -> List[int]: The indices of the (qu)bits that have value 0. """ return [i for i in range(num_qubits) if not bit_value(number_encoded, i)] + + +class ComparatorMode(Enum): + r"""Enumerator for the modes of quantum comparator circuits. + + The modes are as follows: + + * (1, ``ComparatorMode.LT``, :math:`<`); + * (2, ``ComparatorMode.LE``, :math:`\leq`); + * (3, ``ComparatorMode.GT``, :math:`>`); + * (4, ``ComparatorMode.GE``, :math:`\geq`). + """ + + LT = (1,) + LE = (2,) + GT = (3,) + GE = (4,) + + @classmethod + def from_string(cls, mode: str) -> "ComparatorMode": + """ + Parses inequality strings to :class:`.ComparatorMode` objects. + + Parameters + ---------- + mode : str + One of ">=", ">", "<=", and "<". + + Returns + ------- + ComparatorMode + The :class:`ComparatorMode` representing the inequality + """ + mode_map = { + "<": cls.LT, + "<=": cls.LE, + ">": cls.GT, + ">=": cls.GE, + } + + normalized_mode = mode.strip() + + try: + return mode_map[normalized_mode] + except KeyError as exc: + raise LatticeException( + f"Unsupported comparator mode '{mode}'. Expected one of: <, <=, >, >=." + ) from exc + + def to_string(self) -> str: + """ + Get the string representation of this object. + + Returns + ------- + str + One of "<", "<=", ">", ">=". + """ + comparator_strings = { + ComparatorMode.LT: "<", + ComparatorMode.LE: "<=", + ComparatorMode.GT: ">", + ComparatorMode.GE: ">=", + } + + return comparator_strings[self] + + def to_operator(self) -> Callable[[int, int], bool]: + """ + Get the Python comparison operator represented by this mode. + + Returns + ------- + Callable[[int, int], bool] + The function taking to integers and returning a boolean representing the comparison of the integers. + """ + comparator_operations = { + ComparatorMode.LT: lt, + ComparatorMode.LE: le, + ComparatorMode.GT: gt, + ComparatorMode.GE: ge, + } + return comparator_operations[self] diff --git a/test/e2e/__init__.py b/test/e2e/__init__.py new file mode 100644 index 0000000..99978b9 --- /dev/null +++ b/test/e2e/__init__.py @@ -0,0 +1 @@ +"""End-to-end tests for the qlbm package.""" \ No newline at end of file diff --git a/test/e2e/test_ab_e2e.py b/test/e2e/test_ab_e2e.py new file mode 100644 index 0000000..639830e --- /dev/null +++ b/test/e2e/test_ab_e2e.py @@ -0,0 +1,284 @@ +"""End-to-end tests for the ABQLBM algorithm. + +The ABQLBM algorithm performs streaming followed by zone-agnostic reflection. +The D2Q9 velocity channels are indexed as: + + 0: [ 0, 0] (rest) + 1: [+1, 0] (right) + 2: [ 0,+1] (up) + 3: [-1, 0] (left) + 4: [ 0,-1] (down) + 5: [+1,+1] (right-up) + 6: [-1,+1] (left-up) + 7: [-1,-1] (left-down) + 8: [+1,-1] (right-down) +""" + +import pytest + +from qlbm.components.ab import ABQLBM +from qlbm.lattice import ABLattice + +from .utils import ( + decode_state, + get_nonzero_amplitudes, + make_ab_qubit_layout, + prepare_single_particle, + run_statevector, +) + +# Bounceback reverses velocity direction. +D2Q9_BOUNCEBACK = { + 0: 0, + 1: 3, + 2: 4, + 3: 1, + 4: 2, + 5: 7, + 6: 8, + 7: 5, + 8: 6, +} + + +# --------------------------------------------------------------------------- +# Free streaming (no geometry) +# --------------------------------------------------------------------------- + + +class TestABFreeStreaming: + """ABQLBM on a 4x4 D2Q9 lattice without obstacles. + + With no geometry the reflection operator reduces to an identity, + so the algorithm is pure streaming: a particle at position ``(x, y)`` + with velocity channel ``c`` moves to ``(x + vx, y + vy) mod 4``. + """ + + @pytest.fixture + def lattice(self): + """4x4 D2Q9 lattice with no obstacles (9 qubits).""" + return ABLattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": "D2Q9"}, + "geometry": [], + } + ) + + def test_lattice_qubit_count(self, lattice): + """Verify expected register sizes.""" + assert lattice.num_dims == 2 + assert lattice.num_gridpoints == [3, 3] + assert lattice.num_grid_qubits == 4 + assert lattice.num_velocity_qubits == 4 + assert lattice.num_total_qubits == 9 + + def test_stream_right(self, lattice): + """Channel 1 (+x): (0,0) -> (1,0) after 1 step.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (0, 0), 1) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 0 + assert decoded["v"] == 1 + assert decoded["a_o"] == 0 + + def test_stream_up(self, lattice): + """Channel 2 (+y): (0,0) -> (0,1) after 1 step.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (0, 0), 2) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 0 + assert decoded["g_y"] == 1 + assert decoded["v"] == 2 + assert decoded["a_o"] == 0 + + def test_stream_diagonal(self, lattice): + """Channel 5 (+x,+y): (0,0) -> (1,1) after 1 step.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (0, 0), 5) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 1 + assert decoded["v"] == 5 + assert decoded["a_o"] == 0 + + def test_stream_left_wraps(self, lattice): + """Channel 3 (-x): (0,0) -> (3,0) via periodic wrap after 1 step.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (0, 0), 3) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 3 + assert decoded["g_y"] == 0 + assert decoded["v"] == 3 + assert decoded["a_o"] == 0 + + def test_rest_particle_stays(self, lattice): + """Channel 0 (rest): (1,2) -> (1,2) after 1 step.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (1, 2), 0) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 2 + assert decoded["v"] == 0 + assert decoded["a_o"] == 0 + + def test_two_steps_right(self, lattice): + """Channel 1 (+x): (0,0) -> (2,0) after 2 steps.""" + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (0, 0), 1) + circuit.compose(alg.circuit, inplace=True) + circuit.compose(alg.circuit.copy(), inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 2 + assert decoded["g_y"] == 0 + assert decoded["v"] == 1 + assert decoded["a_o"] == 0 + + +# Streaming with bounceback obstacle +class TestABBounceback: + """ABQLBM on an 8x8 D2Q9 lattice with one bounceback cuboid. + + The zone-agnostic reflection algorithm works as follows. After + streaming, if a particle ends up inside the obstacle, its velocity is + reversed and the position is corrected so that it stays just outside the + obstacle wall (i.e., the particle effectively reflects at the boundary). + + Analytically: ``|p, v> -> |p, -v>`` when ``p+v`` falls inside the + obstacle, where ``-v`` is the bounceback-reversed velocity. + """ + + @pytest.fixture + def lattice(self): + """8x8 D2Q9 lattice with a bounceback wall at x in [3,5], y in [0,6].""" + return ABLattice( + { + "lattice": {"dim": {"x": 8, "y": 8}, "velocities": "D2Q9"}, + "geometry": [ + { + "shape": "cuboid", + "x": [3, 5], + "y": [0, 6], + "boundary": "bounceback", + } + ], + } + ) + + def test_lattice_qubit_count(self, lattice): + """Verify register sizes with obstacle.""" + assert lattice.num_dims == 2 + assert lattice.num_gridpoints == [7, 7] + assert lattice.num_grid_qubits == 6 + assert lattice.num_velocity_qubits == 4 + assert lattice.num_obstacle_qubits == 1 + assert lattice.num_comparator_qubits == 2 + assert lattice.num_total_qubits == 13 + + def test_bounceback_right_into_wall(self, lattice): + """Particle at (2,1) with v=1 (+x) bounces off the obstacle. + + After streaming the particle would land at (3,1) which is inside + the obstacle [3,5]x[0,6]. Bounceback reflects it back to (2,1) + with reversed velocity v=3 (-x). + """ + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (2, 1), 1) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 2 + assert decoded["g_y"] == 1 + assert decoded["v"] == D2Q9_BOUNCEBACK[1] + assert decoded["a_o"] == 0 + assert decoded["a_c"] == 0 + + def test_free_streaming_away_from_obstacle(self, lattice): + """Particle at (1,1) with v=3 (-x) streams normally past the obstacle. + + Target position (0,1) is outside the obstacle. + """ + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (1, 1), 3) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 0 + assert decoded["g_y"] == 1 + assert decoded["v"] == 3 + assert decoded["a_o"] == 0 + assert decoded["a_c"] == 0 + + def test_bounceback_then_free_streaming(self, lattice): + """Two-step test: bounce off wall, then stream freely. + + Step 1: (2,1) v=1 -> bounces to (2,1) v=3 + Step 2: (2,1) v=3 -> streams to (1,1) v=3 + """ + alg = ABQLBM(lattice) + circuit = prepare_single_particle(lattice, (2, 1), 1) + circuit.compose(alg.circuit, inplace=True) + circuit.compose(alg.circuit.copy(), inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ab_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 1 + assert decoded["v"] == 3 + assert decoded["a_o"] == 0 + assert decoded["a_c"] == 0 diff --git a/test/e2e/test_lqlga_e2e.py b/test/e2e/test_lqlga_e2e.py new file mode 100644 index 0000000..957d9c1 --- /dev/null +++ b/test/e2e/test_lqlga_e2e.py @@ -0,0 +1,184 @@ +"""End-to-end tests for the LQLGA algorithm. + +The LQLGA (Linear Quantum Lattice Gas Algorithm) uses one qubit per velocity +channel per gridpoint, with log-depth swap-based streaming and EQC collision. + +For D1Q2 on a 4-gridpoint lattice (8 qubits): + - Gridpoints 0, 1, 2, 3 + - Each gridpoint has 2 velocity qubits: vel_0 (+x) and vel_1 (-x) + - Qubit layout: gp0_v0, gp0_v1, gp1_v0, gp1_v1, gp2_v0, gp2_v1, gp3_v0, gp3_v1 + +After 1 algorithm step (collision + streaming + reflection): + - A particle at gridpoint ``g`` with vel_0 moves to ``g+1`` + - A particle at gridpoint ``g`` with vel_1 moves to ``g-1`` + - Periodic wrapping applies at lattice boundaries +""" + +from typing import Dict + +import numpy as np +import pytest + +from qlbm.components.lqlga import LQLGA +from qlbm.components.lqlga.initial import LQGLAInitialConditions +from qlbm.lattice import LQLGALattice + +from .utils import run_statevector + + +def _decode_lqlga_state(sv, num_gridpoints: int, num_velocities: int): + """Decode LQLGA statevector into per-gridpoint velocity occupancy. + + Returns a dict ``{gridpoint: {velocity: 1}}`` for occupied channels. + """ + data = np.array(sv) + nonzero = np.where(np.abs(data) > 1e-8)[0] + occupied: Dict = {} + for idx in nonzero: + for gp in range(num_gridpoints): + for v in range(num_velocities): + bit = gp * num_velocities + v + if (idx >> bit) & 1: + occupied.setdefault(gp, {})[v] = 1 + return occupied + + +# Free streaming (no geometry) +class TestLQLGAFreeStreaming: + """LQLGA D1Q2 on a 4-gridpoint lattice, no obstacles (8 qubits).""" + + @pytest.fixture + def lattice(self): + """4-gridpoint D1Q2, no obstacles.""" + return LQLGALattice( + { + "lattice": {"dim": {"x": 4}, "velocities": "D1Q2"}, + "geometry": [], + } + ) + + def test_lattice_qubit_count(self, lattice): + """Verify expected register sizes.""" + assert lattice.num_total_qubits == 8 + + def test_stream_right(self, lattice): + """Particle at gp1 with vel_0 (+x) -> gp2 after 1 step.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((1,), (True, False))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {2: {0: 1}} + + def test_stream_left(self, lattice): + """Particle at gp1 with vel_1 (-x) -> gp0 after 1 step.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((1,), (False, True))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {0: {1: 1}} + + def test_stream_wraps(self, lattice): + """Particle at gp0 with vel_1 (-x) wraps to gp3.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((0,), (False, True))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {3: {1: 1}} + + def test_both_velocities_split(self, lattice): + """Particle at gp1 with both velocities splits to gp0 and gp2.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((1,), (True, True))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {0: {1: 1}, 2: {0: 1}} + + def test_two_steps_right(self, lattice): + """Particle at gp1 with vel_0 -> gp3 after 2 steps.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((1,), (True, False))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + circuit.compose(alg.circuit.copy(), inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {3: {0: 1}} + + +# Streaming with bounceback obstacle +class TestLQLGABounceback: + """LQLGA D1Q2 with a bounceback obstacle at gridpoint 3 (8 qubits).""" + + @pytest.fixture + def lattice(self): + """4-gridpoint D1Q2, obstacle at gp3.""" + return LQLGALattice( + { + "lattice": {"dim": {"x": 4}, "velocities": "D1Q2"}, + "geometry": [ + {"shape": "cuboid", "x": [3, 3], "boundary": "bounceback"} + ], + } + ) + + def test_bounceback_into_wall(self, lattice): + """Particle at gp2 with vel_0 (+x) bounces off obstacle at gp3. + + The particle stays at gp2 with reversed velocity vel_1 (-x). + """ + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((2,), (True, False))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {2: {1: 1}} + + def test_free_streaming_away_from_obstacle(self, lattice): + """Particle at gp2 with vel_1 (-x) streams freely to gp1.""" + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((2,), (False, True))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {1: {1: 1}} + + def test_bounceback_then_free_streaming(self, lattice): + """Two-step: bounce then stream freely. + + Step 1: gp2 vel_0 -> bounces -> gp2 vel_1 + Step 2: gp2 vel_1 -> streams -> gp1 vel_1 + """ + alg = LQLGA(lattice) + ic = LQGLAInitialConditions(lattice, grid_data=[((2,), (True, False))]) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + circuit.compose(alg.circuit.copy(), inplace=True) + sv = run_statevector(circuit) + + result = _decode_lqlga_state(sv, 4, 2) + assert result == {1: {1: 1}} diff --git a/test/e2e/test_ms_e2e.py b/test/e2e/test_ms_e2e.py new file mode 100644 index 0000000..b3c7fb4 --- /dev/null +++ b/test/e2e/test_ms_e2e.py @@ -0,0 +1,238 @@ +"""End-to-end tests for the MSQLBM algorithm. + +The MSQLBM algorithm uses per-dimension velocity encoding with separate +magnitude and direction qubits. Streaming is performed via CFL substeps. + +For a 4x4 lattice with 4 discrete velocities per dimension: + - 1 velocity magnitude qubit per dimension (values 0 or 1) + - 1 velocity direction qubit per dimension (1 = positive, 0 = negative) + - CFL time series ``get_time_series(4) = [[1], [1], [0, 1]]``: + magnitude 1 streams in all 3 substeps, magnitude 0 streams once. + +Therefore per algorithm step: + - A particle with magnitude 1 moves +/-3 gridpoints. + - A particle with magnitude 0 moves +/-1 gridpoint. +""" + +import pytest + +from qlbm.components.ms import MSQLBM +from qlbm.lattice import MSLattice + +from .utils import ( + decode_state, + get_nonzero_amplitudes, + make_ms_qubit_layout, + prepare_ms_particle, + run_statevector, +) + + +# Free streaming (no geometry) +class TestMSFreeStreaming: + """MSQLBM on a 4x4 lattice with 4 velocities/dim, no obstacles (13 qubits). + + With no geometry, the reflection operator is absent and the algorithm + is pure streaming through CFL substeps. + """ + + @pytest.fixture + def lattice(self): + """4x4, 4 vel/dim, no obstacles.""" + return MSLattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": {"x": 4, "y": 4}}, + "geometry": [], + } + ) + + def test_lattice_qubit_count(self, lattice): + """Verify expected register sizes.""" + assert lattice.num_total_qubits == 13 + + def test_stream_slow_positive(self, lattice): + """Magnitude 0 with positive direction: (0,0) -> (1,1) after 1 step. + + Magnitude 0 streams once (last CFL substep only), moving +1 in each dim. + """ + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (0, 0), (0, 0), (1, 1)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 1 + assert decoded["vd_x"] == 1 + assert decoded["vd_y"] == 1 + assert decoded["a_v"] == 0 + + def test_stream_slow_negative(self, lattice): + """Magnitude 0 with negative direction: (1,1) -> (0,0) after 1 step.""" + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (1, 1), (0, 0), (0, 0)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 0 + assert decoded["g_y"] == 0 + assert decoded["vd_x"] == 0 + assert decoded["vd_y"] == 0 + assert decoded["a_v"] == 0 + + def test_stream_slow_negative_wraps(self, lattice): + """Magnitude 0 with negative direction from (0,0) wraps to (3,3).""" + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (0, 0), (0, 0), (0, 0)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 3 + assert decoded["g_y"] == 3 + + def test_stream_fast_positive(self, lattice): + """Magnitude 1 with positive x, magnitude 0 with positive y. + + Magnitude 1 streams 3 times in x (+3), magnitude 0 streams once in y (+1). + (0,0) -> (3,1). + """ + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (0, 0), (1, 0), (1, 1)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 3 + assert decoded["g_y"] == 1 + assert decoded["v_x"] == 1 + assert decoded["vd_x"] == 1 + + def test_stream_mixed_directions(self, lattice): + """Positive x, negative y: (1,0) -> (2,3) after 1 step. + + Magnitude 0 in both dims, streams once: x+1=2, y-1=-1=3 mod 4. + """ + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (1, 0), (0, 0), (1, 0)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 2 + assert decoded["g_y"] == 3 + + def test_two_steps(self, lattice): + """Two steps with magnitude 0, positive direction: (0,0) -> (2,2).""" + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (0, 0), (0, 0), (1, 1)) + circuit.compose(alg.circuit, inplace=True) + circuit.compose(alg.circuit.copy(), inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 2 + assert decoded["g_y"] == 2 + + +# --------------------------------------------------------------------------- +# Streaming with bounceback obstacle +# --------------------------------------------------------------------------- + + +class TestMSBounceback: + """MSQLBM on a 4x4 lattice with a bounceback obstacle (13 qubits). + + The obstacle spans x in [2,3], y in [0,3]. Particles streaming into + the obstacle have their velocity direction flipped and are streamed + back out. + """ + + @pytest.fixture + def lattice(self): + """4x4, 4 vel/dim, obstacle at x=[2,3], y=[0,3].""" + return MSLattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": {"x": 4, "y": 4}}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 3], + "y": [0, 3], + "boundary": "bounceback", + } + ], + } + ) + + def test_bounceback_into_wall(self, lattice): + """Particle at (1,0) with v_mag=0, vd=(+,+) bounces off obstacle. + + After streaming the particle would land at (2,1), which is inside + the obstacle [2,3]x[0,3]. Bounceback reflects it back to (1,0) + with flipped direction vd=(0,0) = (-,-). + + The position and velocity direction are the primary assertion. + The obstacle ancilla may be dirty after reflection. + """ + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (1, 0), (0, 0), (1, 1)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 1 + assert decoded["g_y"] == 0 + assert decoded["vd_x"] == 0 + assert decoded["vd_y"] == 0 + + def test_free_streaming_away_from_obstacle(self, lattice): + """Particle at (1,1) with vd=(-,+) streams freely away from obstacle. + + Target position (0,2) is outside the obstacle. + """ + alg = MSQLBM(lattice) + circuit = prepare_ms_particle(lattice, (1, 1), (0, 0), (0, 1)) + circuit.compose(alg.circuit, inplace=True) + + sv = run_statevector(circuit) + amps = get_nonzero_amplitudes(sv) + layout = make_ms_qubit_layout(lattice) + + assert len(amps) == 1 + decoded = decode_state(list(amps.keys())[0], layout) + assert decoded["g_x"] == 0 + assert decoded["g_y"] == 2 + assert decoded["vd_x"] == 0 + assert decoded["vd_y"] == 1 + assert decoded["a_o"] == 0 + assert decoded["a_v"] == 0 diff --git a/test/e2e/test_spacetime_e2e.py b/test/e2e/test_spacetime_e2e.py new file mode 100644 index 0000000..6c97ca2 --- /dev/null +++ b/test/e2e/test_spacetime_e2e.py @@ -0,0 +1,196 @@ +"""End-to-end tests for the SpaceTimeQLBM algorithm. + +The SpaceTimeQLBM uses a space-time encoding where velocity information for +neighboring gridpoints is pre-loaded into the register and streaming is +performed via SWAP gates. + +For D1Q2 on a 16-point 1D lattice with 1 timestep (10 qubits): + - 4 grid qubits (positions 0-15) + - 6 velocity qubits: 2 at origin, 2 for right neighbor, 2 for left neighbor + - Velocity 0 = positive direction (+x) + - Velocity 1 = negative direction (-x) + +After 1 streaming step, a particle at position ``x`` with velocity 0 +arrives at position ``x+1``, and with velocity 1 at ``x-1``. + +Only the origin velocity qubits (v0, v1) carry meaningful post-streaming data. +The neighbor velocity qubits contain residual swap artifacts. +""" + +import numpy as np +import pytest + +from qlbm.components.spacetime import SpaceTimeQLBM +from qlbm.components.spacetime.initial import PointWiseSpaceTimeInitialConditions +from qlbm.lattice import SpaceTimeLattice + +from .utils import run_statevector + + +def _origin_velocities_by_position(sv, num_grid_qubits: int, num_velocities: int): + """Extract origin velocity values keyed by grid position. + + Returns a dict ``{position: (vel_0, vel_1, ...)}`` for positions + where at least one origin velocity qubit is set. + """ + data = np.array(sv) + nonzero = np.where(np.abs(data) > 1e-8)[0] + result = {} + grid_mask = (1 << num_grid_qubits) - 1 + for idx in nonzero: + g = idx & grid_mask + vels = tuple((idx >> (num_grid_qubits + v)) & 1 for v in range(num_velocities)) + if any(vels): + result[g] = vels + return result + + +# Free streaming (no geometry) +class TestSpaceTimeFreeStreaming: + """SpaceTimeQLBM D1Q2 on a 16-point 1D lattice, 1 timestep, no obstacles (10 qubits).""" + + @pytest.fixture + def lattice(self): + """16-point D1Q2, 1 timestep, no obstacles.""" + return SpaceTimeLattice( + num_timesteps=1, + lattice_data={ + "lattice": {"dim": {"x": 16}, "velocities": "D1Q2"}, + "geometry": [], + }, + ) + + def test_lattice_qubit_count(self, lattice): + """Verify expected register sizes.""" + assert lattice.num_total_qubits == 10 + + def test_stream_right(self, lattice): + """Particle at x=5 with vel_0 (+x) -> x=6 after 1 step.""" + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((5,), (True, False))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {6: (1, 0)} + + def test_stream_left(self, lattice): + """Particle at x=10 with vel_1 (-x) -> x=9 after 1 step.""" + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((10,), (False, True))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {9: (0, 1)} + + def test_two_particles(self, lattice): + """Two particles streaming independently: x=5 right and x=10 left.""" + ic = PointWiseSpaceTimeInitialConditions( + lattice, + grid_data=[ + ((5,), (True, False)), + ((10,), (False, True)), + ], + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {6: (1, 0), 9: (0, 1)} + + def test_both_velocities_at_same_point(self, lattice): + """Particle at x=8 with both vel_0 and vel_1 splits to x=7 and x=9.""" + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((8,), (True, True))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {9: (1, 0), 7: (0, 1)} + + +# --------------------------------------------------------------------------- +# Streaming with bounceback obstacle +# --------------------------------------------------------------------------- + + +class TestSpaceTimeBounceback: + """SpaceTimeQLBM D1Q2 with a bounceback obstacle at x in [7, 8] (10 qubits).""" + + @pytest.fixture + def lattice(self): + """16-point D1Q2, 1 timestep, obstacle at x=[7,8].""" + return SpaceTimeLattice( + num_timesteps=1, + lattice_data={ + "lattice": {"dim": {"x": 16}, "velocities": "D1Q2"}, + "geometry": [ + {"shape": "cuboid", "x": [7, 8], "boundary": "bounceback"} + ], + }, + ) + + def test_bounceback_into_wall(self, lattice): + """Particle at x=6 with vel_0 (+x) bounces off obstacle at [7,8]. + + After streaming the particle would land at x=7 which is inside + the obstacle. Bounceback reflects it back to x=6 with vel_1 (-x). + """ + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((6,), (True, False))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {6: (0, 1)} + + def test_free_streaming_away_from_obstacle(self, lattice): + """Particle at x=6 with vel_1 (-x) streams freely to x=5.""" + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((6,), (False, True))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {5: (0, 1)} + + def test_bounceback_from_other_side(self, lattice): + """Particle at x=9 with vel_1 (-x) bounces off obstacle at [7,8]. + + Would land at x=8 (inside obstacle). Reflects back to x=9 with vel_0. + """ + ic = PointWiseSpaceTimeInitialConditions( + lattice, grid_data=[((9,), (False, True))] + ) + alg = SpaceTimeQLBM(lattice) + + circuit = ic.circuit.copy() + circuit.compose(alg.circuit, inplace=True) + sv = run_statevector(circuit) + + result = _origin_velocities_by_position(sv, 4, 2) + assert result == {9: (1, 0)} diff --git a/test/e2e/utils.py b/test/e2e/utils.py new file mode 100644 index 0000000..2727d10 --- /dev/null +++ b/test/e2e/utils.py @@ -0,0 +1,225 @@ +"""Shared utilities for end-to-end tests.""" + +from typing import Dict, Tuple + +import numpy as np +from qiskit import QuantumCircuit, transpile +from qiskit.quantum_info import Statevector +from qiskit_aer import AerSimulator + + +def run_statevector(circuit: QuantumCircuit) -> Statevector: + """Run a circuit on AerSimulator and return the final statevector. + + Parameters + ---------- + circuit : QuantumCircuit + The circuit to simulate. A ``save_statevector`` instruction is + appended automatically. + + Returns + ------- + Statevector + The resulting statevector. + """ + qc = circuit.copy() + qc.save_statevector() + sim = AerSimulator(method="statevector") + tqc = transpile(qc, sim, optimization_level=0) + result = sim.run(tqc).result() + return result.data(0)["statevector"] + + +def get_nonzero_amplitudes( + sv: Statevector, threshold: float = 1e-8 +) -> Dict[int, complex]: + """Return a dict mapping basis-state index to amplitude for nonzero entries. + + Parameters + ---------- + sv : Statevector + The statevector to inspect. + threshold : float + Amplitude magnitude below which entries are treated as zero. + + Returns + ------- + Dict[int, complex] + Mapping from statevector index to complex amplitude. + """ + data = np.array(sv) + nonzero_idx = np.where(np.abs(data) > threshold)[0] + return {int(idx): complex(data[idx]) for idx in nonzero_idx} + + +def decode_state( + index: int, qubit_ranges: Dict[str, Tuple[int, int]] +) -> Dict[str, int]: + """Decode a statevector index into named register values. + + Parameters + ---------- + index : int + The statevector basis-state index. + qubit_ranges : Dict[str, Tuple[int, int]] + Mapping from register name to ``(start_qubit, num_qubits)``. + + Returns + ------- + Dict[str, int] + Mapping from register name to the integer value stored in that register. + """ + result: Dict[str, int] = {} + for name, (start, size) in qubit_ranges.items(): + value = 0 + for i in range(size): + if index & (1 << (start + i)): + value |= 1 << i + result[name] = value + return result + + +def make_ab_qubit_layout(lattice) -> Dict[str, Tuple[int, int]]: + """Build a qubit-layout dictionary for an ABLattice. + + Parameters + ---------- + lattice : ABLattice + The lattice whose register structure to describe. + + Returns + ------- + Dict[str, Tuple[int, int]] + Mapping ``{register_name: (start_qubit, size)}``. + """ + dim_names = ["g_x", "g_y", "g_z"] + layout: Dict[str, Tuple[int, int]] = {} + for dim in range(lattice.num_dims): + layout[dim_names[dim]] = ( + lattice.grid_index(dim)[0], + len(lattice.grid_index(dim)), + ) + layout["v"] = (lattice.velocity_index()[0], lattice.num_velocity_qubits) + if lattice.num_comparator_qubits > 0: + layout["a_c"] = ( + lattice.ancillae_comparator_index()[0], + lattice.num_comparator_qubits, + ) + layout["a_o"] = ( + lattice.ancillae_obstacle_index()[0], + lattice.num_obstacle_qubits, + ) + return layout + + +def make_ms_qubit_layout(lattice) -> Dict[str, Tuple[int, int]]: + """Build a qubit-layout dictionary for an MSLattice. + + Parameters + ---------- + lattice : MSLattice + The lattice whose register structure to describe. + + Returns + ------- + Dict[str, Tuple[int, int]] + Mapping ``{register_name: (start_qubit, size)}``. + """ + dim_names = ["g_x", "g_y", "g_z"] + layout: Dict[str, Tuple[int, int]] = {} + layout["a_v"] = ( + lattice.ancillae_velocity_index()[0], + len(lattice.ancillae_velocity_index()), + ) + layout["a_o"] = ( + lattice.ancillae_obstacle_index()[0], + len(lattice.ancillae_obstacle_index()), + ) + if lattice.ancillae_comparator_index(): + layout["a_c"] = ( + lattice.ancillae_comparator_index()[0], + len(lattice.ancillae_comparator_index()), + ) + for dim in range(lattice.num_dims): + layout[dim_names[dim]] = ( + lattice.grid_index(dim)[0], + len(lattice.grid_index(dim)), + ) + for dim in range(lattice.num_dims): + vi = lattice.velocity_index(dim) + if vi: + layout[f"v_{dim_names[dim][-1]}"] = (vi[0], len(vi)) + for dim in range(lattice.num_dims): + layout[f"vd_{dim_names[dim][-1]}"] = ( + lattice.velocity_dir_index(dim)[0], + len(lattice.velocity_dir_index(dim)), + ) + return layout + + +def prepare_single_particle( + lattice, grid_pos: Tuple[int, ...], velocity_channel: int +) -> QuantumCircuit: + """Prepare a circuit with one particle at a specific position and velocity. + + Parameters + ---------- + lattice : ABLattice | MSLattice + The lattice that defines the register layout. + grid_pos : Tuple[int, ...] + Grid coordinates, one per dimension. + velocity_channel : int + Integer index of the velocity channel (binary-encoded into velocity qubits). + + Returns + ------- + QuantumCircuit + A circuit that prepares the desired initial state. + """ + circuit = QuantumCircuit(*lattice.registers) + for dim, pos in enumerate(grid_pos): + for i in range(lattice.num_gridpoints[dim].bit_length()): + if (pos >> i) & 1: + circuit.x(lattice.grid_index(dim)[i]) + for i in range(lattice.num_velocity_qubits): + if (velocity_channel >> i) & 1: + circuit.x(lattice.velocity_index()[i]) + return circuit + + +def prepare_ms_particle( + lattice, + grid_pos: Tuple[int, ...], + velocity_mag: Tuple[int, ...], + velocity_dir: Tuple[int, ...], +) -> QuantumCircuit: + """Prepare a circuit with one MS particle at a given position and velocity. + + Parameters + ---------- + lattice : MSLattice + The lattice that defines the register layout. + grid_pos : Tuple[int, ...] + Grid coordinates, one per dimension. + velocity_mag : Tuple[int, ...] + Velocity magnitude per dimension (binary-encoded). + velocity_dir : Tuple[int, ...] + Velocity direction per dimension (1 = positive, 0 = negative). + + Returns + ------- + QuantumCircuit + A circuit that prepares the desired initial state. + """ + circuit = QuantumCircuit(*lattice.registers) + for dim, pos in enumerate(grid_pos): + for i in range(len(lattice.grid_index(dim))): + if (pos >> i) & 1: + circuit.x(lattice.grid_index(dim)[i]) + for dim in range(lattice.num_dims): + for i in range(len(lattice.velocity_index(dim))): + if (velocity_mag[dim] >> i) & 1: + circuit.x(lattice.velocity_index(dim)[i]) + if velocity_dir[dim]: + circuit.x(lattice.velocity_dir_index(dim)[0]) + return circuit diff --git a/test/integration/compiler_test.py b/test/integration/compiler_test.py index be11dac..62a9bab 100644 --- a/test/integration/compiler_test.py +++ b/test/integration/compiler_test.py @@ -3,7 +3,6 @@ import pytest from qiskit import QuantumCircuit as QiskitQC from qiskit_aer import AerSimulator -from qulacs import QuantumCircuit as QulacsQC from qlbm.components import MSStreamingOperator from qlbm.infra import ( diff --git a/test/unit/ab/ab_initial_codnitions_test.py b/test/unit/ab/ab_initial_codnitions_test.py new file mode 100644 index 0000000..bf47fb2 --- /dev/null +++ b/test/unit/ab/ab_initial_codnitions_test.py @@ -0,0 +1,75 @@ +from itertools import product + +import pytest +from qiskit import ClassicalRegister, transpile +from qiskit_aer import AerSimulator + +from qlbm.components.ab.initial import ABDiscreteUniformInitialConditions + + +@pytest.mark.parametrize( + "velocities,lattice_fixture", + list( + product( + [[0], [0, 1], [6, 7, 8], [4, 5, 0, 7], list(range(9))], + ["ab_lattice_d2q9_8x8"], + ) + ), +) +def test_initial_ab_no_gird_superposition(velocities, lattice_fixture, request): + lattice = request.getfixturevalue(lattice_fixture) + sim = AerSimulator() + + qc = lattice.circuit.copy() + qc.add_register(ClassicalRegister(4)) + qc.compose( + ABDiscreteUniformInitialConditions( + lattice, velocities, ([], []) + ).circuit, + inplace=True, + ) + + qc.measure(lattice.velocity_index(), list(range(4))) + tqc = transpile(qc, sim, optimization_level=0) + + counts = sim.run(tqc, shots=256).result().get_counts() + + output_velocities = list(set([int(c, 2) for c in counts.keys()])) + + assert sorted(output_velocities) == sorted(velocities), ( + f"Expected output velocities to be {velocities}, got {output_velocities}" + ) + + +@pytest.mark.parametrize( + "velocities,lattice_fixture", + list( + product( + [[0], [0, 1], [6, 7, 8], [4, 5, 0, 7], list(range(9))], + ["oh_lattice_d2q9_8x8"], + ) + ), +) +def test_initial_oh_no_gird_superposition(velocities, lattice_fixture, request): + lattice = request.getfixturevalue(lattice_fixture) + sim = AerSimulator() + + qc = lattice.circuit.copy() + qc.add_register(ClassicalRegister(9)) + qc.compose( + ABDiscreteUniformInitialConditions( + lattice, velocities, ([], []) + ).circuit, + inplace=True, + ) + + qc.measure(lattice.velocity_index(), list(range(9))) + tqc = transpile(qc, sim, optimization_level=0) + + counts = sim.run(tqc, shots=256).result().get_counts() + + output_velocities = list(set([int(c, 2) for c in counts.keys()])) + + assert sorted(output_velocities) == sorted([2**v for v in velocities]), ( + f"Expected output velocities to be {velocities}, got {output_velocities}" + ) diff --git a/test/unit/ab/ab_standard_reflection_test.py b/test/unit/ab/ab_standard_reflection_test.py new file mode 100644 index 0000000..46ffeaf --- /dev/null +++ b/test/unit/ab/ab_standard_reflection_test.py @@ -0,0 +1,286 @@ +"""Statevector-level tests for the standard ABReflectionOperator.""" + +import pytest +from qiskit import QuantumCircuit, transpile +from qiskit.quantum_info import Statevector +from qiskit_aer import AerSimulator + +from qlbm.components.ab.reflection.standard_reflection import ABReflectionOperator +from qlbm.lattice import ABLattice +from qlbm.lattice.geometry.shapes.block import Block + +_SIMULATOR = AerSimulator(method="statevector") + + +def _simulate_statevector(circuit: QuantumCircuit) -> Statevector: + """Run a circuit on AerSimulator and return the final statevector.""" + qc = circuit.copy() + qc.save_statevector() + tqc = transpile(qc, _SIMULATOR, optimization_level=0) + result = _SIMULATOR.run(tqc).result() + return result.data(0)["statevector"] + + +def _make_single_geometry_lattice( + dim_x=8, dim_y=8, x_bounds=(2, 5), y_bounds=(2, 5) +) -> ABLattice: + """Create a single-geometry ABLattice with a cuboid obstacle.""" + return ABLattice( + { + "lattice": {"dim": {"x": dim_x, "y": dim_y}, "velocities": "d2q9"}, + "geometry": [ + { + "shape": "cuboid", + "x": list(x_bounds), + "y": list(y_bounds), + "boundary": "bounceback", + } + ], + } + ) + + +def _make_multi_geometry_lattice() -> ABLattice: + """Create a multi-geometry ABLattice with two cuboid configurations.""" + lattice = ABLattice( + { + "lattice": {"dim": {"x": 8, "y": 8}, "velocities": "d2q9"}, + } + ) + + lattice.set_geometries( + [ + [ + { + "shape": "cuboid", + "x": [2, 5], + "y": [2, 5], + "boundary": "bounceback", + } + ], + [ + { + "shape": "cuboid", + "x": [1, 3], + "y": [1, 3], + "boundary": "bounceback", + } + ], + ] + ) + + return lattice + + +def _encode_basis_state(lattice: ABLattice, x: int, y: int, v: int, marker: int = 0): + """Encode a computational basis state on the lattice circuit. + + Grid positions and velocity are encoded in binary representation. + Ancillae are initialized to 0 and the marker is set via X gates. + """ + circuit = lattice.circuit.copy() + + for i, q in enumerate(lattice.grid_index(0)): + if (x >> i) & 1: + circuit.x(q) + + for i, q in enumerate(lattice.grid_index(1)): + if (y >> i) & 1: + circuit.x(q) + + for i, q in enumerate(lattice.velocity_index()): + if (v >> i) & 1: + circuit.x(q) + + if lattice.num_marker_qubits > 0: + for i, q in enumerate(lattice.marker_index()): + if (marker >> i) & 1: + circuit.x(q) + + return circuit + + +def _get_obstacle_ancilla_value(lattice: ABLattice, sv: Statevector) -> dict: + """Extract obstacle ancilla probabilities from a statevector. + + Returns a dict mapping obstacle ancilla value (0 or 1) to probability. + """ + obstacle_idx = lattice.ancillae_obstacle_index() + probs = {} + for val in [0, 1]: + prob = 0.0 + for i, amp in enumerate(sv.data): + obstacle_val = (i >> obstacle_idx[0]) & 1 + if obstacle_val == val: + prob += abs(amp) ** 2 + probs[val] = prob + return probs + + +# ============================================================================= +# Statevector: single geometry full operator +# ============================================================================= + + +class TestStandardReflectionSingleGeometryStatevector: + """Statevector-level verification of the standard reflection with a single geometry.""" + + def test_obstacle_ancilla_clean_outside_obstacle(self): + """Obstacle ancilla should be 0 for positions well outside the obstacle. + + For the rest velocity (v=0, stationary particles), positions far from + the obstacle should be unaffected by the reflection operator. + """ + lattice = _make_single_geometry_lattice() + op = ABReflectionOperator(lattice) + + # Position (0, 0) is far from obstacle [2,5]x[2,5] + prep = _encode_basis_state(lattice, x=0, y=0, v=0) + prep.compose(op.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + def test_obstacle_ancilla_clean_at_corner_outside(self): + """Obstacle ancilla should be 0 at outside corners of the obstacle. + + The gridpoints immediately adjacent to the obstacle corners + (in the fluid domain) should have their obstacle ancilla correctly + reset after the full reflection operator. + """ + lattice = _make_single_geometry_lattice() + op = ABReflectionOperator(lattice) + + # Position (1, 1) is outside the obstacle [2,5]x[2,5] + # and is an outside corner point + prep = _encode_basis_state(lattice, x=1, y=1, v=0) + prep.compose(op.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + def test_operator_is_consistent_across_velocities_outside(self): + """Obstacle ancilla should remain 0 for various velocities at positions outside. + + For positions outside the obstacle, no velocity should trigger + the obstacle ancilla. + """ + lattice = _make_single_geometry_lattice() + op = ABReflectionOperator(lattice) + + for v in range(9): + prep = _encode_basis_state(lattice, x=0, y=0, v=v) + prep.compose(op.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx( + 1.0, abs=1e-10 + ), f"Obstacle ancilla not clean for v={v} at (0,0)" + + +# ============================================================================= +# Statevector: set_inside_wall_ancilla_state +# ============================================================================= + + +class TestSetInsideWallAncillaStatevector: + """Statevector-level tests for the set_inside_wall_ancilla_state primitive. + + This primitive sets the obstacle ancilla for positions lying along the + walls of the obstacle (excluding corners). It uses SpecularWallComparator + to identify wall positions. + """ + + def test_wall_ancilla_set_for_interior_wall_point(self): + """Interior wall points should have their obstacle ancilla set. + + For a [2,5]x[2,5] obstacle, position (3, 2) lies on the y=2 wall + and is inside the obstacle. The wall ancilla state primitive should + set the obstacle ancilla for this position. + """ + lattice = _make_single_geometry_lattice() + op = ABReflectionOperator(lattice) + block: Block = lattice.shapes["bounceback"][0] # type: ignore[assignment] + + wall_circuit = op.set_inside_wall_ancilla_state(block) + + prep = _encode_basis_state(lattice, x=3, y=2, v=0) + prep.compose(wall_circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_wall_ancilla_not_set_for_point_outside_obstacle(self): + """Positions clearly outside the obstacle should not have ancilla set.""" + lattice = _make_single_geometry_lattice() + op = ABReflectionOperator(lattice) + block: Block = lattice.shapes["bounceback"][0] # type: ignore[assignment] + + wall_circuit = op.set_inside_wall_ancilla_state(block) + + prep = _encode_basis_state(lattice, x=0, y=0, v=0) + prep.compose(wall_circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + +# ============================================================================= +# Statevector: multi-geometry +# ============================================================================= + + +class TestStandardReflectionMultiGeometry: + """Statevector tests for the standard reflection operator with multiple geometries.""" + + def test_multi_geometry_obstacle_ancilla_clean_outside_all(self): + """Positions outside all obstacles should have clean ancilla for all markers.""" + lattice = _make_multi_geometry_lattice() + op = ABReflectionOperator(lattice) + + # (7, 7) is outside both obstacle [2,5]x[2,5] and [1,3]x[1,3] + for marker_val in [0, 1]: + prep = _encode_basis_state(lattice, x=7, y=7, v=0, marker=marker_val) + prep.compose(op.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx( + 1.0, abs=1e-10 + ), f"Failed for marker={marker_val}" + + def test_multi_geometry_operator_consistent_between_explicit_and_inferred(self): + """Operator with shapes=None should produce same statevector as explicit shapes. + + For multi-geometry, shapes are inferred from lattice.geometries. + Passing None should give the same result as passing the grouped shapes. + """ + lattice = _make_multi_geometry_lattice() + + op_inferred = ABReflectionOperator(lattice) + + grouped_shapes = [ + gdict["bounceback"] + gdict["specular"] for gdict in lattice.geometries + ] + op_explicit = ABReflectionOperator(lattice, shapes=grouped_shapes) # type: ignore[arg-type] + + # Verify statevector equivalence at representative points + for marker_val in [0, 1]: + prep_a = _encode_basis_state( + lattice, x=7, y=7, v=0, marker=marker_val + ) + prep_a.compose(op_inferred.circuit, inplace=True) + sv_a = _simulate_statevector(prep_a) + + prep_b = _encode_basis_state( + lattice, x=7, y=7, v=0, marker=marker_val + ) + prep_b.compose(op_explicit.circuit, inplace=True) + sv_b = _simulate_statevector(prep_b) + + assert sv_a.equiv(sv_b), f"Mismatch at (7,7), marker={marker_val}" diff --git a/test/unit/ab/ab_zone_agnostic_reflection_oracle_test.py b/test/unit/ab/ab_zone_agnostic_reflection_oracle_test.py new file mode 100644 index 0000000..42809d4 --- /dev/null +++ b/test/unit/ab/ab_zone_agnostic_reflection_oracle_test.py @@ -0,0 +1,82 @@ +import pytest +from qiskit import QuantumCircuit + +from qlbm.components.ab.reflection.agnosotic_reflection import ( + ABZoneAgnosticReflectionOracle, +) +from qlbm.lattice import ABLattice +from qlbm.tools.exceptions import CircuitException + + +def test_ymonomial_oracle_raises_for_non_quadratic_exponent(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 8, "y": 8}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 1, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + + shape = lattice.shapes["bounceback"][0] + + with pytest.raises(CircuitException) as excinfo: + ABZoneAgnosticReflectionOracle(lattice, shape) + + assert ( + "YMonomial oracle is a work in progress: only exponent=2 (x^2) is currently supported." + == str(excinfo.value) + ) + + +def test_ymonomial_oracle_raises_for_mismatched_y_and_result_registers(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 8, "y": 8}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + + shape = lattice.shapes["bounceback"][0] + + with pytest.raises(CircuitException) as excinfo: + ABZoneAgnosticReflectionOracle(lattice, shape) + + assert ( + "YMonomial oracle is a work in progress: only configurations with equal y and monomial result register sizes are currently supported." + == str(excinfo.value) + ) + + +def test_ymonomial_oracle_builds_for_supported_work_in_progress_case(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 2, "y": 4}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + + shape = lattice.shapes["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle(lattice, shape) + + assert isinstance(oracle.circuit, QuantumCircuit) + assert oracle.circuit is not None diff --git a/test/unit/ab/ab_zone_agnostic_reflection_test.py b/test/unit/ab/ab_zone_agnostic_reflection_test.py new file mode 100644 index 0000000..2d585e7 --- /dev/null +++ b/test/unit/ab/ab_zone_agnostic_reflection_test.py @@ -0,0 +1,520 @@ +"""Statevector-level tests for the zone-agnostic reflection oracle and operator.""" + +import numpy as np +import pytest +from qiskit import QuantumCircuit, transpile +from qiskit.quantum_info import Statevector +from qiskit_aer import AerSimulator + +from qlbm.components.ab.reflection.agnosotic_reflection import ( + ABZoneAgnosticReflectionOperator, + ABZoneAgnosticReflectionOracle, +) +from qlbm.lattice import ABLattice +from qlbm.tools.exceptions import CircuitException + +# ============================================================================= +# Helper utilities +# ============================================================================= + +_SIMULATOR = AerSimulator(method="statevector") + + +def _simulate_statevector(circuit: QuantumCircuit) -> Statevector: + """Run a circuit on AerSimulator and return the final statevector.""" + qc = circuit.copy() + qc.save_statevector() + tqc = transpile(qc, _SIMULATOR, optimization_level=0) + result = _SIMULATOR.run(tqc).result() + return result.data(0)["statevector"] + + +def _make_single_geometry_lattice(dim_x=4, dim_y=4) -> ABLattice: + """Create a single-geometry ABLattice with a cuboid obstacle.""" + return ABLattice( + { + "lattice": {"dim": {"x": dim_x, "y": dim_y}, "velocities": "d2q9"}, + "geometry": [ + { + "shape": "cuboid", + "x": [1, 2], + "y": [1, 2], + "boundary": "bounceback", + } + ], + } + ) + + +def _make_multi_geometry_lattice(dim_x=4, dim_y=4) -> ABLattice: + """Create a multi-geometry ABLattice with two cuboid configurations.""" + lattice = ABLattice( + { + "lattice": {"dim": {"x": dim_x, "y": dim_y}, "velocities": "d2q9"}, + } + ) + + lattice.set_geometries( + [ + [ + { + "shape": "cuboid", + "x": [1, 2], + "y": [1, 2], + "boundary": "bounceback", + } + ], + [ + { + "shape": "cuboid", + "x": [0, 1], + "y": [0, 1], + "boundary": "bounceback", + } + ], + ] + ) + + return lattice + + +def _encode_basis_state(lattice: ABLattice, x: int, y: int, v: int, marker: int = 0): + r"""Encode a computational basis state |x>|y>|v>|ancillae>|marker>. + + The grid and velocity are encoded in the standard binary representation. + Ancillae are initialized to 0. Marker is set via X gates. + """ + circuit = lattice.circuit.copy() + + for i, q in enumerate(lattice.grid_index(0)): + if (x >> i) & 1: + circuit.x(q) + + for i, q in enumerate(lattice.grid_index(1)): + if (y >> i) & 1: + circuit.x(q) + + for i, q in enumerate(lattice.velocity_index()): + if (v >> i) & 1: + circuit.x(q) + + if lattice.num_marker_qubits > 0: + for i, q in enumerate(lattice.marker_index()): + if (marker >> i) & 1: + circuit.x(q) + + return circuit + + +def _get_obstacle_ancilla_value(lattice: ABLattice, sv: Statevector) -> dict: + """Extract the obstacle ancilla probabilities from a statevector. + + Returns a dict mapping obstacle ancilla value (0 or 1) to probability. + """ + obstacle_idx = lattice.ancillae_obstacle_index() + probs = {} + for val in [0, 1]: + prob = 0.0 + for i, amp in enumerate(sv.data): + obstacle_val = (i >> obstacle_idx[0]) & 1 + if obstacle_val == val: + prob += abs(amp) ** 2 + probs[val] = prob + return probs + + +# ============================================================================= +# Oracle: single geometry statevector tests +# ============================================================================= + + +class TestOracleSingleGeometry: + """Statevector tests for the oracle without marker control (single geometry).""" + + def test_oracle_marks_position_inside_block(self): + """Oracle should set obstacle ancilla for positions inside the block.""" + lattice = _make_single_geometry_lattice() + shape = lattice.shapes["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle(lattice, shape) + + # Position (1, 1) is inside the block [1,2] x [1,2] + prep = _encode_basis_state(lattice, x=1, y=1, v=0) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_oracle_does_not_mark_position_outside_block(self): + """Oracle should not set obstacle ancilla for positions outside the block.""" + lattice = _make_single_geometry_lattice() + shape = lattice.shapes["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle(lattice, shape) + + # Position (0, 0) is outside the block [1,2] x [1,2] + prep = _encode_basis_state(lattice, x=0, y=0, v=0) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + def test_oracle_marks_corner_of_block(self): + """Oracle should mark the upper corner of the block.""" + lattice = _make_single_geometry_lattice() + shape = lattice.shapes["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle(lattice, shape) + + # Position (2, 2) is the upper corner of the block [1,2] x [1,2] + prep = _encode_basis_state(lattice, x=2, y=2, v=0) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_oracle_is_self_inverse(self): + """Applying the oracle twice should return to the original state.""" + lattice = _make_single_geometry_lattice() + shape = lattice.shapes["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle(lattice, shape) + + prep = _encode_basis_state(lattice, x=1, y=1, v=0) + prep.compose(oracle.circuit, inplace=True) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + +# ============================================================================= +# Oracle: marker-controlled statevector tests +# ============================================================================= + + +class TestOracleWithMarkerControl: + """Statevector tests for the oracle with marker control (parallel BCs).""" + + def test_oracle_marks_only_when_marker_matches(self): + """Oracle controlled on marker should only set obstacle ancilla when marker is all-ones.""" + lattice = _make_multi_geometry_lattice() + shape = lattice.geometries[0]["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle( + lattice, shape, control_on_marker_state=True + ) + + # Position (1, 1) is inside the block. + # Marker = 1 (all ones for 1-qubit marker) -> should mark + prep = _encode_basis_state(lattice, x=1, y=1, v=0, marker=1) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_oracle_does_not_mark_when_marker_does_not_match(self): + """Oracle controlled on marker should NOT set obstacle ancilla when marker is not all-ones.""" + lattice = _make_multi_geometry_lattice() + shape = lattice.geometries[0]["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle( + lattice, shape, control_on_marker_state=True + ) + + # Position (1, 1) is inside the block. + # Marker = 0 (not all ones) -> should NOT mark + prep = _encode_basis_state(lattice, x=1, y=1, v=0, marker=0) + prep.compose(oracle.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + def test_oracle_preserves_grid_state_regardless_of_marker(self): + """Oracle should restore grid qubits to original state for all marker values. + + Even though the oracle temporarily modifies grid qubits (via subtraction/addition), + the net effect on the grid should be zero, regardless of marker state. + """ + lattice = _make_multi_geometry_lattice() + shape = lattice.geometries[0]["bounceback"][0] + oracle = ABZoneAgnosticReflectionOracle( + lattice, shape, control_on_marker_state=True + ) + + for marker_val in [0, 1]: + prep = _encode_basis_state(lattice, x=1, y=1, v=3, marker=marker_val) + original_sv = _simulate_statevector(prep) + + prep.compose(oracle.circuit, inplace=True) + after_sv = _simulate_statevector(prep) + + # Check that grid qubits are preserved by comparing marginal probabilities + grid_qubits = lattice.grid_index() + original_grid_probs = original_sv.probabilities(grid_qubits) + after_grid_probs = after_sv.probabilities(grid_qubits) + np.testing.assert_allclose( + original_grid_probs, after_grid_probs, atol=1e-10 + ) + + def test_ymonomial_raises_with_marker_control(self): + """YMonomial oracle should raise when control_on_marker_state=True.""" + lattice = ABLattice( + { + "lattice": {"dim": {"x": 2, "y": 4}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + lattice.set_num_marker_qubits(1) + + shape = lattice.shapes["bounceback"][0] + + with pytest.raises(CircuitException, match="Marker-controlled oracles"): + ABZoneAgnosticReflectionOracle(lattice, shape, control_on_marker_state=True) + + +# ============================================================================= +# Combined oracle statevector tests +# ============================================================================= + + +class TestCombinedOracle: + """Statevector tests for the combined oracle used in multi-geometry parallel BCs. + + The combined oracle applies each geometry's oracle controlled on the + corresponding marker state. It verifies that for a superposition of + marker states, the obstacle ancilla is correctly set per geometry. + """ + + def test_combined_oracle_marks_geometry_0_only(self): + """For marker=0, only geometry 0's obstacle region should be marked.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + # Access the combined oracle through the private method + oracle = operator.build_combined_oracle() + + # Position (1, 1) is inside geometry 0 ([1,2]x[1,2]) but also inside geometry 1 ([0,1]x[0,1]) + # With marker=0, only geometry 0's oracle fires + prep = _encode_basis_state(lattice, x=1, y=1, v=0, marker=0) + prep.compose(oracle, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_combined_oracle_marks_geometry_1_only(self): + """For marker=1, only geometry 1's obstacle region should be marked.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + oracle = operator.build_combined_oracle() + + # Position (0, 0) is inside geometry 1 ([0,1]x[0,1]) but NOT inside geometry 0 + # With marker=1, geometry 1's oracle fires + prep = _encode_basis_state(lattice, x=0, y=0, v=0, marker=1) + prep.compose(oracle, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[1] == pytest.approx(1.0, abs=1e-10) + + def test_combined_oracle_does_not_mark_wrong_geometry(self): + """Position inside geometry 1 should NOT be marked when marker=0.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + oracle = operator.build_combined_oracle() + + # Position (0, 0) is inside geometry 1 but NOT geometry 0 + # With marker=0, geometry 0's oracle fires but position is outside geo 0 + prep = _encode_basis_state(lattice, x=0, y=0, v=0, marker=0) + prep.compose(oracle, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + def test_combined_oracle_outside_all_geometries(self): + """Position outside all geometries should never be marked.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + oracle = operator.build_combined_oracle() + + # Position (3, 3) is outside both geometry 0 ([1,2]x[1,2]) and geometry 1 ([0,1]x[0,1]) + for marker_val in [0, 1]: + prep = _encode_basis_state(lattice, x=3, y=3, v=0, marker=marker_val) + prep.compose(oracle, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx( + 1.0, abs=1e-10 + ), f"Failed for marker={marker_val}" + + def test_combined_oracle_is_self_inverse(self): + """Applying the combined oracle twice should return to the original state.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + oracle = operator.build_combined_oracle() + + for marker_val in [0, 1]: + for x, y in [(1, 1), (0, 0), (3, 3)]: + prep = _encode_basis_state(lattice, x=x, y=y, v=0, marker=marker_val) + prep.compose(oracle, inplace=True) + prep.compose(oracle, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx( + 1.0, abs=1e-10 + ), f"Not self-inverse for marker={marker_val}, pos=({x},{y})" + + +# ============================================================================= +# Operator-level statevector tests +# ============================================================================= + + +class TestOperatorSingleGeometry: + """Statevector tests for the zone-agnostic reflection operator with single geometry.""" + + def test_operator_obstacle_ancilla_is_clean_after_full_circuit(self): + """After the full reflection operator, the obstacle ancilla should be |0>. + + The operator structure is O -> PermStream -> S^{-1} -> O -> S. + After the second oracle, the obstacle ancilla should be uncomputed, + assuming the particle's position is correctly restored. + """ + lattice = _make_single_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator( + lattice, shapes=lattice.shapes["bounceback"] + ) + + # Test with a position outside the obstacle + prep = _encode_basis_state(lattice, x=0, y=0, v=0) + prep.compose(operator.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx(1.0, abs=1e-10) + + +class TestOperatorMultiGeometry: + """Statevector tests for the zone-agnostic reflection operator with multiple geometries.""" + + def test_operator_obstacle_ancilla_is_clean_outside_all_geometries(self): + """For positions outside all geometries, obstacle ancilla should remain 0.""" + lattice = _make_multi_geometry_lattice() + operator = ABZoneAgnosticReflectionOperator(lattice) + + for marker_val in [0, 1]: + prep = _encode_basis_state(lattice, x=3, y=3, v=0, marker=marker_val) + prep.compose(operator.circuit, inplace=True) + sv = _simulate_statevector(prep) + + probs = _get_obstacle_ancilla_value(lattice, sv) + assert probs[0] == pytest.approx( + 1.0, abs=1e-10 + ), f"Failed for marker={marker_val}" + + +# ============================================================================= +# Backward compatibility +# ============================================================================= + + +class TestBackwardCompatibility: + """Tests verifying that single-geometry behavior is unchanged.""" + + def test_single_geometry_operator_statevector_unchanged(self): + """The operator output for a single geometry should match regardless of code path. + + This test constructs the operator via the explicit shapes parameter + and via None. Their outputs must match for a sample of input basis states. + """ + lattice = _make_single_geometry_lattice() + + op_explicit = ABZoneAgnosticReflectionOperator( + lattice, shapes=lattice.shapes["bounceback"] + ) + op_inferred = ABZoneAgnosticReflectionOperator(lattice) + + # Sample representative positions and velocities instead of exhaustive + for x, y in [(0, 0), (1, 1), (2, 2), (3, 0)]: + for v in [0, 3, 5]: + prep_a = _encode_basis_state(lattice, x=x, y=y, v=v) + prep_a.compose(op_explicit.circuit, inplace=True) + sv_a = _simulate_statevector(prep_a) + + prep_b = _encode_basis_state(lattice, x=x, y=y, v=v) + prep_b.compose(op_inferred.circuit, inplace=True) + sv_b = _simulate_statevector(prep_b) + + assert sv_a.equiv(sv_b), f"Mismatch at x={x}, y={y}, v={v}" + + def test_single_geometry_in_multi_geometry_lattice_produces_same_oracle_effect( + self, + ): + """A multi-geometry lattice with one geometry should produce the same oracle marking. + + When there is only one geometry, the operator should behave identically + to the single-geometry case (modulo the extra marker qubit). + """ + # Single geometry lattice + single_lattice = _make_single_geometry_lattice() + single_oracle = ABZoneAgnosticReflectionOracle( + single_lattice, single_lattice.shapes["bounceback"][0] + ) + + # Multi-geometry lattice with only one geometry + multi_lattice = ABLattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": "d2q9"}, + } + ) + multi_lattice.set_geometries( + [ + [ + { + "shape": "cuboid", + "x": [1, 2], + "y": [1, 2], + "boundary": "bounceback", + } + ], + ] + ) + + # With one geometry, has_multiple_geometries() returns False + assert not multi_lattice.has_multiple_geometries() + + # Oracle should work the same way + multi_oracle = ABZoneAgnosticReflectionOracle( + multi_lattice, multi_lattice.geometries[0]["bounceback"][0] + ) + + # Check that both oracles mark the same positions + for x, y in [(1, 1), (0, 0), (2, 2), (3, 3)]: + prep_s = _encode_basis_state(single_lattice, x=x, y=y, v=0) + prep_s.compose(single_oracle.circuit, inplace=True) + sv_s = _simulate_statevector(prep_s) + probs_s = _get_obstacle_ancilla_value(single_lattice, sv_s) + + prep_m = _encode_basis_state(multi_lattice, x=x, y=y, v=0) + prep_m.compose(multi_oracle.circuit, inplace=True) + sv_m = _simulate_statevector(prep_m) + probs_m = _get_obstacle_ancilla_value(multi_lattice, sv_m) + + assert probs_s[1] == pytest.approx( + probs_m[1], abs=1e-10 + ), f"Oracle mismatch at ({x},{y})" diff --git a/test/unit/ab/addition_conversion_test.py b/test/unit/ab/addition_conversion_test.py new file mode 100644 index 0000000..72fb0c0 --- /dev/null +++ b/test/unit/ab/addition_conversion_test.py @@ -0,0 +1,33 @@ +from itertools import product + +import pytest +from qiskit import QuantumCircuit, transpile +from qiskit_aer import AerSimulator + +from qlbm.components.common.primitives import AdditionConversion +from qlbm.tools.utils import bit_value + + +@pytest.mark.parametrize( + "nq,state_in,state_out", list(product([4], [1, 4, 7, 11, 14], [0, 2, 8, 9, 12])) +) +def test_addition_conversion(nq, state_in, state_out): + sim = AerSimulator() + + qc = QuantumCircuit(nq + 1) + for q in range(nq): + if bit_value(state_in, q): + qc.x(q) + + qc.compose( + AdditionConversion(nq, state_in, state_out).circuit, + inplace=True, + ) + qc.measure_all() + tqc = transpile(qc, sim, optimization_level=0) + + counts = sim.run(tqc, shots=128).result().get_counts() + + assert all(int(c, 2) == state_out for c in counts.keys()), ( + f"{state_in} handled incorrectly. Expected {state_out}, got {counts}." + ) diff --git a/test/unit/ab/conftest.py b/test/unit/ab/conftest.py new file mode 100644 index 0000000..1ce13f9 --- /dev/null +++ b/test/unit/ab/conftest.py @@ -0,0 +1,28 @@ +import pytest + +from qlbm.lattice.lattices.ab_lattice import ABLattice +from qlbm.lattice.lattices.oh_lattice import OHLattice + + +@pytest.fixture +def oh_lattice_d2q9_8x8() -> OHLattice: + return OHLattice( + { + "lattice": { + "dim": {"x": 8, "y": 8}, + "velocities": "d2q9", + }, + }, + ) + + +@pytest.fixture +def ab_lattice_d2q9_8x8() -> ABLattice: + return ABLattice( + { + "lattice": { + "dim": {"x": 8, "y": 8}, + "velocities": "d2q9", + }, + }, + ) diff --git a/test/unit/ab/uniform_state_prep_test.py b/test/unit/ab/uniform_state_prep_test.py new file mode 100644 index 0000000..839b088 --- /dev/null +++ b/test/unit/ab/uniform_state_prep_test.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest +from qiskit import QuantumCircuit, transpile +from qiskit_aer import AerSimulator + +from qlbm.components.common.primitives import UniformStatePrep + + +@pytest.mark.parametrize( + "num_states", + list(range(1, 10)), +) +def test_uniform_State_prep(num_states): + nq = 5 + sim = AerSimulator() + + qc = QuantumCircuit(nq) + qc.compose( + UniformStatePrep(nq, num_states).circuit, + inplace=True, + ) + tqc = transpile(qc, sim) + tqc.save_statevector() + result = sim.run(tqc).result() + state = result.get_statevector(tqc) + + expected = 1.0 / np.sqrt(num_states) + + assert np.allclose(np.abs(state)[:num_states], expected, atol=1e-8), ( + "Uniform state prep results in wrong magnitudes for the first k basis states" + ) + assert np.allclose(np.abs(state)[num_states:], 0.0, atol=1e-8), ( + "Uniform state prep results in wrong magnitudes for the trailing basis states" + ) diff --git a/test/unit/collision/eqc_generator_test.py b/test/unit/collision/eqc_generator_test.py index 66934e3..b0d51c5 100644 --- a/test/unit/collision/eqc_generator_test.py +++ b/test/unit/collision/eqc_generator_test.py @@ -1,4 +1,3 @@ -import numpy as np from qlbm.lattice.eqc.eqc import EquivalenceClass from qlbm.lattice.eqc.eqc_generator import ( diff --git a/test/unit/collision/eqc_velocity_discretization_test.py b/test/unit/collision/eqc_velocity_discretization_test.py index 4f78d9e..47327aa 100644 --- a/test/unit/collision/eqc_velocity_discretization_test.py +++ b/test/unit/collision/eqc_velocity_discretization_test.py @@ -3,9 +3,6 @@ import pytest from qlbm.lattice.eqc.eqc import EquivalenceClass -from qlbm.lattice.eqc.eqc_generator import ( - EquivalenceClassGenerator, -) from qlbm.lattice.spacetime.properties_base import LatticeDiscretization from qlbm.tools.exceptions import LatticeException diff --git a/test/unit/comparator_mode_test.py b/test/unit/comparator_mode_test.py new file mode 100644 index 0000000..0229da1 --- /dev/null +++ b/test/unit/comparator_mode_test.py @@ -0,0 +1,20 @@ +import pytest + +from qlbm.tools.utils import ComparatorMode +from qlbm.tools.exceptions import LatticeException + + +def test_comparator_mode_from_string(): + assert ComparatorMode.from_string("<") == ComparatorMode.LT + assert ComparatorMode.from_string("<=") == ComparatorMode.LE + assert ComparatorMode.from_string(">") == ComparatorMode.GT + assert ComparatorMode.from_string(">=") == ComparatorMode.GE + + +def test_comparator_mode_from_string_whitespace(): + assert ComparatorMode.from_string(" < ") == ComparatorMode.LT + + +def test_comparator_mode_from_string_invalid(): + with pytest.raises(LatticeException, match="Unsupported comparator mode"): + ComparatorMode.from_string("!=") diff --git a/test/unit/lattice/abe_lattice_properties_test.py b/test/unit/lattice/abe_lattice_properties_test.py index 278e1d1..1a80d52 100644 --- a/test/unit/lattice/abe_lattice_properties_test.py +++ b/test/unit/lattice/abe_lattice_properties_test.py @@ -56,3 +56,394 @@ def test_2d_lattice_ancilla_obstacle_register( "Cannot index ancilla obstacle register for index 2. Maximum index for this lattice is 0." == str(excinfo.value) ) + + +def test_2d_lattice_no_cuboid_has_no_comparator_register(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + + assert lattice.num_comparator_qubits == 0 + assert lattice.ancillae_comparator_index() == [] + assert len(lattice.ancilla_comparator_register) == 0 + with pytest.raises(LatticeException) as excinfo: + lattice.ancillae_comparator_index(0) + assert ( + "Cannot index ancilla comparator register because this lattice has no comparator qubits." + == str(excinfo.value) + ) + assert lattice.ancillae_obstacle_index() == [10] + + +def test_set_geometries_updates_comparator_register_allocation(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + } + ], + } + ) + + assert lattice.num_comparator_qubits == 2 + assert lattice.ancillae_comparator_index() == [10, 11] + + lattice.set_geometries( + [ + [ + { + "shape": "ymonomial", + "exponent": 3, + "comparator": ">", + "boundary": "bounceback", + } + ] + ] + ) + + assert lattice.num_comparator_qubits == 0 + assert lattice.ancillae_comparator_index() == [] + assert len(lattice.ancilla_comparator_register) == 0 + with pytest.raises(LatticeException) as excinfo: + lattice.ancillae_comparator_index(0) + assert ( + "Cannot index ancilla comparator register because this lattice has no comparator qubits." + == str(excinfo.value) + ) + assert lattice.ancillae_obstacle_index() == [10] + + +def test_2d_lattice_no_objects_has_no_comparator_register(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + } + ) + + assert lattice.num_comparator_qubits == 0 + assert lattice.ancillae_comparator_index() == [] + assert len(lattice.ancilla_comparator_register) == 0 + + with pytest.raises(LatticeException) as excinfo: + lattice.ancillae_comparator_index(0) + assert ( + "Cannot index ancilla comparator register because this lattice has no comparator qubits." + == str(excinfo.value) + ) + + +@pytest.mark.parametrize( + "geometry, expected_comparator_qubits, expected_obstacle_qubits, expected_copy_qubits, expected_monomial_qubits", + [ + ( + [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + } + ], + 2, + 1, + 0, + 0, + ), + ( + [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + 0, + 1, + 4, + 8, + ), + ( + [ + { + "shape": "cuboid", + "x": [1, 3], + "y": [7, 11], + "boundary": "specular", + }, + { + "shape": "ymonomial", + "exponent": 1, + "comparator": ">=", + "boundary": "bounceback", + }, + ], + 2, + 2, + 4, + 4, + ), + ( + [ + { + "shape": "ymonomial", + "exponent": 1, + "comparator": "<", + "boundary": "bounceback", + }, + { + "shape": "ymonomial", + "exponent": 4, + "comparator": ">", + "boundary": "bounceback", + }, + ], + 0, + 1, + 4, + 16, + ), + ], +) +def test_2d_ab_lattice_cuboid_ymonomial_combinations( + geometry, + expected_comparator_qubits, + expected_obstacle_qubits, + expected_copy_qubits, + expected_monomial_qubits, +): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": geometry, + } + ) + + assert lattice.num_comparator_qubits == expected_comparator_qubits + assert lattice.num_obstacle_qubits == expected_obstacle_qubits + assert lattice.num_copy_qubits == expected_copy_qubits + assert lattice.num_monomial_qubits == expected_monomial_qubits + assert lattice.num_ancilla_qubits == ( + expected_comparator_qubits + expected_obstacle_qubits + ) + + if expected_comparator_qubits == 2: + assert lattice.ancillae_comparator_index() == [10, 11] + assert lattice.ancillae_comparator_index(0) == [10, 11] + assert len(lattice.ancilla_comparator_register) == 1 + else: + assert lattice.ancillae_comparator_index() == [] + assert len(lattice.ancilla_comparator_register) == 0 + with pytest.raises(LatticeException) as excinfo: + lattice.ancillae_comparator_index(0) + assert ( + "Cannot index ancilla comparator register because this lattice has no comparator qubits." + == str(excinfo.value) + ) + + expected_obstacle_start = 10 + expected_comparator_qubits + assert lattice.ancillae_obstacle_index()[0] == expected_obstacle_start + + +@pytest.mark.parametrize( + "new_geometries, expected_comparator_qubits, expected_obstacle_qubits, expected_copy_qubits, expected_monomial_qubits, expected_marker_qubits", + [ + ( + [ + [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + } + ], + [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + } + ], + ], + 2, + 1, + 4, + 8, + 1, + ), + ( + [ + [ + { + "shape": "ymonomial", + "exponent": 1, + "comparator": "<", + "boundary": "specular", + } + ], + [ + { + "shape": "ymonomial", + "exponent": 3, + "comparator": ">", + "boundary": "bounceback", + } + ], + ], + 0, + 2, + 4, + 12, + 1, + ), + ( + [ + [ + { + "shape": "cuboid", + "x": [1, 4], + "y": [1, 4], + "boundary": "specular", + } + ], + [ + { + "shape": "cuboid", + "x": [8, 10], + "y": [8, 10], + "boundary": "bounceback", + }, + { + "shape": "ymonomial", + "exponent": 0, + "comparator": ">=", + "boundary": "bounceback", + }, + ], + [ + { + "shape": "ymonomial", + "exponent": 5, + "comparator": "<=", + "boundary": "bounceback", + } + ], + ], + 2, + 2, + 4, + 20, + 2, + ), + ], +) +def test_set_geometries_updates_registers_for_ymonomial_cuboid_combinations( + new_geometries, + expected_comparator_qubits, + expected_obstacle_qubits, + expected_copy_qubits, + expected_monomial_qubits, + expected_marker_qubits, +): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + } + ], + } + ) + + lattice.set_geometries(new_geometries) + + assert lattice.num_comparator_qubits == expected_comparator_qubits + assert lattice.num_obstacle_qubits == expected_obstacle_qubits + assert lattice.num_copy_qubits == expected_copy_qubits + assert lattice.num_monomial_qubits == expected_monomial_qubits + assert lattice.num_marker_qubits == expected_marker_qubits + assert lattice.has_multiple_geometries() + + assert lattice.num_ancilla_qubits == ( + expected_comparator_qubits + expected_obstacle_qubits + ) + assert len(lattice.ancilla_comparator_register) == ( + 1 if expected_comparator_qubits > 0 else 0 + ) + + if expected_copy_qubits > 0: + assert len(lattice.copy_register) == 1 + else: + assert len(lattice.copy_register) == 0 + + if expected_monomial_qubits > 0: + assert len(lattice.monomial_register) == 1 + else: + assert len(lattice.monomial_register) == 0 + + assert len(lattice.marker_index()) == expected_marker_qubits + + +def test_2d_ymonomial_register_sizes_and_indices(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 3, + "comparator": "<=", + "boundary": "bounceback", + } + ], + } + ) + + assert lattice.num_copy_qubits == 4 + assert lattice.num_monomial_qubits == 12 + assert len(lattice.ancillae_copy_index()) == 4 + assert len(lattice.ancillae_monomial_index()) == 12 + assert set(lattice.ancillae_copy_index()).isdisjoint(set(lattice.ancillae_monomial_index())) + + +def test_3d_cuboid_comparator_qubits_equal_num_dims(): + lattice = ABLattice( + { + "lattice": {"dim": {"x": 8, "y": 8, "z": 8}, "velocities": "D3Q6"}, + "geometry": [ + { + "shape": "cuboid", + "x": [1, 3], + "y": [2, 4], + "z": [0, 2], + "boundary": "bounceback", + } + ], + } + ) + + assert lattice.num_dims == 3 + assert lattice.num_comparator_qubits == 3 + assert len(lattice.ancillae_comparator_index()) == 3 + assert lattice.ancillae_comparator_index(0) == lattice.ancillae_comparator_index() diff --git a/test/unit/lattice/conftest.py b/test/unit/lattice/conftest.py index 1c58d5b..e21e16e 100644 --- a/test/unit/lattice/conftest.py +++ b/test/unit/lattice/conftest.py @@ -2,13 +2,13 @@ from qlbm.lattice.geometry.shapes.block import Block from qlbm.lattice.lattices.ab_lattice import ABLattice +from qlbm.lattice.lattices.oh_lattice import OHLattice # 1D Lattices @pytest.fixture def dummy_1d_lattice() -> ABLattice: return ABLattice( - 0, { "lattice": { "dim": {"x": 256}, @@ -22,10 +22,7 @@ def dummy_1d_lattice() -> ABLattice: def lattice_1d_16_1_obstacle() -> ABLattice: return ABLattice( { - "lattice": { - "dim": {"x": 16}, - "velocities": "D1Q2" - }, + "lattice": {"dim": {"x": 16}, "velocities": "D1Q2"}, "geometry": [ {"shape": "cuboid", "x": [4, 6], "boundary": "bounceback"}, ], @@ -33,17 +30,12 @@ def lattice_1d_16_1_obstacle() -> ABLattice: ) - # 2D Lattices @pytest.fixture def dummy_2d_lattice() -> ABLattice: return ABLattice( - 0, { - "lattice": { - "dim": {"x": 32, "y": 32}, - "velocities": "D2Q4" - }, + "lattice": {"dim": {"x": 32, "y": 32}, "velocities": "D2Q4"}, }, ) @@ -52,10 +44,24 @@ def dummy_2d_lattice() -> ABLattice: def lattice_2d_16x16_1_obstacle() -> ABLattice: return ABLattice( { - "lattice": { - "dim": {"x": 16, "y": 16}, - "velocities": "D2Q4" - }, + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + }, + ], + }, + ) + + +@pytest.fixture +def lattice_2d_16x16_1_obstacle_oh() -> OHLattice: + return OHLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q9"}, "geometry": [ { "shape": "cuboid", diff --git a/test/unit/lattice/lattice_result_test.py b/test/unit/lattice/lattice_result_test.py new file mode 100644 index 0000000..6ecc322 --- /dev/null +++ b/test/unit/lattice/lattice_result_test.py @@ -0,0 +1,244 @@ +"""Tests for the Lattice factory methods create_result and create_reinitializer.""" + +import os +import shutil +import tempfile + +import pytest + +from qlbm.infra.compiler import CircuitCompiler +from qlbm.infra.reinitialize.base import Reinitializer +from qlbm.infra.reinitialize.identity_reinitializer import IdentityReinitializer +from qlbm.infra.reinitialize.spacetime_reinitializer import SpaceTimeReinitializer +from qlbm.infra.result.amplitude_result import AmplitudeResult +from qlbm.infra.result.base import QBMResult +from qlbm.infra.result.lqlga_result import LQLGAResult +from qlbm.infra.result.spacetime_result import SpaceTimeResult +from qlbm.lattice.lattices.ab_lattice import ABLattice +from qlbm.lattice.lattices.lqlga_lattice import LQLGALattice +from qlbm.lattice.lattices.ms_lattice import MSLattice +from qlbm.lattice.lattices.oh_lattice import OHLattice +from qlbm.lattice.lattices.spacetime_lattice import SpaceTimeLattice + + +@pytest.fixture +def ms_lattice() -> MSLattice: + return MSLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": {"x": 4, "y": 4}}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + }, + ], + }, + ) + + +@pytest.fixture +def ab_lattice() -> ABLattice: + return ABLattice( + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [], + }, + ) + + +@pytest.fixture +def oh_lattice() -> OHLattice: + return OHLattice( + { + "lattice": {"dim": {"x": 8, "y": 8}, "velocities": "D2Q9"}, + "geometry": [], + }, + ) + + +@pytest.fixture +def spacetime_lattice() -> SpaceTimeLattice: + return SpaceTimeLattice( + 1, + { + "lattice": {"dim": {"x": 16, "y": 16}, "velocities": "D2Q4"}, + "geometry": [ + { + "shape": "cuboid", + "x": [2, 6], + "y": [5, 10], + "boundary": "bounceback", + }, + ], + }, + ) + + +@pytest.fixture +def lqlga_lattice() -> LQLGALattice: + return LQLGALattice( + { + "lattice": {"dim": {"x": 4, "y": 4}, "velocities": "D2Q4"}, + }, + ) + + +@pytest.fixture +def compiler() -> CircuitCompiler: + return CircuitCompiler("QISKIT", "QISKIT") + + +@pytest.fixture +def temp_dir(): + d = tempfile.mkdtemp() + yield d + shutil.rmtree(d) + + + +class TestCreateResult: + """Tests for the create_result factory method across all lattice types.""" + + def test_ms_lattice_creates_amplitude_result(self, ms_lattice, temp_dir): + result = ms_lattice.create_result(temp_dir, "step") + assert isinstance(result, AmplitudeResult) + assert isinstance(result, QBMResult) + + def test_ab_lattice_creates_amplitude_result(self, ab_lattice, temp_dir): + result = ab_lattice.create_result(temp_dir, "step") + assert isinstance(result, AmplitudeResult) + assert isinstance(result, QBMResult) + + def test_oh_lattice_creates_amplitude_result(self, oh_lattice, temp_dir): + result = oh_lattice.create_result(temp_dir, "step") + assert isinstance(result, AmplitudeResult) + assert isinstance(result, QBMResult) + + def test_spacetime_lattice_creates_spacetime_result( + self, spacetime_lattice, temp_dir + ): + result = spacetime_lattice.create_result(temp_dir, "step") + assert isinstance(result, SpaceTimeResult) + assert isinstance(result, QBMResult) + + def test_lqlga_lattice_creates_lqlga_result(self, lqlga_lattice, temp_dir): + result = lqlga_lattice.create_result(temp_dir, "step") + assert isinstance(result, LQLGAResult) + assert isinstance(result, QBMResult) + + def test_result_has_correct_directory(self, ms_lattice, temp_dir): + result = ms_lattice.create_result(temp_dir, "output") + assert result.directory == temp_dir + + def test_result_has_correct_file_name(self, ms_lattice, temp_dir): + result = ms_lattice.create_result(temp_dir, "my_output") + assert result.output_file_name == "my_output" + + def test_result_stores_lattice(self, ab_lattice, temp_dir): + result = ab_lattice.create_result(temp_dir, "step") + assert result.lattice is ab_lattice + + def test_result_creates_output_directory(self, ms_lattice, temp_dir): + output_dir = os.path.join(temp_dir, "nested", "output") + result = ms_lattice.create_result(output_dir, "step") + assert os.path.isdir(output_dir) + + def test_result_writes_lattice_json(self, spacetime_lattice, temp_dir): + spacetime_lattice.create_result(temp_dir, "step") + lattice_json_path = os.path.join(temp_dir, "lattice.json") + assert os.path.isfile(lattice_json_path) + + +class TestCreateReinitializer: + """Tests for the create_reinitializer factory method across all lattice types.""" + + def test_ms_lattice_creates_identity_reinitializer(self, ms_lattice, compiler): + reinit = ms_lattice.create_reinitializer(compiler) + assert isinstance(reinit, IdentityReinitializer) + assert isinstance(reinit, Reinitializer) + + def test_ab_lattice_creates_identity_reinitializer(self, ab_lattice, compiler): + reinit = ab_lattice.create_reinitializer(compiler) + assert isinstance(reinit, IdentityReinitializer) + assert isinstance(reinit, Reinitializer) + + def test_oh_lattice_creates_identity_reinitializer(self, oh_lattice, compiler): + reinit = oh_lattice.create_reinitializer(compiler) + assert isinstance(reinit, IdentityReinitializer) + assert isinstance(reinit, Reinitializer) + + def test_spacetime_lattice_creates_spacetime_reinitializer( + self, spacetime_lattice, compiler + ): + reinit = spacetime_lattice.create_reinitializer(compiler) + assert isinstance(reinit, SpaceTimeReinitializer) + assert isinstance(reinit, Reinitializer) + + def test_lqlga_lattice_creates_identity_reinitializer( + self, lqlga_lattice, compiler + ): + reinit = lqlga_lattice.create_reinitializer(compiler) + assert isinstance(reinit, IdentityReinitializer) + assert isinstance(reinit, Reinitializer) + + def test_reinitializer_stores_lattice(self, ms_lattice, compiler): + reinit = ms_lattice.create_reinitializer(compiler) + assert reinit.lattice is ms_lattice + + def test_reinitializer_stores_compiler(self, ab_lattice, compiler): + reinit = ab_lattice.create_reinitializer(compiler) + assert reinit.compiler is compiler + + def test_spacetime_reinitializer_stores_lattice(self, spacetime_lattice, compiler): + reinit = spacetime_lattice.create_reinitializer(compiler) + assert reinit.lattice is spacetime_lattice + + def test_identity_reinitializer_requires_statevector(self, ms_lattice, compiler): + reinit = ms_lattice.create_reinitializer(compiler) + assert reinit.requires_statevector() is True + + def test_spacetime_reinitializer_requires_statevector( + self, spacetime_lattice, compiler + ): + reinit = spacetime_lattice.create_reinitializer(compiler) + assert reinit.requires_statevector() is False + + +class TestFactoryConsistency: + """Tests ensuring the factory methods produce the same types as the old isinstance dispatch.""" + + @pytest.mark.parametrize( + "lattice_fixture,expected_result_type", + [ + ("ms_lattice", AmplitudeResult), + ("ab_lattice", AmplitudeResult), + ("oh_lattice", AmplitudeResult), + ("spacetime_lattice", SpaceTimeResult), + ("lqlga_lattice", LQLGAResult), + ], + ) + def test_result_type_matches_lattice( + self, lattice_fixture, expected_result_type, temp_dir, request + ): + lattice = request.getfixturevalue(lattice_fixture) + result = lattice.create_result(temp_dir, "step") + assert type(result) is expected_result_type + + @pytest.mark.parametrize( + "lattice_fixture,expected_reinit_type", + [ + ("ms_lattice", IdentityReinitializer), + ("ab_lattice", IdentityReinitializer), + ("oh_lattice", IdentityReinitializer), + ("spacetime_lattice", SpaceTimeReinitializer), + ("lqlga_lattice", IdentityReinitializer), + ], + ) + def test_reinitializer_type_matches_lattice( + self, lattice_fixture, expected_reinit_type, compiler, request + ): + lattice = request.getfixturevalue(lattice_fixture) + reinit = lattice.create_reinitializer(compiler) + assert type(reinit) is expected_reinit_type diff --git a/test/unit/collisionles_lattice_test.py b/test/unit/lattice/ms_lattice_test.py similarity index 99% rename from test/unit/collisionles_lattice_test.py rename to test/unit/lattice/ms_lattice_test.py index fd4d850..c3205cc 100644 --- a/test/unit/collisionles_lattice_test.py +++ b/test/unit/lattice/ms_lattice_test.py @@ -399,7 +399,7 @@ def test_lattice_exception_unsupported_shape(): ) assert ( - 'Obstacle 1 has unsupported shape "cuboidz". Supported shapes are cuboid and sphere.' + 'Obstacle 1 has unsupported shape "cuboidz". Supported shapes are cuboid, sphere, and ymonomial.' == str(excinfo.value) ) diff --git a/test/unit/lattice/oh_lattice_exception_test.py b/test/unit/lattice/oh_lattice_exception_test.py new file mode 100644 index 0000000..96a4037 --- /dev/null +++ b/test/unit/lattice/oh_lattice_exception_test.py @@ -0,0 +1,105 @@ +import pytest + +from qlbm.lattice import OHLattice +from qlbm.tools.exceptions import LatticeException + + +def test_lattice_exception_empty_dict(): + with pytest.raises(LatticeException) as excinfo: + OHLattice({}) + + assert 'Input configuration missing "lattice" properties.' == str(excinfo.value) + + +def test_lattice_exception_no_dims(): + with pytest.raises(LatticeException) as excinfo: + OHLattice({"lattice": {}}) + + assert 'Lattice configuration missing "dim" properties.' == str(excinfo.value) + + +def test_lattice_exception_no_velocities(): + with pytest.raises(LatticeException) as excinfo: + OHLattice({"lattice": {"dim": {}}}) + + assert 'Lattice configuration missing "velocities" properties.' == str( + excinfo.value + ) + + +def test_lattice_exception_mismatched_velocities_and_dims(): + with pytest.raises(LatticeException) as excinfo: + OHLattice({"lattice": {"dim": {"x": 64}, "velocities": "D2Q4"}}) + + assert ( + "Velocity specification dimensions (2) do not match lattice dimensions (1)." + == str(excinfo.value) + ) + + +def test_lattice_exception_unsupported_discretization(): + with pytest.raises(LatticeException) as excinfo: + OHLattice({"lattice": {"dim": {"x": 64}, "velocities": {"x": 4}}}) + + assert ( + "Discretization LatticeDiscretization.CFLDISCRETIZATION is not supported." + == str(excinfo.value) + ) + + +def test_lattice_exception_mismatched_bad_dimensions(): + with pytest.raises(LatticeException) as excinfo: + OHLattice( + { + "lattice": { + "dim": {"x": 64, "y": 127}, + "velocities": "D2Q9", + } + } + ) + + assert ( + "Lattice has a number of grid points that is not divisible by 2 in dimension y." + == str(excinfo.value) + ) + + +def test_lattice_exception_mismatched_bad_object_dimensions(): + with pytest.raises(LatticeException) as excinfo: + OHLattice( + { + "lattice": { + "dim": {"x": 64, "y": 64}, + "velocities": "D2Q9", + }, + "geometry": [ + { + "shape": "cuboid", + "x": [5, 6], + "y": [1, 2], + "z": [1, 2], + "boundary": "specular", + }, + ], + } + ) + + assert "Obstacle 1 has 3 dimensions whereas the lattice has 2." == str( + excinfo.value + ) + + +def test_lattice_exception_marker_index(): + lattice = OHLattice({"lattice": {"dim": {"x": 64, "y": 64}, "velocities": "D2Q9"}}) + with pytest.raises(LatticeException) as excinfo: + lattice.marker_index() + + assert "Multiple geometries not yet supported for OHLattice." == str(excinfo.value) + + +def test_lattice_exception_accumulation_index(): + lattice = OHLattice({"lattice": {"dim": {"x": 64, "y": 64}, "velocities": "D2Q9"}}) + with pytest.raises(LatticeException) as excinfo: + lattice.accumulation_index() + + assert "Accumulation not yet supported for OHLattice." == str(excinfo.value) diff --git a/test/unit/lattice/oh_lattice_properties_test.py b/test/unit/lattice/oh_lattice_properties_test.py new file mode 100644 index 0000000..c6dcc5b --- /dev/null +++ b/test/unit/lattice/oh_lattice_properties_test.py @@ -0,0 +1,59 @@ +import pytest + +from qlbm.lattice import OHLattice +from qlbm.tools.exceptions import LatticeException + + +def test_2d_lattice_basic_properties(lattice_2d_16x16_1_obstacle_oh: OHLattice): + assert lattice_2d_16x16_1_obstacle_oh.num_dims == 2 + assert lattice_2d_16x16_1_obstacle_oh.num_gridpoints == [15, 15] + assert lattice_2d_16x16_1_obstacle_oh.num_ancilla_qubits == 3 + assert lattice_2d_16x16_1_obstacle_oh.num_grid_qubits == 8 + assert lattice_2d_16x16_1_obstacle_oh.num_velocity_qubits == 9 + assert lattice_2d_16x16_1_obstacle_oh.num_total_qubits == 20 + + +def test_2d_lattice_grid_register(lattice_2d_16x16_1_obstacle_oh: OHLattice): + assert lattice_2d_16x16_1_obstacle_oh.grid_index(0) == list(range(4)) + assert lattice_2d_16x16_1_obstacle_oh.grid_index(1) == list(range(4, 8)) + assert lattice_2d_16x16_1_obstacle_oh.grid_index() == list(range(8)) + + with pytest.raises(LatticeException) as excinfo: + lattice_2d_16x16_1_obstacle_oh.grid_index(2) + assert ( + "Cannot index grid register for dimension 2 in 2-dimensional lattice." + == str(excinfo.value) + ) + + +def test_2d_lattice_velocity_register( + lattice_2d_16x16_1_obstacle_oh: OHLattice, +): + assert lattice_2d_16x16_1_obstacle_oh.velocity_index() == list(range(8, 17)) + + +def test_2d_lattice_ancilla_comparator_register( + lattice_2d_16x16_1_obstacle_oh: OHLattice, +): + assert lattice_2d_16x16_1_obstacle_oh.ancillae_comparator_index(0) == [17, 18] + assert lattice_2d_16x16_1_obstacle_oh.ancillae_comparator_index() == [17, 18] + + with pytest.raises(LatticeException) as excinfo: + lattice_2d_16x16_1_obstacle_oh.ancillae_comparator_index(1) + assert ( + "Cannot index ancilla comparator register for index 1 in 2-dimensional lattice. Maximum is 0." + == str(excinfo.value) + ) + + +def test_2d_lattice_ancilla_obstacle_register( + lattice_2d_16x16_1_obstacle_oh: OHLattice, +): + assert lattice_2d_16x16_1_obstacle_oh.ancillae_obstacle_index() == [19] + + with pytest.raises(LatticeException) as excinfo: + lattice_2d_16x16_1_obstacle_oh.ancillae_obstacle_index(2) + assert ( + "Cannot index ancilla obstacle register for index 2. Maximum index for this lattice is 0." + == str(excinfo.value) + ) diff --git a/test/unit/lattice/ymonomial_lattice_parser_test.py b/test/unit/lattice/ymonomial_lattice_parser_test.py new file mode 100644 index 0000000..ed6b33f --- /dev/null +++ b/test/unit/lattice/ymonomial_lattice_parser_test.py @@ -0,0 +1,273 @@ +"""Unit tests for ymonomial geometry parsing in lattice base parser.""" + +import json + +import pytest + +from qlbm.lattice import MSLattice +from qlbm.lattice.geometry.shapes.block import Block +from qlbm.lattice.geometry.shapes.ymonomial import YMonomial +from qlbm.tools.exceptions import LatticeException +from qlbm.tools.utils import ComparatorMode + + +def _lattice_with_geometry(geometry): + """Build a minimal valid 2D MS lattice with a configurable geometry list.""" + return MSLattice( + { + "lattice": { + "dim": {"x": 16, "y": 16}, + "velocities": {"x": 4, "y": 4}, + }, + "geometry": geometry, + } + ) + + +def test_parse_ymonomial_specular_geometry(): + """Parses a specular ymonomial obstacle and stores the expected mode.""" + lattice = _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "specular", + } + ] + ) + + assert len(lattice.shapes["specular"]) == 1 + shape = lattice.shapes["specular"][0] + assert isinstance(shape, YMonomial) + assert shape.exponent == 2 + assert shape.comparator_mode == ComparatorMode.LE + + +def test_parse_ymonomial_bounceback_geometry(): + """Parses a bounceback ymonomial obstacle and stores the expected mode.""" + lattice = _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 3, + "comparator": ">", + "boundary": "bounceback", + } + ] + ) + + assert len(lattice.shapes["bounceback"]) == 1 + shape = lattice.shapes["bounceback"][0] + assert isinstance(shape, YMonomial) + assert shape.comparator_mode == ComparatorMode.GT + + +@pytest.mark.parametrize( + "comparator_symbol, expected_mode", + [ + ("<", ComparatorMode.LT), + ("<=", ComparatorMode.LE), + (">", ComparatorMode.GT), + (">=", ComparatorMode.GE), + ], +) +def test_parse_ymonomial_comparator_modes(comparator_symbol, expected_mode): + """Parses each supported comparator symbol into the expected mode.""" + lattice = _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 1, + "comparator": comparator_symbol, + "boundary": "specular", + } + ] + ) + + ymonomial_shape = lattice.shapes["specular"][0] + assert isinstance(ymonomial_shape, YMonomial) + assert ymonomial_shape.comparator_mode == expected_mode + + +def test_parse_ymonomial_and_cuboid_geometry_together(): + """Keeps cuboid parsing intact while adding ymonomial parsing.""" + lattice = _lattice_with_geometry( + [ + {"shape": "cuboid", "x": [4, 6], "y": [3, 5], "boundary": "specular"}, + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "bounceback", + }, + ] + ) + + assert len(lattice.shapes["specular"]) == 1 + assert len(lattice.shapes["bounceback"]) == 1 + assert isinstance(lattice.shapes["specular"][0], Block) + assert isinstance(lattice.shapes["bounceback"][0], YMonomial) + + +def test_ymonomial_to_json_roundtrip_shape_and_parameters(): + """Serializes parsed ymonomial geometry back with expected parameters.""" + lattice = _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 4, + "comparator": ">=", + "boundary": "specular", + } + ] + ) + + geometry = json.loads(lattice.to_json())["geometry"] + + assert len(geometry) == 1 + assert geometry[0]["shape"] == "ymonomial" + assert geometry[0]["exponent"] == 4 + assert geometry[0]["comparator"] == ">=" + assert geometry[0]["boundary"] == "specular" + + +def test_lattice_exception_ymonomial_missing_exponent(): + """Raises an informative exception when ymonomial exponent is missing.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "comparator": "<=", + "boundary": "specular", + } + ] + ) + + assert ( + "Obstacle 1: ymonomial obstacle does not specify an exponent." + == str(excinfo.value) + ) + + +def test_lattice_exception_ymonomial_non_integer_exponent(): + """Raises an informative exception when ymonomial exponent is non-integer.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": "abc", + "comparator": "<=", + "boundary": "specular", + } + ] + ) + + assert ( + "Obstacle 1: ymonomial exponent abc is not an integer." + == str(excinfo.value) + ) + + +def test_lattice_exception_ymonomial_negative_exponent(): + """Raises an informative exception when ymonomial exponent is negative.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": -1, + "comparator": "<=", + "boundary": "specular", + } + ] + ) + + assert ( + "Obstacle 1: ymonomial exponent -1 must be non-negative." + == str(excinfo.value) + ) + + +def test_lattice_exception_ymonomial_missing_comparator(): + """Raises an informative exception when ymonomial comparator is missing.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 2, + "boundary": "specular", + } + ] + ) + + assert ( + "Obstacle 1: ymonomial obstacle does not specify a comparator." + == str(excinfo.value) + ) + + +def test_lattice_exception_ymonomial_non_string_comparator(): + """Raises an informative exception when ymonomial comparator is not a string.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": 123, + "boundary": "specular", + } + ] + ) + + assert "Obstacle 1: ymonomial comparator must be a string." == str(excinfo.value) + + +def test_lattice_exception_ymonomial_invalid_comparator_symbol(): + """Propagates comparator symbol validation for unsupported operators.""" + with pytest.raises(LatticeException) as excinfo: + _lattice_with_geometry( + [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "!=", + "boundary": "specular", + } + ] + ) + + assert ( + "Unsupported comparator mode '!='. Expected one of: <, <=, >, >=." + == str(excinfo.value) + ) + + +def test_lattice_exception_ymonomial_in_1d_lattice(): + """Raises an informative exception when ymonomial is used outside 2D.""" + with pytest.raises(LatticeException) as excinfo: + MSLattice( + { + "lattice": { + "dim": {"x": 16}, + "velocities": {"x": 4}, + }, + "geometry": [ + { + "shape": "ymonomial", + "exponent": 2, + "comparator": "<=", + "boundary": "specular", + } + ], + } + ) + + assert ( + "Obstacle 1: ymonomial is only supported for 2-dimensional lattices." + == str(excinfo.value) + ) \ No newline at end of file diff --git a/test/unit/lqlga/circuits/hamming_adder_test.py b/test/unit/lqlga/circuits/hamming_adder_test.py index 7eba82a..41fc39a 100644 --- a/test/unit/lqlga/circuits/hamming_adder_test.py +++ b/test/unit/lqlga/circuits/hamming_adder_test.py @@ -1,7 +1,6 @@ -import pytest from qiskit import QuantumCircuit, transpile -from qiskit_aer import AerSimulator from qiskit.result import Counts +from qiskit_aer import AerSimulator from qlbm.components.common import HammingWeightAdder @@ -55,7 +54,6 @@ def test_hamming_adder_2plus0(): counts = get_count_from_circuit(circuit) assert len(counts) == 1 - print(counts) assert all(int(s[:4], 2) == 2 for s in counts) @@ -72,7 +70,6 @@ def test_hamming_adder_2plus4(): counts = get_count_from_circuit(circuit) assert len(counts) == 1 - print(counts) assert all(int(s[:4], 2) == 6 for s in counts) @@ -100,6 +97,5 @@ def test_hamming_adder_superposition_x(): adder.measure_all() circuit.compose(adder, inplace=True) counts = get_count_from_circuit(circuit) - print(counts) assert len(counts) == 16 assert all(int(s[:4], 2) == (hamming_weight(s[4:]) + 4) for s in counts) diff --git a/test/unit/lqlga/circuits/streaming_test.py b/test/unit/lqlga/circuits/streaming_test.py index 444d474..5cce590 100644 --- a/test/unit/lqlga/circuits/streaming_test.py +++ b/test/unit/lqlga/circuits/streaming_test.py @@ -1,6 +1,7 @@ from typing import List, Tuple import pytest + from qlbm.components.lqlga.streaming import LQLGAStreamingOperator diff --git a/test/unit/two_register_comparator_test.py b/test/unit/two_register_comparator_test.py new file mode 100644 index 0000000..1adc605 --- /dev/null +++ b/test/unit/two_register_comparator_test.py @@ -0,0 +1,115 @@ +import pytest +from qiskit import QuantumCircuit +from qiskit.quantum_info import Statevector + +from qlbm.components.common.comparators import TwoRegisterComparator +from qlbm.tools.utils import ComparatorMode + + +def _state_index(x_value: int, y_value: int, out_value: int, num_qubits: int) -> int: + return x_value + (y_value << num_qubits) + (out_value << (2 * num_qubits)) + + +def _extract_register_value(state_index: int, start: int, num_qubits: int) -> int: + # Reconstruct an integer from a contiguous little-endian qubit slice. + return sum(((state_index >> (start + bit)) & 1) << bit for bit in range(num_qubits)) + + +@pytest.mark.parametrize( + "mode", + [ComparatorMode.LT, ComparatorMode.LE, ComparatorMode.GT, ComparatorMode.GE], +) +@pytest.mark.parametrize("num_qubits", [1, 2, 3]) +def test_two_register_comparator_all_modes(mode: ComparatorMode, num_qubits: int): + # Build one comparator circuit per (mode, width) and reuse it for all inputs. + comparator = TwoRegisterComparator(num_qubits=num_qubits, mode=mode) + operator = mode.to_operator() + + # Exhaustively verify all (x, y) inputs for this register width. + for x_value in range(2**num_qubits): + for y_value in range(2**num_qubits): + qc = QuantumCircuit(2 * num_qubits + 1) + + # Prepare |x>|y>|0> in computational basis (LSB at the lower qubit index). + for bit in range(num_qubits): + if (x_value >> bit) & 1: + qc.x(bit) + if (y_value >> bit) & 1: + qc.x(num_qubits + bit) + + # Apply the reversible two-register comparator. + qc.compose(comparator.circuit, inplace=True) + + # The circuit is deterministic on basis input; a single basis state should have unit amplitude. + state = Statevector.from_instruction(qc) + amplitudes = state.data + max_index = max(range(len(amplitudes)), key=lambda idx: abs(amplitudes[idx])) + max_amplitude = amplitudes[max_index] + + # Ensure no superposition/leakage due to incorrect uncomputation. + assert abs(abs(max_amplitude) - 1.0) < 1e-9 + + x_after = _extract_register_value(max_index, 0, num_qubits) + y_after = _extract_register_value(max_index, num_qubits, num_qubits) + out_after = (max_index >> (2 * num_qubits)) & 1 + + # Comparator must preserve both input registers exactly. + assert x_after == x_value + assert y_after == y_value + + # Output ancilla must encode the selected inequality mode. + assert out_after == int(operator(x_value, y_value)) + + +@pytest.mark.parametrize( + "mode", + [ComparatorMode.LT, ComparatorMode.LE, ComparatorMode.GT, ComparatorMode.GE], +) +def test_two_register_comparator_superposition_inputs(mode: ComparatorMode): + num_qubits = 2 + num_total_qubits = 2 * num_qubits + 1 + comparator = TwoRegisterComparator(num_qubits=num_qubits, mode=mode) + operator = mode.to_operator() + + def assert_expected_superposition(circuit: QuantumCircuit): + input_state = Statevector.from_instruction(circuit) + + # Build expected output by routing each |x,y,0> amplitude to |x,y,f(x,y)>. + expected = [0j] * (2**num_total_qubits) + for x_value in range(2**num_qubits): + for y_value in range(2**num_qubits): + source_idx = _state_index(x_value, y_value, 0, num_qubits) + target_idx = _state_index( + x_value, + y_value, + int(operator(x_value, y_value)), + num_qubits, + ) + expected[target_idx] = input_state.data[source_idx] + + actual_state = input_state.evolve(comparator.circuit) + expected_state = Statevector(expected) + + assert actual_state.equiv(expected_state) + + # Superposition over x only (y fixed to 2). + x_superposed = QuantumCircuit(num_total_qubits) + x_superposed.h(0) + x_superposed.h(1) + x_superposed.x(num_qubits + 1) + assert_expected_superposition(x_superposed) + + # Superposition over y only (x fixed to 1). + y_superposed = QuantumCircuit(num_total_qubits) + y_superposed.x(0) + y_superposed.h(num_qubits) + y_superposed.h(num_qubits + 1) + assert_expected_superposition(y_superposed) + + # Superposition over both registers. + both_superposed = QuantumCircuit(num_total_qubits) + both_superposed.h(0) + both_superposed.h(1) + both_superposed.h(num_qubits) + both_superposed.h(num_qubits + 1) + assert_expected_superposition(both_superposed) \ No newline at end of file diff --git a/test/unit/ymonomial_test.py b/test/unit/ymonomial_test.py new file mode 100644 index 0000000..32e261a --- /dev/null +++ b/test/unit/ymonomial_test.py @@ -0,0 +1,18 @@ +from qlbm.lattice.geometry.shapes.ymonomial import YMonomial +from qlbm.tools.utils import ComparatorMode + + +def test_ymonomial_stl_mesh_stays_within_lattice_bounds(): + ymonomial = YMonomial([2, 4], "bounceback", 2, ComparatorMode.LE) + + ymonomial_mesh = ymonomial.stl_mesh() + vertices = ymonomial_mesh.vectors.reshape(-1, 3) + max_coords = vertices.max(axis=0) + min_coords = vertices.min(axis=0) + + assert min_coords[0] >= 0 + assert min_coords[1] >= 0 + assert max_coords[0] <= 2**ymonomial.num_grid_qubits[0] - 1 + assert max_coords[1] <= 2**ymonomial.num_grid_qubits[1] - 1 + assert max_coords[0] == 2**ymonomial.num_grid_qubits[0] - 1 + assert max_coords[1] > 9