{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Get an introduction to graphs in Boulder Opal\n",
    "\n",
    "**An overview of how Boulder Opal uses computational graphs to represent systems and perform operations**"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Computational graphs are used in Boulder Opal to represent quantum systems and desired computations on them, from simple arithmetic calculations to complex operations on high-dimensional quantum systems, including simulations and optimizations.\n",
    "\n",
    "Our [simulation tutorial](https://docs.q-ctrl.com/boulder-opal/toolkit/design/simulate-quantum-systems/learn-simulation-basics-through-the-dynamics-of-a-single-qubit) can then teach you how to perform a simple simulation with Boulder Opal using graphs."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## What are graphs?\n",
    "\n",
    "Graphs are a way of describing simple and complex computations using a combination of *nodes* and *edges*.\n",
    "\n",
    "Each *node* in a graph describes a primitive computation, known as an *operation*, which is performed on its input(s) and results in its output(s).\n",
    "Boulder Opal offers a wide variety of nodes, some representing standard mathematical operations and others specific to quantum systems.\n",
    "The operations can be simple, like the multiplication of two matrices, or complex, such as calculating the unitary evolution operators due to a Hamiltonian.\n",
    "You can see all available [graph operations](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes) in our reference.\n",
    "\n",
    "*Edges* in the graph connect nodes together, representing the data flowing from one node to another.\n",
    "An edge going into a node represents an input to that node's operation, and an edge going out of a node represents an output.\n",
    "A node can have multiple input edges and multiple output edges, although they usually have a single output.\n",
    "\n",
    "More formally, we use *directed* and *acyclic* graphs, known as [data-flow graphs](https://en.wikipedia.org/wiki/Dataflow_programming).\n",
    "This means that each edge has a given direction between two nodes (indicating the flow of information), and no closed loops are created following those directions.\n",
    "\n",
    "Note that the computation is not performed while constructing the graph.\n",
    "Instead you can think of it as defining a recipe for executing the computation remotely.\n",
    "You can only retrieve the computed values out of graphs when they are evaluated."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Why does Boulder Opal use graphs?\n",
    "\n",
    "Graphs offer some key advantages when it comes to representing generic computations:\n",
    " - Flexibility: no particular structure is enforced, so you can represent *any* computation, as long as it is expressed in terms of the available *nodes*.\n",
    " - Efficiency: graphs can be evaluated extremely efficiently.\n",
    " - Automatic differentiation: graphs can be automatically differentiated, enabling features like gradient-based optimization and calculation of Hessian matrices."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Graph nodes\n",
    "\n",
    "Each node has a particular [data type](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/node-data-types) describing the type of data it produces.\n",
    "\n",
    "Some commonly-used nodes are:\n",
    "- [Tensors](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/Tensor), representing multidimensional arrays of data.\n",
    "You can manipulate tensors in much the same way as NumPy arrays, although you need to use [graph operations](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes) instead of NumPy functions.\n",
    "When calling a graph operation that accepts tensors, you can usually pass NumPy arrays instead as well.\n",
    "- [PWCs](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/Pwc), representing piecewise-constant functions of time.\n",
    "We offer several [graph operations for creating and manipulating PWCs](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#piecewise-constant-tensor-functions-pwcs).\n",
    "- [STFs](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/Stf), representing sampleable time-dependent functions, with various [STF-specific graph operations](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#sampleable-tensor-functions-stfs) available."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Graph operations\n",
    "\n",
    "### Basic operations\n",
    "\n",
    "Boulder Opal graphs include many nodes representing operations you can apply to Tensors, PWCs, or STFs.\n",
    "These include basic mathematical functions (like `graph.sqrt`, `graph.exp`, `graph.log`), trigonometric functions (`graph.sin`, `graph.cosh`, `graph.arctan`), functions for complex objects (`graph.real`, `graph.angle`, `graph.conjugate`), and even matrix functions (`graph.trace`, `graph.adjoint`).\n",
    "You can search the [full list of available operations](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes) to learn more about them.\n",
    "\n",
    "### Arithmetic operations\n",
    "\n",
    "You can directly sum PWC operators to yield the overall system Hamiltonian, as long as all the operators have compatible shapes.\n",
    "In general, you can apply basic element-wise arithmetic operations between arrays, Tensors, PWCs, or STFs using regular Python operators, such as `+` (instead of `graph.add`), `-`, `*`, `/`, `//` (floor division), `^` (exponentiation), and `@` (matrix multiplication).\n",
    "We have enabled this for convenience of use by constructing optional wrappers around the corresponding graph operations.\n",
    "\n",
    "Note that operations between PWCs/STFs and NumPy arrays or Tensors correspond to applying the operation to every value that the PWC/STF assumes in time, while operations between two PWCs/STFs correspond to the operation between the values that the objects assume in each time window.\n",
    "Operations between a PWC and an STF are not allowed.\n",
    "\n",
    "Besides basic arithmetic, you can also calculate other operations between objects, such as matrix multiplication (using the `@` operator or `graph.matmul`), the Kronecker product (with `graph.kron`), or create a complex object from its real and imaginary parts (via `graph.complex_value`).\n",
    "In these cases the shape compatibility depends on the particular operation.\n",
    "\n",
    "If the two objects don't have the same shape, Boulder Opal attempts to broadcast them into a compatible shape.\n",
    "Broadcasting is an important tool to improve the efficiency of your code, and you can learn more about it in the topic [Batching and broadcasting in Boulder Opal](https://docs.q-ctrl.com/boulder-opal/toolkit/design/calculate-with-graphs/understand-batches-and-broadcasting-in-boulder-opal).\n",
    "\n",
    "### Quantum-specific operations\n",
    "\n",
    "We offer specific [graph operations](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes) for working with quantum systems.\n",
    "These include operations to calculate the [time evolution](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#time-evolution) of your open or closed quantum system, operations for [optimal and robust control](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#optimal-and-robust-control), and operations to design [Mølmer–Sørensen gates](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#molmersorensen-gates), among several others."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Using graphs\n",
    "\n",
    "### Creating a graph\n",
    "\n",
    "You instantiate a Boulder Opal graph (a Python object) using the `boulderopal.Graph` function (for instance, as `graph = boulderopal.Graph()`).\n",
    "This will create an empty graph, without any nodes or edges.\n",
    "\n",
    "### Adding nodes\n",
    "\n",
    "You should then add nodes representing the computation or describing the quantum system you are aiming to construct, by calling the [methods](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes) of our Python graph object.\n",
    "For instance, nodes that describe the Hamiltonian of your quantum system, and/or nodes representing the computation that you want to perform on it (for example, calculating the time evolution operators for the system).\n",
    "\n",
    "You can pass the results of operations as inputs to other operations in order to build up graphs representing more complicated computations.\n",
    "\n",
    "When defining each node, you give a name to those whose values you want to retrieve after the computation has been executed.\n",
    "You do this by passing a `name=` argument to its constructor, or by assigning a string to the `name` attribute of the node after defining it.\n",
    "For a simulation you likely want to retrieve the values of nodes representing the time evolution operators or evolved states of your system, while for an optimization you might be interested, for example, in the nodes representing the control pulses that create the optimized gates.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Evaluating a graph\n",
    "#### Executing a graph\n",
    "\n",
    "You can execute a graph, carrying out the computation it represents, and evaluate the values of its nodes by using the [graph execution function](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/execution).\n",
    "This is the typical case when you want to [perform the simulation of a quantum system](https://docs.q-ctrl.com/boulder-opal/toolkit/design/simulate-quantum-systems).\n",
    "\n",
    "The `boulderopal.execute_graph` function calculates and returns the values of specific nodes in the graph.\n",
    "See our documentation on [simulation tutorial](https://docs.q-ctrl.com/boulder-opal/toolkit/design/simulate-quantum-systems/learn-simulation-basics-through-the-dynamics-of-a-single-qubit) and our various [simulation user guides](https://docs.q-ctrl.com/boulder-opal/toolkit/design/simulate-quantum-systems) for examples.\n",
    "\n",
    "Here we show a simple example that execute a simple graph that calculates the trace of a matrix:\n",
    "$$\n",
    "\\mathrm{tr} \\left[ \\sigma_z \\otimes \\sigma_z + \\mathrm{Id}_4 \\right]  ,\n",
    "$$\n",
    "where $\\sigma_z$ is the Pauli Z operator and $\\mathrm{Id}_4$ the $4\\times 4$ identity matrix."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Keys of result['output']:\n",
      " ['matrix', 'trace']\n",
      "\n",
      "Matrix value:\n",
      " [[2.+0.j 0.+0.j 0.+0.j 0.+0.j]\n",
      " [0.+0.j 0.+0.j 0.+0.j 0.+0.j]\n",
      " [0.+0.j 0.+0.j 0.+0.j 0.+0.j]\n",
      " [0.+0.j 0.+0.j 0.+0.j 2.+0.j]]\n",
      "\n",
      "Trace value:\n",
      " (4+0j)\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "import boulderopal as bo\n",
    "\n",
    "\n",
    "# Create graph.\n",
    "graph = bo.Graph()\n",
    "\n",
    "# Add nodes and operations.\n",
    "identity_4 = np.eye(4)\n",
    "\n",
    "# Create node with the matrix we want to trace.\n",
    "kron = graph.pauli_kronecker_product([(\"Z\", 0), (\"Z\", 1)], 2)\n",
    "matrix = kron + identity_4\n",
    "matrix.name = \"matrix\"\n",
    "\n",
    "# Create node calculating the trace.\n",
    "trace = graph.trace(matrix, name=\"trace\")\n",
    "\n",
    "# Execute the graph.\n",
    "result = bo.execute_graph(graph=graph, output_node_names=[\"matrix\", \"trace\"])\n",
    "\n",
    "# Extract results.\n",
    "print(f\"Keys of result['output']:\\n {list(result['output'].keys())}\\n\")\n",
    "print(f\"Matrix value:\\n {result['output']['matrix']['value']}\\n\")\n",
    "print(f\"Trace value:\\n {result['output']['trace']['value']}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Optimizing a graph\n",
    "\n",
    "If you want to optimize the values of some graph nodes in order to minimize a cost function, you should call one of the [graph optimization functions](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/optimization):\n",
    "\n",
    " - `boulderopal.run_optimization`, which optimizes special [optimization nodes](https://docs.q-ctrl.com/references/boulder-opal/boulderopal/graph/nodes#optimization-variables) in order to minimize a specified *cost* node, and then returns the values of specific nodes in the graph.\n",
    "Note that optimization variables cannot be used with `boulderopal.execute_graph`.\n",
    "Check out our [robust control optimization](https://docs.q-ctrl.com/boulder-opal/toolkit/design/design-error-robust-quantum-logic-gates/learn-to-design-robust-single-qubit-gates-using-computational-graphs) tutorial or our various [control optimization](https://docs.q-ctrl.com/boulder-opal/toolkit/design/design-model-based-controls) and [system identification](https://docs.q-ctrl.com/boulder-opal/toolkit/design/characterize-hardware/how-to-perform-parameter-estimation-with-a-small-amount-of-data) user guides to see this function in action.\n",
    " - `boulderopal.run_stochastic_optimization`, which is similar to `boulderopal.run_optimization`, but allows stochastic (random) cost functions.\n",
    "You can see examples in our [stochastic optimization](https://docs.q-ctrl.com/boulder-opal/toolkit/design/design-model-based-controls/how-to-optimize-controls-robust-to-strong-noise-sources) and [system identification](https://docs.q-ctrl.com/boulder-opal/toolkit/design/characterize-hardware/how-to-perform-parameter-estimation-with-a-small-amount-of-data) user guides.\n",
    " - `boulderopal.run_gradient_free_optimization`, which is an alternative to the gradient-based optimizers, and is useful when the gradient is either very costly to compute or inaccessible.\n",
    "You can see an example in our [gradient-free optimization](https://docs.q-ctrl.com/boulder-opal/toolkit/design/design-model-based-controls/how-to-optimize-controls-using-gradient-free-optimization) user guide.\n",
    " \n",
    "When calling these functions, besides providing the graph, you need to provide a list of `output_node_names` with strings corresponding to the nodes whose values you want to retrieve from the graph.\n",
    "If you haven't explicitly named those nodes while defining them, you can check their automatically-assigned names by calling the appropriate attribute, `<node_variable_name>.name`, and use those instead.\n",
    "In the case of the optimization functions, you also need to provide the `cost_node_name` of the node whose value you want to minimize.\n",
    "\n",
    "Below we show a simple graph optimization that minimizes the Booth function,\n",
    "$$\n",
    "f(x,y) = (x + 2 y - 7)^2 + (2 x + y - 5)^2  ,\n",
    "$$\n",
    "which has a global minimum at $f(x=1, y=3) = 0$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Optimized cost = 2.840e-29\n",
      "Variables (x, y) = (0.9999999999999982, 3.0000000000000036)\n"
     ]
    }
   ],
   "source": [
    "# Create graph object.\n",
    "graph = bo.Graph()\n",
    "\n",
    "# Add optimization variables.\n",
    "x = graph.optimizable_scalar(lower_bound=-10, upper_bound=10, name=\"x\")\n",
    "y = graph.optimizable_scalar(lower_bound=-10, upper_bound=10, name=\"y\")\n",
    "\n",
    "# Create cost node.\n",
    "cost = (x + 2 * y - 7) ** 2 + (2 * x + y - 5) ** 2\n",
    "cost.name = \"cost\"\n",
    "\n",
    "# Optimize the graph, miniizing the value of the cost node.\n",
    "result = bo.run_optimization(\n",
    "    graph=graph,\n",
    "    cost_node_name=\"cost\",\n",
    "    output_node_names=[\"x\", \"y\"],\n",
    "    optimization_count=4,\n",
    ")\n",
    "\n",
    "# Extract the values of the final cost function and the output nodes.\n",
    "print(f\"Optimized cost = {result['cost']:.3e}\")\n",
    "print(\n",
    "    f\"Variables (x, y) = {(result['output']['x']['value'], result['output']['y']['value'])}\"\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Next steps\n",
    "\n",
    "[Our documentation](https://docs.q-ctrl.com/boulder-opal) has tutorials and user guides with step-by-step examples of graphs being used for representing and solving various relevant problems with Boulder Opal.\n",
    "For instance, you can see how to use graph representations for [optimal control](https://docs.q-ctrl.com/boulder-opal/toolkit/design/design-error-robust-quantum-logic-gates/learn-to-design-robust-single-qubit-gates-using-computational-graphs) (for calculating optimized control pulses), [simulation](https://docs.q-ctrl.com/boulder-opal/toolkit/design/simulate-quantum-systems/learn-simulation-basics-through-the-dynamics-of-a-single-qubit) (to understand the dynamics of the system in the presence of specific controls and noises), and [system identification](https://docs.q-ctrl.com/boulder-opal/toolkit/design/characterize-hardware) (to estimate the values of unknown system parameters based on measurements of the system).\n",
    "Our [topic on improving calculation performance](https://docs.q-ctrl.com/boulder-opal/toolkit/design/calculate-with-graphs/improve-calculation-performance-in-graphs) can give you further tips to consider when working with graphs to solve your unique and interesting problems."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "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.11.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
