Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Entropy Computing via QCi

Entropy Computing via QCi

David E. Bernal Neira
Davidson School of Chemical Engineering, Purdue University

Albert Lee
Davidson School of Chemical Engineering, Purdue University
Graduate Research Assistant

Open In Colab SECQUOIA

Entropy Computing via QCi

This notebook provides an introduction to solving constrained optimization problems using Quantum Computing Inc.'s (QCI) Entropy Quantum Computer. We will define a Constrained Quadratic Model by setting up an objective function and a series of linear constraints. The notebook demonstrates how to:

  1. Structure the problem using QCI’s eqc_models library.

  2. Submit the model to the Dirac3IntegerCloudSolver and Dirac3ContinuousSolver for processing.

  3. Analyze the high-quality candidate solutions returned by the solver to find the optimal result.

# If using this on Google colab, we need to install the packages
try:
  import google.colab
  IN_COLAB = True
except:
  IN_COLAB = False

if IN_COLAB:
    !pip install eqc_models pyomo
# Import the QCI models and solvers
import eqc_models
from eqc_models.base import ConstrainedPolynomialModel, PolynomialModel, QuadraticModel
from eqc_models.solvers import Dirac3IntegerCloudSolver, Dirac3ContinuousCloudSolver

# Import Matplotlib to generate plots
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker

# Import numpy and scipy for certain numerical calculations below
import numpy as np
from scipy.special import gamma
import pyomo.environ as pyo
import os
if IN_COLAB:
    !pip install idaes-pse --pre
    !idaes get-extensions --to ./bin 
    os.environ['PATH'] += ':bin'

How Dirac-3 Works

The Dirac-3 solver is a cloud-based quantum solver that uses the Entropy Quantum Computer (EQC) to solve optimization problems. It encodes a problem into the physical properties of coherent light pulses, allowing it to represent and explore many potential solutions at once within its optical circuits. A controlled feedback loop, managed by electronics, interacts with the light to steer the system away from poor solutions. This process rapidly converges the system onto the single, lowest-energy state, which represents the optimal answer.

# Define the API URL and token for QCI
# Note: Replace the api_url and api_token with your actual QCI API URL and token
api_url = "https://api.qci-prod.com" 
api_token = "" # Replace with your actual API token

Solving Problems using Dirac-3

Dirac-3 can solve combinatorial optimization problems using the follwing objective function, E, that has the following high-order polynomial structure:

E=i=1NCiVi+i,j=1N,NJijViVj+i,j,k=1N,N,NTijkViVjVk+i,j,k,l=1N,N,N,NQijklViVjVkVl+i,j,k,l,m=1N,N,N,N,NPijklmViVjVkVlVmE = \sum_{i=1}^{N} C_i V_i + \sum_{i,j=1}^{N,N} J_{ij} V_i V_j + \sum_{i,j,k=1}^{N,N,N} T_{ijk} V_i V_j V_k + \sum_{i,j,k,l=1}^{N,N,N,N} Q_{ijkl} V_i V_j V_k V_l + \sum_{i,j,k,l,m=1}^{N,N,N,N,N} P_{ijklm} V_i V_j V_k V_l V_m

ViV_i refers to the value of nonnegative variable, and CiC_i, JijJ_{ij}, TijkT_{ijk}, QijklQ_{ijkl}, and PijklmP_{ijklm} are the coefficients of the objective function. The solver defaults to minimization. To solve a maximization problem, you should multiply the entire objective function by -1.

Continuous Solver

The Dirac-3 continuous solver is particularly well-suited for this objective function, treating it as a quasi-continuous optimization problem. In this approach, the solver operates under the linear constraint

R=i=1NVi,R[1,10000].R = \sum_{i=1}^{N} V_i, \quad R \in [1, 10000].

Note that RR is a fixed value, which for this problem must be in the range of [1, 10000].

Let’s try to solve the following simple quadratic problem with linear constraints using the Dirac-3 continuous solver:

minE=3x12+2x22+x32s.t.x1+x2+x3=10,x1,x2,x3[0,10].\begin{align*} \min \quad & E = 3 x_1^2 + 2 x_2^2 + x_3^2 \\ \text{s.t.} \quad & x_1 + x_2 + x_3 = 10, \\ & x_1, x_2, x_3 \in [0, 10]. \end{align*}
# 1. Create a concrete Pyomo model
m = pyo.ConcreteModel(name="Simple_Quadratic_Program")

# 2. Define the decision variables with their bounds
# The variables x_1, x_2, and x_3 are defined with bounds [0, 10].
m.x = pyo.Var([1, 2, 3], domain=pyo.NonNegativeReals, bounds=(0, 10))

# 3. Define the objective function
# The objective is to minimize the quadratic expression.
m.obj = pyo.Objective(
    expr=3*m.x[1]**2 + 2*m.x[2]**2 + m.x[3]**2, 
    sense=pyo.minimize
)

# 4. Define the linear constraint
# The sum of the variables must equal 10.
m.c1 = pyo.Constraint(expr=m.x[1] + m.x[2] + m.x[3] == 10)

# 5. Create a solver instance and solve the model
# We specify 'ipopt' as the solver. 'tee=True' displays the solver's log.
solver = pyo.SolverFactory('ipopt')
results = solver.solve(m, tee=False)

# 6. Display the optimization results
print("\n" + "="*30)
print("-- 📊 Optimization Results --")
print(f"Solver Status: {results.solver.status}")
print(f"Termination Condition: {results.solver.termination_condition}")
print(f"Objective Value (E): {pyo.value(m.obj):.4f}")
print("\n-- 🏗️ Variable Values --")
for i in m.x:
    print(f"x[{i}] = {pyo.value(m.x[i]):.4f}")
print("="*30)

==============================
-- 📊 Optimization Results --
Solver Status: ok
Termination Condition: optimal
Objective Value (E): 54.5455

-- 🏗️ Variable Values --
x[1] = 1.8183
x[2] = 2.7272
x[3] = 5.4545
==============================

Now let’s solve this problem using the Dirac-3 continuous solver.

1. Defining the Objective Function

The polynomial objective function, E, is defined by combining two lists: coefficients and indices.

  • coefficients: This list contains the numerical multiplier for each term in your objective function.

    coefficients = [3, 2, 1] 
  • indices: This list of tuples specifies which variables are multiplied together for each term. The numbers in the tuples correspond to the variable indices (e.g., 1 for x1x_1, 2 for x2x_2).

    # (1,1) -> x_1*x_1  |  (2,2) -> x_2*x_2  |  (3,3) -> x_3*x_3
    indices = [(1,1), (2,2), (3,3)]

The PolynomialModel maps the coefficients to the indices in order, creating the full objective function:

E=3coef[0]x1x1idx[0]+2coef[1]x2x2idx[1]+1coef[2]x3x3idx[2]E = \underbrace{3}_{\text{coef[0]}} \cdot \underbrace{x_1 x_1}_{\text{idx[0]}} + \underbrace{2}_{\text{coef[1]}} \cdot \underbrace{x_2 x_2}_{\text{idx[1]}} + \underbrace{1}_{\text{coef[2]}} \cdot \underbrace{x_3 x_3}_{\text{idx[2]}}

2. Setting Constraints

Constraints are handled in two different places:

  • Variable Bounds upper_bound: The individual upper bounds for each variable are set as an attribute on the model object after it has been created.

    # Sets the upper bound for all 3 variables to 10
    model.upper_bound = 10 * np.ones((3,))
R = 10
coefficients = [3, 2, 1]
indices = [(1,1), (2,2), (3,3)] # Create a polynomial model
model = PolynomialModel(coefficients, indices)
model.upper_bound = R*np.ones((3,)) # Bounds for the variables

3. Submitting the Model to the Solver

When submitting the model to the solver, you need to provide several key parameters:

  • sum_constraint: The constraint on the sum of all variables (R=ViR = \sum V_i) is a required input for the solver.solve() method.

  • relaxation_schedule: An integer from the set {1, 2, 3, 4} representing one of four predefined schedules. Higher values reduce variation in the analog spin values during the solving process and are more likely to result in a better objective function value.

  • num_samples: An integer specifying the number of independent solutions (samples) to be generated by the device.

# The solver needs the value for R, the schedule, and number of samples
response = solver.solve(
    model, 
    sum_constraint=10, 
    relaxation_schedule=1, 
    num_samples=10
)
solver = Dirac3ContinuousCloudSolver(url=api_url, api_token=api_token)
response = solver.solve(model, sum_constraint=10, relaxation_schedule=1, num_samples=20) # sum constraint is set to 10, 5 samples are collected

# Print the results
print(response["results"]["solutions"])
print(response["results"]["energies"])
print(response["results"]["counts"])
2025-08-18 15:27:39 - Dirac allocation balance = 9645 s (unmetered)
2025-08-18 15:27:39 - Job submitted: job_id='68a37eab8060c9339796334d'
2025-08-18 15:27:39 - QUEUED
2025-08-18 15:27:42 - RUNNING
2025-08-18 15:27:44 - COMPLETED
2025-08-18 15:27:47 - Dirac allocation balance = 9645 s (unmetered)
[[1.8597407, 2.7621448, 5.3781147], [1.8901105, 2.7242651, 5.3856239], [1.8217838, 2.8303771, 5.3478389], [1.9145035, 2.722614, 5.3628826], [1.9317503, 2.7126865, 5.3555632], [1.7030318, 2.71842, 5.5785484], [1.942136, 2.6797423, 5.3781223], [1.9522564, 2.67962, 5.3681231], [1.6953294, 2.8521452, 5.4525251], [1.8016272, 2.5664244, 5.6319485], [1.6546638, 2.730413, 5.614924], [1.7797163, 2.9434371, 5.2768469], [1.6292859, 2.7642462, 5.6064677], [1.9134034, 2.8933036, 5.1932931], [1.8563873, 2.9376242, 5.2059884], [1.8356512, 2.972044, 5.1923056], [2.0200472, 2.779747, 5.2002058], [1.5742502, 2.8361845, 5.5895658], [1.7902411, 3.0133739, 5.1963849], [1.5251429, 2.9664829, 5.5083733]]
[54.5589104, 54.5657387, 54.5781403, 54.5817337, 54.5943718, 54.600769, 54.6019135, 54.6113892, 54.6219215, 54.6294937, 54.6514168, 54.6749268, 54.6783142, 54.6960449, 54.7001076, 54.7349739, 54.7378998, 54.7659225, 54.7781487, 54.9203987]
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
print(response)
{'job_info': {'job_id': '68a37eab8060c9339796334d', 'job_submission': {'problem_config': {'normalized_qudit_hamiltonian_optimization': {'polynomial_file_id': '68a37eabacc178773e9a33f8'}}, 'device_config': {'dirac-3_normalized_qudit': {'num_samples': 20, 'relaxation_schedule': 1, 'sum_constraint': 10}}}, 'job_status': {'submitted_at_rfc3339nano': '2025-08-18T19:27:39.467Z', 'queued_at_rfc3339nano': '2025-08-18T19:27:39.468Z', 'running_at_rfc3339nano': '2025-08-18T19:27:39.802Z', 'completed_at_rfc3339nano': '2025-08-18T19:27:42.809Z'}, 'job_result': {'file_id': '68a37eaeacc178773e9a33fa', 'device_usage_s': 2}}, 'status': 'COMPLETED', 'results': {'counts': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 'energies': [54.5589104, 54.5657387, 54.5781403, 54.5817337, 54.5943718, 54.600769, 54.6019135, 54.6113892, 54.6219215, 54.6294937, 54.6514168, 54.6749268, 54.6783142, 54.6960449, 54.7001076, 54.7349739, 54.7378998, 54.7659225, 54.7781487, 54.9203987], 'solutions': [[1.8597407, 2.7621448, 5.3781147], [1.8901105, 2.7242651, 5.3856239], [1.8217838, 2.8303771, 5.3478389], [1.9145035, 2.722614, 5.3628826], [1.9317503, 2.7126865, 5.3555632], [1.7030318, 2.71842, 5.5785484], [1.942136, 2.6797423, 5.3781223], [1.9522564, 2.67962, 5.3681231], [1.6953294, 2.8521452, 5.4525251], [1.8016272, 2.5664244, 5.6319485], [1.6546638, 2.730413, 5.614924], [1.7797163, 2.9434371, 5.2768469], [1.6292859, 2.7642462, 5.6064677], [1.9134034, 2.8933036, 5.1932931], [1.8563873, 2.9376242, 5.2059884], [1.8356512, 2.972044, 5.1923056], [2.0200472, 2.779747, 5.2002058], [1.5742502, 2.8361845, 5.5895658], [1.7902411, 3.0133739, 5.1963849], [1.5251429, 2.9664829, 5.5083733]]}}

4. Interpreting the Results

The solver returns a dictionary containing job information and results. The optimal solution is found by identifying the result with the lowest “energy” (objective function value). The response['results'] key contains three important lists that correspond to each other by index:

  • solutions: A list of all potential solutions. Each item is a list of variable values (e.g., [x1, x2, x3]).

  • energies: A list of the objective function values (energies) for each corresponding solution.

  • counts: A list of how many times each solution was found (typically 1 for unique solutions).

# Extract the results from the response
results = response["results"]
energies = results["energies"]
solutions = results["solutions"]

# Find the index of the best solution (minimum energy)
best_index = np.argmin(energies)

# Get the best solution and its energy
best_energy = energies[best_index]
best_solution = solutions[best_index]

# Print the results in a clear format
print("-- 📊 Best Solution Found --")
print(f"Optimal Objective (Energy): {best_energy:.4f}")
print(f"Variable Values: {np.round(best_solution, 4)}")
-- 📊 Best Solution Found --
Optimal Objective (Energy): 54.5477
Variable Values: [1.8302 2.6994 5.4704]

Integer Solver

The Dirac-3 can solver unconstrained optimization problem. In this mode, the resulting state vector ViV_i consists of integers:

ViN,0Vi16V_i \in \mathbb{N}, \quad 0 \leq V_i \leq 16

For integer problems, you must define the number of possible states (or levels) for each variable. For instance, to model a standard Qudratic Unconstrained Binary Optimization problem, you would set the number of levels for each variable to 2. This restricts the variables to two possible states, 0 and 1, which is equivalent to defining a binary variable with an upper bound of 1.

The solver is flexible, allowing for mixed encoding where some variables have 2 levels (binary) while others have more (up to 17 levels, for an upper bound of 16). If your problem requires more than 17 distinct states for any variable, the continuous solver is the recommended approach.

Let’s solve the simple QUBO problem given by the following objective function:

minE=0.75x120.25x22+2x1x2+0.5x1s.t.x1,x2{0,1}\begin{align*} \min \quad & E = -0.75 x_1^2 - 0.25 x_2^2 + 2 x_1 x_2 + 0.5 x_1 \\ \text{s.t.} \quad & x_1, x_2 \in \{0,1\} \\ \end{align*}
# 1. Create a concrete Pyomo model
m = pyo.ConcreteModel(name="Simple_QUBO")

# 2. Define the binary variables
# x[1] and x[2] can only take values of 0 or 1.
m.x = pyo.Var([1, 2], domain=pyo.Binary)

# 3. Define the objective function
# We use the property that for a binary variable x, x^2 = x.
# So, the objective E = -0.75*x1^2 - 0.25*x2^2 + 2*x1*x2 + 0.5*x1 becomes:
# E = -0.75*x1 - 0.25*x2 + 2*x1*x2
m.obj = pyo.Objective(
    expr= -0.75 * m.x[1] - 0.25 * m.x[2] + 2 * m.x[1] * m.x[2] + 0.5 * m.x[1],
    sense=pyo.minimize
)

# 4. Create a solver instance and solve the model
# This is an unconstrained problem, so no constraints are needed.
# We use a Mixed-Integer Programming (MINLP) solver like 'bonmin'.
solver = pyo.SolverFactory('bonmin')
results = solver.solve(m, tee=False)

# 5. Display the optimization results
print("\n" + "="*30)
print("-- 📊 Optimization Results --")
print(f"Solver Status: {results.solver.status}")
print(f"Termination Condition: {results.solver.termination_condition}")
print(f"Objective Value: {pyo.value(m.obj):.4f}")
print("\n-- 🏗️ Variable Values --")
for i in m.x:
    print(f"x[{i}] = {int(pyo.value(m.x[i]))}")
print("="*30)

==============================
-- 📊 Optimization Results --
Solver Status: ok
Termination Condition: optimal
Objective Value: -0.2500

-- 🏗️ Variable Values --
x[1] = 1
x[2] = 0
==============================

Now let’s solve this problem using the Dirac-3 Integer solver.

The problem’s coefficients and indices are structured in the same way as they were for the continuous solver.

coefficients = [-0.75, -0.25, 2, 0.5]  # Coefficients for the polynomial model
indices = [(1, 1), (2, 2), (1, 2), (0, 1)]  # Create a polynomial model
model = PolynomialModel(coefficients, indices)

# add upper bounds for the variables
model.upper_bound = np.ones((2,))  # Bounds for the variables

For integer and binary optimization problems, the sum_constraint is not necessary because Dirac3IntegerCloudSover operates in an unconstrained mode.

solver = Dirac3IntegerCloudSolver(url=api_url, api_token=api_token)
response = solver.solve(model, relaxation_schedule=1, num_samples=100)

# Print the results
print(response)
print(response["results"]["solutions"])
print(response["results"]["energies"])
print(response["results"]["counts"])
2025-08-18 14:45:08 - Dirac allocation balance = 9645 s (unmetered)
2025-08-18 14:45:08 - Job submitted: job_id='68a374b48060c93397963349'
2025-08-18 14:45:08 - QUEUED
2025-08-18 14:45:11 - RUNNING
2025-08-18 14:45:32 - COMPLETED
2025-08-18 14:45:34 - Dirac allocation balance = 9645 s (unmetered)
{'job_info': {'job_id': '68a374b48060c93397963349', 'job_submission': {'problem_config': {'qudit_hamiltonian_optimization': {'polynomial_file_id': '68a374b4acc178773e9a33e8'}}, 'device_config': {'dirac-3_qudit': {'num_levels': [2, 2], 'num_samples': 100, 'relaxation_schedule': 1}}}, 'job_status': {'submitted_at_rfc3339nano': '2025-08-18T18:45:08.772Z', 'queued_at_rfc3339nano': '2025-08-18T18:45:08.772Z', 'running_at_rfc3339nano': '2025-08-18T18:45:09.505Z', 'completed_at_rfc3339nano': '2025-08-18T18:45:32.039Z'}, 'job_result': {'file_id': '68a374ccacc178773e9a33ea', 'device_usage_s': 15}}, 'status': 'COMPLETED', 'results': {'counts': [78, 22], 'energies': [-0.25, -0.25], 'solutions': [[0, 1], [1, 0]]}}
[[0, 1], [1, 0]]
[-0.25, -0.25]
[78, 22]

This code processes the solver’s results by counting the frequency of each unique solution and then uses matplotlib to create a bar chart visualizing this distribution.

def plot_solution_distribution(response):
    """
    Processes a solver response and plots the distribution of unique solutions.

    Args:
        response (dict): The response dictionary from the Dirac-3 solver,
                         containing 'solutions' and 'counts' in its 'results' key.
    """
    # --- Data Processing ---
    # Extract the raw results from the response dictionary
    results = response.get('results', {})
    raw_solutions = results.get('solutions', [])
    raw_counts = results.get('counts', [])

    if not raw_solutions or not raw_counts:
        print("Response object does not contain valid solutions or counts.")
        return

    # Aggregate counts for each unique solution
    solution_counts = {}
    for solution, count in zip(raw_solutions, raw_counts):
        # Convert list to tuple to use it as a dictionary key
        solution_tuple = tuple(solution)
        solution_counts[solution_tuple] = solution_counts.get(solution_tuple, 0) + count

    # Prepare data for plotting
    # Create string labels for the x-axis from the solution tuples
    plot_solutions_str = [str(list(s)) for s in solution_counts.keys()]
    plot_counts = list(solution_counts.values())

    # --- Plotting ---
    plt.style.use('seaborn-v0_8-whitegrid')
    fig, ax = plt.subplots(figsize=(8, 7)) # Increased height for rotated labels

    # Create the bar plot
    bars = ax.bar(plot_solutions_str, plot_counts, color='#4A90E2', width=0.5)

    # Add labels and title for clarity
    ax.set_xlabel("Solution", fontsize=12, labelpad=15)
    ax.set_ylabel("Counts (Frequency)", fontsize=12)
    ax.set_title("Distribution of Solutions", fontsize=14, fontweight='bold')
    
    # Rotate x-axis labels to prevent overlap
    plt.xticks(rotation=90)
    
    # Ensure the y-axis only displays integer values
    ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))

    # Add count labels on top of each bar
    for bar in bars:
        yval = bar.get_height()
        ax.text(bar.get_x() + bar.get_width()/2.0, yval + 0.1, int(yval), ha='center', va='bottom', fontsize=11)

    # Set y-axis to start at 0 and give some space at the top
    if plot_counts:
        ax.set_ylim(0, max(plot_counts) * 1.15)

    # Adjust layout to make room for rotated labels
    plt.tight_layout()
    plt.show()

# Plot the distribution of unique solutions
plot_solution_distribution(response)
<Figure size 800x700 with 1 Axes>

Solving Quadratic Unconstrained Binary Optimization (QUBO) Problems via Dirac-3 Integer Solver

A Quadratic Unconstrained Binary Optimization (QUBO) problem is defined by the goal of minimizing the following objective function, E, over a set of binary variables:

minxE(x)=iCixi+ijJijxixj+cQ\min_x \quad E(x)=\sum_i C_i x_i + \sum_{ij} J_{ij} x_i x_j + c_Q

where the variables xix_i are binary, i.e., xi{0,1}x_i \in \{0,1\}, where CiC_i are the linear coefficients, JijJ_{ij} are the quadratic coefficients, and cQc_Q is an offset term. The Dirac-3 Integer Solver is specifically designed to solve problems of this nature. To submit a QUBO problem, you provide the C and J Hamiltonians to the QuadraticModel. The constant offset cQc_Q is not passed to the model directly; instead, it should be stored separately and added to the final energy value returned by the solver to get the true objective function value.

Example

Suppose we want to solve the following linear integer problem via QUBO $$ \min_{\mathbf{x}} 2𝑥_0+4𝑥_1+4𝑥_2+4𝑥_3+4𝑥_4+4𝑥_5+5𝑥_6+4𝑥_7+5𝑥_8+6𝑥_9+5𝑥_{10} \ s.t. \begin{bmatrix} 1 & 0 & 0 & 1 & 1 & 1 & 0 & 1 & 1 & 1 & 1\ 0 & 1 & 0 & 1 & 0 & 1 & 1 & 0 & 1 & 1 & 1\ 0 & 0 & 1 & 0 & 1 & 0 & 1 & 1 & 1 & 1 & 1 \end{bmatrix}\mathbf{x}=

[111]\begin{bmatrix} 1\\ 1\\ 1 \end{bmatrix}

\mathbf{x} \in {0,1 }^{11} $$

First we would write this problem as a an unconstrained one by penalizing the linear constraints as quadratics in the objective. Let’s first define the problem parameters

A = np.array([[1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1],
            [0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1],
            [0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1]])
b = np.array([1, 1, 1])
c = np.array([2, 4, 4, 4, 4, 4, 5, 4, 5, 6, 5])
num_variables = A.shape[1]

Given the parameters, we can solve the problem using classical methods. However, we can also use the eqc_models library to solve this problem using the Dirac-3 Integer solver.

# Build a Pyomo model for the constrained linear integer program
m = pyo.ConcreteModel(name="Constrained_Linear_Integer_Program")

# Define the set of variable indices
m.I = pyo.RangeSet(0, num_variables - 1)
# Define the set of constraint indices
m.J = pyo.RangeSet(0, A.shape[0] - 1)

# Define the 11 binary variables
m.x = pyo.Var(m.I, domain=pyo.Binary)

# Define the Objective Function
# The objective is the original linear expression.
m.obj = pyo.Objective(
    expr=sum(c[i] * m.x[i] for i in m.I),
    sense=pyo.minimize
)

# Define the constraints
# We define a set of constraints Ax = b directly.
def ax_constraint_rule(model, j):
    # For each row j, sum(A[j,i] * x[i]) must equal b[j]
    return sum(A[j, i] * model.x[i] for i in model.I) == b[j]

m.constraints = pyo.Constraint(m.J, rule=ax_constraint_rule)

# Solve the model
# We use a Mixed-Integer Linear Programming (MILP) solver.
solver = pyo.SolverFactory('cbc')
results = solver.solve(m, tee=False)

# Display the optimization results
print("\n" + "="*40)
print("-- 📊 Optimization Results --")
print(f"Solver Status: {results.solver.status}")
print(f"Termination Condition: {results.solver.termination_condition}")
print(f"Objective Value: {pyo.value(m.obj):.4f}")

print("\n-- 🏗️ Variable Values --")
solution_vector = [int(pyo.value(m.x[i])) for i in m.I]
print(f"x = {solution_vector}")

========================================
-- 📊 Optimization Results --
Solver Status: ok
Termination Condition: optimal
Objective Value: 5.0000

-- 🏗️ Variable Values --
x = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0]

In order to define the C and J matrix, we reformulate the linear problem

minxcxs.t.Ax=bx{0,1}11\min_{\mathbf{x}} \mathbf{c}^\top \mathbf{x}\\ s.t. \mathbf{A}\mathbf{x}=\mathbf{b} \\ \mathbf{x} \in \{0,1 \}^{11}

as follows:

minxcx+ρ(Axb)(Axb)x{0,1}11\min_{\mathbf{x}} \mathbf{c}^\top \mathbf{x} + \rho(\mathbf{A}\mathbf{x}-\mathbf{b})^\top (\mathbf{A}\mathbf{x}-\mathbf{b}) \\ \mathbf{x} \in \{0,1 \}^{11}

where ρ\rho is a penalization factor that ensures the constraints are satisfied.

The term ρ(Axb)(Axb)\rho(\mathbf{A}\mathbf{x}-\mathbf{b})^\top (\mathbf{A}\mathbf{x}-\mathbf{b}) penalizes deviations from the constraints.

ρ(Axb)(Axb)=ρ(x(AA)x2bAx+bb)\rho(\mathbf{A}\mathbf{x}-\mathbf{b})^\top (\mathbf{A}\mathbf{x}-\mathbf{b}) = \rho( \mathbf{x}^\top (\mathbf{A}^\top \mathbf{A}) \mathbf{x} - 2\mathbf{b}^\top \mathbf{A} \mathbf{x} + \mathbf{b}^\top \mathbf{b} )

From this reformulation, we can derive the quadratic hamiltonian, linear hamiltonian and offset as follows:

C=c2ρAbC = \mathbf{c} - 2\rho\mathbf{A}^\top \mathbf{b}
J=ρAAJ = \rho\mathbf{A}^\top \mathbf{A}
cQ=ρ(bb)c_Q = \rho(\mathbf{b}^\top \mathbf{b})

For this problem in particular, one can prove the the penalization factor is given by ρ>i=1nci\rho > \sum_{i=1}^n |c_i|, therefore we choose this bound + 1.

epsilon = 1 # small value to avoid division by zero
rho = np.sum(np.abs(c)) + epsilon # penalty multiplier

# Construct the quadratic Hamiltonian J
J = rho*np.matmul(A.T,A)

# Construct the linear Hamiltonian C
C = c.copy()  
C -= rho*2*(np.matmul(b.T,A))

# Construct the offset term cQ
cQ = rho*np.matmul(b.T,b)

# --- Formatted Printing ---
print("\n" + "="*60)
print("--- QUBO Formulation Parameters ---")
# Set numpy print options for better readability
np.set_printoptions(precision=2, suppress=True)

print("\nQuadratic Hamiltonian (J):\n", J)
print("\n" + "-"*60)
print("\nLinear Hamiltonian (C):\n", C)
print("\n" + "-"*60)
print(f"\nOffset Term (cQ): {cQ:.2f}")
print("\n" + "="*60)

============================================================
--- QUBO Formulation Parameters ---

Quadratic Hamiltonian (J):
 [[ 48   0   0  48  48  48   0  48  48  48  48]
 [  0  48   0  48   0  48  48   0  48  48  48]
 [  0   0  48   0  48   0  48  48  48  48  48]
 [ 48  48   0  96  48  96  48  48  96  96  96]
 [ 48   0  48  48  96  48  48  96  96  96  96]
 [ 48  48   0  96  48  96  48  48  96  96  96]
 [  0  48  48  48  48  48  96  48  96  96  96]
 [ 48   0  48  48  96  48  48  96  96  96  96]
 [ 48  48  48  96  96  96  96  96 144 144 144]
 [ 48  48  48  96  96  96  96  96 144 144 144]
 [ 48  48  48  96  96  96  96  96 144 144 144]]

------------------------------------------------------------

Linear Hamiltonian (C):
 [ -94  -92  -92 -188 -188 -188 -187 -188 -283 -282 -283]

------------------------------------------------------------

Offset Term (cQ): 144.00

============================================================

Since the QuadraticModel cannot handle the offset term cQc_Q directly, we will store it separately and add it to the final energy value returned by the solver to get the true objective function value.

model = QuadraticModel(C, J)
model.upper_bound = np.ones((11, )) # Set the upper bound for the variables

solver = Dirac3IntegerCloudSolver(url=api_url, api_token=api_token)
Q = model.qubo.Q

response = solver.solve(model, num_samples=10) # qubomodel

solution = model.decode(np.array(response["results"]["solutions"][0]), "qubo")

true_solution = model.evaluate(solution) + cQ # add the offset term to get the true objective value

print(f"Decoded Solution (x): {solution}")
print(f"Objective Value (E): {true_solution}")
2025-08-18 14:46:21 - Dirac allocation balance = 9645 s (unmetered)
2025-08-18 14:46:21 - Job submitted: job_id='68a374fd8060c9339796334a'
2025-08-18 14:46:21 - QUEUED
2025-08-18 14:46:24 - RUNNING
2025-08-18 14:46:26 - COMPLETED
2025-08-18 14:46:29 - Dirac allocation balance = 9645 s (unmetered)
Decoded Solution (x): [0 0 0 0 0 0 0 0 0 0 1]
Objective Value (E): 5
# Plot the distribution of unique solutions
plot_solution_distribution(response)
<Figure size 800x700 with 1 Axes>

Solving the Problem in Constrained Polynomial Form

We can now define the problem using the ConstrainedPolynomialModel class, which will automatically convert the linear constraints into a penalty function and combine them with the original objective. This approach avoids the need to manually construct a QUBO matrix.

To use this model, we provide the objective and constraints separately:

  • coefficients and indices: These define the original linear objective function, cTx\mathbf{c}^T \mathbf{x} . The coefficients variable holds the vector c of costs, while indices specifies the variable for each corresponding coefficient.

  • lhs and rhs: These define the linear equality constraints, Ax=b\mathbf{A} \mathbf{x}=\mathbf{b}. The lhs variable is the left-hand-side matrix A, and rhs is the right-hand-side vector b.

coefficients = c
num_vars = 11
indices = [[0, i + 1] for i in range(num_vars)]

lhs = A
rhs = b

# Create the constrained polynomial model
constraint_model = ConstrainedPolynomialModel(coefficients, indices, lhs, rhs)
constraint_model.upper_bound = np.ones(num_vars, ) # Set the upper bound for the variables
# Set the penalty multiplier for the constraints
# This is a crucial step to ensure the constraints are satisfied in the optimization process.
constraint_model.penalty_multiplier = np.sum(np.abs(c)) + 1  # Set the penalty multiplier
print(constraint_model.offset) # Offse term generated by the model

solver = Dirac3IntegerCloudSolver(url=api_url, api_token=api_token)
response = solver.solve(model, num_samples=10)
3
2025-08-18 14:46:30 - Dirac allocation balance = 9645 s (unmetered)
2025-08-18 14:46:30 - Job submitted: job_id='68a375068060c9339796334b'
2025-08-18 14:46:30 - QUEUED
2025-08-18 14:46:33 - RUNNING
2025-08-18 14:46:38 - COMPLETED
2025-08-18 14:46:41 - Dirac allocation balance = 9645 s (unmetered)
# plot the distribution of unique solutions
plot_solution_distribution(response)

# Extract the best solution and its objective value
best_solution = response["results"]["solutions"][0]
best_objective_value = response["results"]["energies"][0] + constraint_model.offset * constraint_model.penalty_multiplier 
# Add the offset term to get the true objective value

print(f"Best Solution: {best_solution}")
print(f"Best Objective Value: {best_objective_value}")
<Figure size 800x700 with 1 Axes>
Best Solution: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
Best Objective Value: 5

References