Quantum implementation
How one day of battery decisions becomes a quantum circuit, and how that circuit runs, first on a simulator and then on IBM's real machines. Each step shows the actual code and what it does, so you can follow the whole path without opening GitHub.
Before you start
Everything here except the last step runs on an ordinary laptop, for free. You need Python 3. The install below brings in the whole environment: numpy for the arithmetic, Qiskit and Qiskit Aer for building and simulating circuits, and scipy, matplotlib, jupyter and pytest for the rest.
terminal
git clone https://github.com/austinamissah/quantum-solar
cd quantum-solar
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # qiskit, qiskit-aer, numpy, scipy, matplotlib, jupyter, pytest
pip install -e . --no-deps # install the quantum_solar package (src layout)The classical solvers need less than that. If you only want the exact answers, without the quantum path, numpy is the only dependency: pip install numpy and then the --no-deps line on its own. Importing quantum_solar pulls in no quantum stack; qiskit loads the first time you ask for it.
Step 1
A day is a small bundle of numbers: per time slot, the electricity price, the solar generation and the household's use, plus the battery's size, how much it can take in or give out per slot, and how full it starts. To keep every step visible I use a made-up day with 3 slots. The real Golden, Colorado day runs the same steps with more slots.
python
from quantum_solar import synthetic_instance
problem = synthetic_instance(num_slots=3, seed=1)
print(problem.price) # $/kWh in each slot
print(problem.load) # kWh the home uses
print(problem.generation) # kWh the panels produceStep 2
Each slot gets two yes/no bits: charge this hour, and discharge this hour. The day's cost then becomes one formula over those bits, a QUBO. Rules are enforced by penalties inside the same formula: charging and discharging at once, or overfilling the battery, adds so much cost that no rule-breaking plan can look cheapest. Keeping the battery inside its limits needs a few extra bookkeeping bits, called slack bits. For this 3-slot day that is 10 bits: 6 decision bits and 4 slack bits.
Those bookkeeping bits are the expensive part, and they grow with both the number of slots and the battery's capacity: a full 24-hour day costs 117 bits, 69 of them bookkeeping. The state-of-charge penalty is now pluggable, and a checkpoint encoding that checks the level every fifth hour rather than every hour does the same day on 52 bits with no loss of value. This 3-slot walkthrough keeps the exact encoding, since it is the one everything else is graded against.
Encoding constraints without slack bits is an active area with published methods. I arrived at this one from the structure of my own problem rather than from that work.
python
from quantum_solar import build_qubo, default_weights
qubo = build_qubo(problem, default_weights(problem))
print(qubo.num_vars) # 10 bits for the 3-slot dayStep 3
Quantum hardware does not minimize formulas. It minimizes energy. There is a standard, exact translation: each bit becomes a qubit, and the QUBO becomes an energy rule (physicists call it an Ising Hamiltonian) whose lowest-energy state is the cheapest schedule. Nothing is lost. It is the same problem in the machine's language.
python
from quantum_solar import qubo_to_ising
hamiltonian, constant = qubo_to_ising(qubo)Step 4
The circuit is QAOA, and it has a simple shape. Reading it like a recipe:
One round only nudges the odds a little. Each extra layer nudges them again, so in theory more layers make the cheap schedules come out more often when you measure. But layers have two prices. Each one adds dials that have to be tuned by trial and error, so a badly tuned deep circuit can lose to a well-tuned shallow one; my hardware results showed exactly that, with the ordering reflecting tuning rather than depth. And on a real quantum computer, every layer adds physical operations, each adding a little noise, so past some point extra layers hurt more than they help. (On a simulator, extra layers only cost computing time, because a simulator makes no errors.)
There is a third price, and I learned it the hard way: how hard the circuit is to tune depends on a setting I had chosen badly. If the penalty for breaking a rule dwarfs the entire spread of prices you are trying to compare, the circuit spends its effort finding legal plans and almost none finding cheap ones. Mine was about 48 times too large. Correcting it, with the circuit and the tuning untouched, improved the result by a factor of several hundred, so some of what looked like “deeper circuits are hard to tune” was actually “the problem was posed badly.”
Each pair of layers has two dials (angles). The circuit does nothing useful until the dials are tuned; that is the next step. Qiskit builds the whole circuit from the energy rule in one line:
python
from qiskit.circuit.library import QAOAAnsatz
ansatz = QAOAAnsatz(cost_operator=hamiltonian, reps=2)
print(ansatz.num_parameters) # 4 dials: two per layerStep 5
Tuning is a loop between a quantum circuit and an ordinary optimizer: run the circuit with the current dial settings, estimate the average energy of what comes out, let the optimizer (COBYLA, a standard numerical method) nudge the dials, and repeat, up to 200 times, restarting from 5 random starting points to avoid getting stuck. My solver runs that whole loop with one call, on the free Aer simulator, on a laptop.
A simulator is an ordinary computer doing the math a perfect, noise-free quantum computer would do: it gives the ideal answer, but the work doubles with every qubit. A real machine is fast per run but noisy, so some of the ideal answer is lost; how much is exactly what the hardware run in Step 9 measured. The results below, through Step 8, are all simulator results. The technical page explains both machines in more depth.
python
from quantum_solar import QAOASolver
solver = QAOASolver(reps=2, n_starts=5, shots=4096, seed=1234)
result = solver.solve(problem, qubo)
print(f"cost ${result.true_energy:.3f} feasible={result.feasible}")
# cost $-0.026 feasible=TrueStep 6
Measuring a quantum circuit gives one random schedule each time (each measurement is called a shot), so the solver measures 4,096 times and gets back a tally: each observed bit-pattern and how often it appeared. Every pattern decodes to a schedule; the solver scores them all against the real cost formula and keeps the best one that obeys the rules. A well-tuned circuit makes the cheap schedules appear far more often than chance, which is exactly what the quantum-vs-random-guessing chart on the project page measures.
python
# result.counts is the raw tally from 4,096 measurements
for bits, count in sorted(result.counts.items(),
key=lambda kv: -kv[1])[:3]:
print(bits, count) # the three most frequent patternsStep 7
Because the day is small, two classical solvers can find the guaranteed best plan: brute force tries every plan, and dynamic programming computes it instantly. The quantum answer is graded against them. On this day, all three agree.
python
import numpy as np
from quantum_solar import brute_force_solve, dp_solve
brute = brute_force_solve(problem, qubo)
dp = dp_solve(problem)
assert np.isclose(brute.true_energy, dp.true_energy)
assert np.isclose(result.true_energy, dp.true_energy)
print("all three solvers agree")Step 8
Everything so far used the made-up 3-slot day. One call replaces it with the real Golden, Colorado day: sunlight from NREL's PVWatts, Xcel Energy time-of-use prices from the Utility Rate Database, and the ResStock household profile (the sources and preparation steps are listed on the project page). You need one thing first: a free NREL API key from developer.nlr.gov, set as an environment variable.
terminal (one time)
export NREL_API_KEY=your_key_herepython
from quantum_solar.data import load_nrel_instance
# Golden, CO; day 172 of the year is near June 21
real = load_nrel_instance(lat=39.74, lon=-105.18, day=172)The result is the same kind of problem object as Step 1, so every step above works on it unchanged; it just has 24 hourly slots instead of 3, which means more qubits. Responses are cached on disk, so the network is only touched once. This is exactly the day behind the bill and the chart on the project page.
Step 9
The only step that leaves the laptop. It is free, and it is deliberately hard to trigger by accident.
First, the account. IBM Quantum's free Open Plan gives anyone about ten minutes of real machine time every month. Sign up at IBM Quantum's website, copy your API token (a long code that identifies your account), and save it once on your computer; Qiskit remembers it from then on.
python (one time)
from qiskit_ibm_runtime import QiskitRuntimeService
QiskitRuntimeService.save_account(token="YOUR_TOKEN")Then the run itself, which my project splits into three deliberate stages so the free minutes cannot be wasted:
terminal
# (a) tune the dials on the simulator; saves them to a file
python scripts/experiment_hardware.py optimize
# (b) dry run: shows the chosen machine, the circuits, the
# shot count, and the estimated seconds of machine time.
# Spends nothing.
python scripts/experiment_hardware.py submit
# (c) the real thing. Only this flag spends the free minutes.
python scripts/experiment_hardware.py submit --yes-spend-qpuThree details. By default the script asks IBM for the least busy machine that is currently up, so there is no hand-picking. The exception is a plan that calibrates a prediction against one specific machine; that plan pins the machine by name and refuses to run anywhere else rather than quietly substituting another. Only the final sampling happens on the real machine; all the tuning stayed on the simulator, so the hardware job is short. And the results come back in the same tally format as Step 6, so the same analysis applies, this time comparing the simulator's tally against the real machine's to measure how much physical noise changes the answer.
Where this stands
As of August 26, 2026 this has been run six times on a real machine, for 165 seconds of quantum-processor time in total. July 11: the four original circuits, where noise grew with size and only the smallest beat random guessing. August 3: the two encodings head to head, where the smaller one degraded measurably less, then an independent repeat of that comparison which came back the same, and a job running one circuit ten times back to back to measure the machine's own run-to-run variation. August 25 and 26: one circuit at one and two layers, where the deeper version won against my registered prediction of a dead heat, and a next-day replication that confirmed the win. Job IDs, raw counts, and the predictions written before each run are all in the repository and on the project page.
Keep going
Keep reading