# Copyright 2024 Moth Quantum
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==========================================================================
from typing import Optional, Union, Callable, Any, Tuple
import numpy as np
import qiskit
from quantumaudio import utils
from .base_scheme import Scheme
[docs]
class MSQPAM(Scheme):
"""Multi-channel Single-Qubit Probability Amplitude Modulation (MSQPAM).
MSQPAM class implements an encoding and decoding scheme where the
amplitude of a Digital signal is encoded through rotation gates
acting on a single-qubit. This qubit is controlled by qubits of
time register that encodes the corresponding time index information.
Additionally, another register is used to represent the channel information.
"""
def __init__(self, num_channels: Optional[int] = None) -> None:
"""Initialize the MSQPAM instance. The attributes of `__init__` method are
specific to this Scheme which remains fixed and independent of the
Data. These attributes give an overview of the Scheme.
Attributes:
name: Holds the full name of the representation.
qubit_depth: Number of qubits to represent the amplitude of
an audio signal.
(Note: In MSQPAM, the qubit depth
is 1 denoting the "Single-Qubit".)
num_channels: Number of channels in a 2-dimensional data.
E.g. (2,8) denotes stereo audio of length 8.
(Note: MSQPAM works with at least 2 channels.)
n_fold: Term for a fixed number of indexed registers used.
labels: Name of the Quantum registers
positions: Index position of Quantum registers
(In a Qiskit circuit the registers are arranged
from Top to Bottom)
convert: Function that applies a mathematical conversion
of input at Encoding.
restore: Function that restores the conversion at Decoding.
keys: Reference to essential metadata keys for decoding.
Args:
num_channels: If None, the num_channels is adapted to the data.
However, a user can specify `num_channels` to
override it. In any case, Minimum 2 channels
is ensured by padding if required.
"""
self.name = (
"Multi-channel Single-Qubit Probability Amplitude Modulation"
)
self.qubit_depth = 1
self.num_channels = num_channels
self.n_fold = 2
self.labels = ("time", "channel", "amplitude")
self.positions = (2, 1, 0)
self.convert = utils.convert_to_angles
self.restore = utils.convert_from_angles
self.keys = ("num_samples", "num_channels", "qubit_shape")
print(self.name)
# ------------------- Encoding Helpers ---------------------------
# ----- Data Preparation -----
[docs]
def calculate(
self, data: np.ndarray, verbose: Union[int, bool] = True
) -> Tuple[Tuple[int, int], Tuple[int, int, int]]:
"""Returns necessary information required for Encoding and Decoding:
- Number of qubits required to encode Channel, Time and Amplitude information.
- Original shape of the data required for decoding.
Args:
data: Array representing Digital Audio Samples.
verbose: Prints the Qubit information if True or int > 0.
Returns:
A Tuple of (data_shape, qubit_shape).
`data_shape` is a Tuple (int, int) consisting of:
- `num_samples`
- `num_channels`
`qubit_shape` is a Tuple (int, int, int) consisting of:
- `num_index_qubits` to encode Time Information.
- `num_channel_qubits` to encode Channel Information.
- `num_value_qubits` to encode Amplitude Information.
"""
# x-axis
num_samples = data.shape[-1]
num_index_qubits = utils.get_qubit_count(num_samples)
# y-axis
num_channels = (
1 if data.ndim == 1 else data.shape[0]
) # data-dependent channels
if self.num_channels:
num_channels = self.num_channels # override with pre-set channels
data_shape = (num_channels, num_samples)
num_channel_qubits = utils.get_qubit_count(
max(2, num_channels)
) # apply constraint of minimum 2 channels
num_value_qubits = self.qubit_depth
qubit_shape = (num_index_qubits, num_channel_qubits, num_value_qubits)
# print
if verbose:
utils.print_num_qubits(qubit_shape, labels=self.labels)
return data_shape, qubit_shape
[docs]
def prepare_data(
self, data: np.ndarray, num_index_qubits: int, num_channel_qubits: int
) -> np.ndarray:
"""Prepares the data with appropriate dimensions for encoding:
- It pads the length of data with zeros on both dimensions to fit the
number of states that can be represented with time and channel registers.
- It flattens the array for encoding. The default arrangement of samples is
made in an alternating manner using `utils.interleave_channels`.
Args:
data: Array representing Digital Audio Samples
num_index_qubits: Number of qubits used to encode the sample indices.
num_channel_qubits: Number of qubits used to encode the channels.
Returns:
Array with dimensions suitable for encoding.
Note:
This method should be followed by `convert()` method
to convert the values suitable for encoding.
"""
data = utils.apply_padding(
data, (num_channel_qubits, num_index_qubits)
)
data = utils.interleave_channels(data)
return data
[docs]
def initialize_circuit(
self,
num_index_qubits: int,
num_channel_qubits: int,
num_value_qubits: int,
) -> qiskit.QuantumCircuit:
"""Initializes the circuit with Index, Channel and Value Registers.
Args:
num_index_qubits: Number of qubits used to encode the sample indices.
num_channel_qubits: Number of qubits used to encode the channels.
num_value_qubits: Number of qubits used to encode the sample values.
Returns:
Qiskit Circuit with the registers
"""
index_register = qiskit.QuantumRegister(
num_index_qubits, self.labels[0]
)
channel_register = qiskit.QuantumRegister(
num_channel_qubits, self.labels[1]
)
value_register = qiskit.QuantumRegister(
num_value_qubits, self.labels[2]
)
circuit = qiskit.QuantumCircuit(
value_register,
channel_register,
index_register,
name=self.__class__.__name__,
)
circuit.h(channel_register)
circuit.h(index_register)
return circuit
[docs]
@utils.with_indexing
def value_setting(
self, circuit: qiskit.QuantumCircuit, index: int, value: float
) -> None:
"""Encodes the prepared, converted values to the initialised circuit.
This function is used to set a single value at a single index. The
decorator `with_indexing` applies the necessary control qubits
corresponding to the given index.
Args:
circuit: Initialized Qiskit Circuit
index: position to set the value
value: value to be set at the index
"""
value_register, channel_register, index_register = circuit.qregs
# initialise sub-circuit
sub_circuit = qiskit.QuantumCircuit(
name=f"Sample {index} (CH {index%(2**channel_register.size)})"
)
sub_circuit.add_register(value_register)
# rotate qubits with values
sub_circuit.ry(2 * value, 0)
# entangle with index qubits
sub_circuit = sub_circuit.control(
channel_register.size + index_register.size
)
# attach sub-circuit
circuit.append(
sub_circuit, list(i for i in range(circuit.num_qubits - 1, -1, -1))
)
[docs]
def measure(self, circuit: qiskit.QuantumCircuit) -> None:
"""Adds classical measurements to all registers of the Quantum Circuit
if the circuit is not already measured.
Args:
circuit: Encoded Qiskit Circuit
"""
if not circuit.cregs:
circuit.measure_all()
# ----- Default Encode Function -----
[docs]
def encode(
self,
data: np.ndarray,
measure: bool = True,
verbose: Union[int, bool] = 1,
) -> qiskit.QuantumCircuit:
"""Given audio data, prepares a Qiskit Circuit representing it.
Args:
data: Array representing Digital Audio Samples
measure: Adds measurement to the circuit if set True or int > 0.
verbose: Level of information to print.
- >1: Prints the number of qubits required.
- >2: Displays the encoded circuit.
Returns:
A Qiskit Circuit representing the Digital Audio
"""
utils.validate_data(data)
(num_channels, num_samples), qubit_shape = self.calculate(
data, verbose=verbose
)
num_index_qubits, num_channel_qubits, num_value_qubits = qubit_shape
# prepare data
data = self.prepare_data(data, num_index_qubits, num_channel_qubits)
values = self.convert(data)
# prepare circuit
circuit = self.initialize_circuit(
num_index_qubits, num_channel_qubits, num_value_qubits
)
# encode information
for i, sample in enumerate(values):
self.value_setting(circuit=circuit, index=i, value=sample)
# additional information for decoding
circuit.metadata = {
"num_samples": num_samples,
"num_channels": num_channels,
"qubit_shape": qubit_shape,
"scheme": circuit.name,
}
# measure
if measure:
self.measure(circuit)
if verbose == 2:
utils.draw_circuit(circuit, decompose=1)
return circuit
# ------------------- Decoding Helpers ---------------------------
[docs]
def decode_components(
self,
counts: Union[dict, qiskit.result.Counts],
qubit_shape: Tuple[int, int],
) -> np.ndarray:
"""The first stage of decoding is extracting required components from
counts.
Args:
counts: a dictionary with the outcome of measurements
performed on the quantum circuit.
qubit_shape: Tuple to determine the number of (channels, samples) to get.
Returns:
2-D Array of shape (num_channels, num_samples)
for further decoding.
"""
# initialising components
num_index_qubits = qubit_shape[0]
num_channel_qubits = qubit_shape[1]
num_samples = 2**num_index_qubits
num_channels = 2**num_channel_qubits
num_components = (num_channels, num_samples)
cosine_amps = np.zeros(num_components)
sine_amps = np.zeros(num_components)
# getting components from counts
for state in counts:
index_bits, channel_bits, value_bits = utils.split_string(
state, qubit_shape
)
index = int(index_bits, 2)
channel = int(channel_bits, 2)
value = counts[state]
if value_bits == "0":
cosine_amps[channel][index] = value
elif value_bits == "1":
sine_amps[channel][index] = value
return cosine_amps, sine_amps
[docs]
def reconstruct_data(
self,
counts: Union[dict, qiskit.result.Counts],
qubit_shape: Tuple[int, int],
inverted: bool = False,
) -> np.ndarray:
"""Given counts, Extract components and restore the conversion did at
encoding stage.
Args:
counts: a dictionary with the outcome of measurements
performed on the quantum circuit.
qubit_shape: Tuple to determine the number of (channels, samples) to get.
inverted : retrieves cosine components of the signal.
Return:
Array of restored values
"""
cosine_amps, sine_amps = self.decode_components(counts, qubit_shape)
data = self.restore(cosine_amps, sine_amps, inverted)
return data
[docs]
def decode_counts(
self,
counts: Union[dict, qiskit.result.Counts],
metadata: dict,
inverted: bool = False,
keep_padding: Tuple[int, int] = (False, False),
) -> np.ndarray:
"""Given a Qiskit counts object or Dictionary, Extract components and restore the
conversion did at encoding stage.
Args:
counts: a qiskit Counts object or Dictionary obtained from a job result.
metadata: metadata required for decoding.
inverted : retrieves cosine components of the signal.
keep_padding: Undo the padding set at Encoding stage if set to False.
- Dimension 0 for Channels.
- Dimension 1 for Time.
Return:
Array of restored values with original dimensions
"""
# decoding x-axis
index_position, channel_position, _ = self.positions
qubit_shape = metadata["qubit_shape"]
num_channel_qubits = qubit_shape[1]
num_channels = 2**num_channel_qubits
original_num_samples = metadata["num_samples"]
original_num_channels = metadata["num_channels"]
# decoding y-axis
data = self.reconstruct_data(
counts=counts,
qubit_shape=qubit_shape,
inverted=False,
)
# post-processing
data = utils.restore_channels(data, num_channels)
if not keep_padding[0]:
data = data[:original_num_channels]
if not keep_padding[1]:
data = data[:, :original_num_samples]
return data
[docs]
def decode_result(
self,
result: qiskit.result.Result,
metadata: Optional[dict] = None,
inverted: bool = False,
keep_padding: Tuple[int, int] = (False, False),
) -> np.ndarray:
"""Given a result object. Extract components and restore the conversion
did in the encoding stage.
Args:
result: a qiskit Result object that contains counts along
with metadata that was held by the original circuit.
metadata: optionally pass metadata as argument.
inverted : retrieves cosine components of the signal.
keep_padding: Undo the padding set at Encoding stage if set to False.
- Dimension 0 for Channels.
- Dimension 1 for Time.
Return:
Array of restored values with original dimensions
"""
counts = utils.get_counts(result)
metadata = utils.get_metadata(result) if not metadata else metadata
data = self.decode_counts(
counts=counts,
metadata=metadata,
inverted=inverted,
keep_padding=keep_padding,
)
return data
# ----- Default Decode Function -----
[docs]
def decode(
self,
circuit: qiskit.QuantumCircuit,
metadata: Optional[dict] = None,
inverted: bool = False,
keep_padding: Tuple[int, int] = (False, False),
execute_function: Callable[
[qiskit.QuantumCircuit, dict], Any
] = utils.execute,
**kwargs,
) -> np.ndarray:
"""Given a qiskit circuit, decodes and returns the Original Audio Array.
Args:
circuit: A Qiskit Circuit representing the Digital Audio.
metadata: optionally pass metadata as argument.
inverted: retrieves cosine components of the signal.
keep_padding: Undo the padding set at Encoding stage if set to False.
- Dimension 0 for Channels.
- Dimension 1 for Time.
execute_function: Function to execute the circuit for decoding.
- Defaults to :ref:`utils.execute <execute>` which accepts any additional `**kwargs`.
Return:
Array of decoded values
"""
self.measure(circuit)
result = utils.execute(circuit=circuit, **kwargs)
data = self.decode_result(
result=result,
metadata=metadata,
inverted=inverted,
keep_padding=keep_padding,
)
return data