Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
The **Quantum Simulation Environment (QSE)** is a flexible, modular, high-level Python library designed to
decouple the essence of the quantum simulation problem from the technicalities of the backend software/hardware.

>> [!important] This project is under active development.
> [!important] This project is under active development.

## Architectural Overview

Expand Down Expand Up @@ -68,7 +68,7 @@ simulation.
- [myQLM](https://myqlm.github.io/), and
- [Qutip](https://qutip.org/)

## 🎯 The Philosophy: Separation of Concerns
## The Philosophy: Separation of Concerns

The core value of QSE is the strict separation between **Problem Framing** and **Problem Execution**

Expand All @@ -77,18 +77,19 @@ The core value of QSE is the strict separation between **Problem Framing** and *
| Problem Framing | `Qbits` & `Lattices` | Defining geometry, positions, and quantum degrees of freedom. |
| Backend Execution | `Calculators` | Handling SDK-specific syntax, hardware constraints, and simulators. |

>> [!IMPORTANT] Why this matters
>> 1. **Backend Agnostic:** Frame your problem once; simulate it on Pulser, myQLM, or QuTiP just by switching one line of code.
>> 2. **No More "Jargon":** You don't need to learn the specific pulse sequences or gate-level syntax of every vendor to get started. You focus on the lattice and the physics.
>> 3. **Reproducibility:** Your problem definition remains a "clean" representation of the physical model, making it easier to share and verify across different research groups.
> [!IMPORTANT] Why this matters
> 1. **Backend Agnostic:** Frame your problem once; simulate it on Pulser, myQLM, or QuTiP just by switching one line of code.
> 2. **No More "Jargon":** You don't need to learn the specific pulse sequences or gate-level syntax of every vendor to get started. You focus on the lattice and the physics.
> 3. **Reproducibility:** Your problem definition remains a "clean" representation of the physical model, making it easier to share and verify across different research groups.

## 📍Position-Dependent Quantum Degrees of Freedom
## Position-Dependent Quantum Degrees of Freedom

Unlike standard gate-based frameworks where qubits are abstract entities in a register, QSE treats qubits as physical objects with coordinates. This is crucial for simulations where the interaction strength between qubits is a function of their spatial separation.

### Why Positions Matter

In many physical implementations of quantum simulators—such as Rydberg Atom Arrays or Trapped Ions—the Hamiltonian of the system is governed by the distance $R_{ij}​=|{\bf R}_i - {\bf R}_j|$ between qubits $i$ and $j$:
In many physical implementations of quantum simulators—such as Rydberg Atom Arrays or Trapped Ions—the Hamiltonian of the system is governed by
the distance $R_{ij}​=|{\bf R}_i - {\bf R}_j|$ between qubits $i $ and $j $:

$$
H = \sum_i \Omega_i\sigma_i^x - \sum_i \delta_i n_i + \sum_{i<j}{C_6 \over |{\bf R}_i - {\bf R}_j|^6} n_i n_j
Expand Down
16 changes: 14 additions & 2 deletions qse/calc/calculator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pathlib

import qse.magnetic as magnetic
from qse.calc.results import BaseResult


class Parameters(dict):
Expand Down Expand Up @@ -122,6 +123,17 @@ def calculate(self):
"""
raise NotImplementedError

@property
def results(self) -> BaseResult:
"""Common getter to safely access the attached result."""
if self._results is None:
raise ValueError("No results available. Run calculate() first.")
return self._results

def reset(self):
"""Reset the calculator. Free up the data."""
self._results = None

def get_spins(self):
"""
Get spin expectation values.
Expand All @@ -139,7 +151,7 @@ def get_spins(self):
if self.results is None:
self.calculate()

return magnetic.get_spins(self.statevector, len(self.qbits))
return magnetic.get_spins(self.results.statevector, len(self.qbits))

def get_sij(self):
r"""
Expand All @@ -158,7 +170,7 @@ def get_sij(self):
if self.results is None:
self.calculate()

sij = magnetic.get_sisj(self.statevector, len(self.qbits))
sij = magnetic.get_sisj(self.results.statevector, len(self.qbits))
self.sij = sij # quick fix. TODO: proper property setup done
return sij

Expand Down
127 changes: 117 additions & 10 deletions qse/calc/pulser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,76 @@
except ImportError:
CALCULATOR_AVAILABLE = False

from functools import partial
from typing import Any, Dict, Generator

import numpy as np

from qse.calc.results import SimResult


# Function to extract metadata from Pulser's simulation
def extract_safe_metadata(obj, backend_name: str) -> dict:
"""
Dynamically sweep an object for primitive attributes to build metadata.
Ignore callables, complex objects, and dunder methods.
TODO: Need to investigate some _ functions that give useful info.
"""
metadata = {"backend": backend_name}

for attr in dir(obj):
# Optionally skip dunders, but Pulser keeps good stuff in _basis_name
if attr.startswith("__"):
continue

try:
val = getattr(obj, attr)
# Only extract basic serializable types
if isinstance(val, (int, float, str, bool, tuple)):
# Clean up the key name (e.g., '_basis_name' -> 'basis_name')
clean_key = attr.lstrip("_")
metadata[clean_key] = val
except Exception:
pass # Ignore properties that raise errors on access
return metadata


# ==========================================
# GLOBAL EXTRACTORS (Pure Functions)
# ==========================================
# These functions know nothing about the Calculator.
# They only know how to translate a Pulser result into QSE format.


def extract_pulser_statevector(raw_results: Any) -> np.ndarray:
"""Extracts the final dense complex array."""
qobj = raw_results.get_final_state()
state_array = qobj.full().flatten()
# this is equivalent to earlier flipping based on
# the channel name == "rydberg_global"
if getattr(raw_results, "_basis_name", "") == "ground-rydberg":
state_array = state_array[::-1]
return state_array


def extract_pulser_counts(raw_results: Any, shots: int) -> Dict[str, int]:
"""Samples the final state hardware style."""
return dict(raw_results.sample_final_state(N_samples=shots))


def extract_pulser_expectation(raw_results: Any, observable: Any) -> float:
"""Delegates expectation value calculations to QuTiP."""
exp_array = raw_results.expect([observable])[0]
return float(exp_array[-1])


def extract_pulser_states(raw_results: Any) -> Generator[np.ndarray, None, None]:
"""Yields the statevector at every intermediate time step."""
for qobj in raw_results.states:
yield qobj.full().flatten()


# The calculator
class Pulser(Calculator):
r"""
QSE-Calculator for pulser.
Expand Down Expand Up @@ -118,7 +187,7 @@ def __init__(
self.device = pulser.devices.MockDevice if device is None else device
self.emulator = QutipEmulator if emulator is None else emulator
self.wtimes = wtimes
self.results = None
self._results = None
self.channel = channel
self.magnetic_field = magnetic_field

Expand Down Expand Up @@ -158,6 +227,14 @@ def register(self):
def sequence(self):
return self._sequence

@property
def results(self) -> SimResult:
"""
By simply overriding the property signature and calling super(),
Pylance instantly knows this branch returns a SimResult.
"""
return super().results # type: ignore

def build_sequence(self):
"""
Build the sequence of operations involving the qubit coordinates,
Expand All @@ -182,7 +259,7 @@ def build_sequence(self):
def sim(self):
return self._sim

def calculate(self, progress=True):
def calculate(self, progress=True) -> SimResult:
"""
Run the calculation.
"""
Expand All @@ -191,21 +268,51 @@ def calculate(self, progress=True):
if self.wtimes:
t1 = time()

self.results = self.sim.run(progress_bar=progress)

final_state = self.results.get_final_state()
# Execute the pulser backend
raw_results = self.sim.run(progress_bar=progress)

# Extract metadata from self.sim
metadata = extract_safe_metadata(self.sim, "pulser")
# metadata = {
# "backend": "pulser",
# "basis": getattr(self.sim, "basis_name", "unknown"),
# "total_duration_ns": getattr(self.sim, "total_duration_ns", -1),
# "n_time_steps": len(self.sim.evaluation_times),
# "has_noise": hasattr(self.sim, "noise_model")
# and self.sim.noise_model is not None,
# "noise_model": getattr(self.sim, "noise_model", "unknown"),
# }

# bind local result to the global extractor functions using "partial"
# This creates new functions/callables that already have results loaded
# as their first argument.

bind_statevector = partial(extract_pulser_statevector, raw_results)
bind_counts = partial(extract_pulser_counts, raw_results)
bind_expectation = partial(extract_pulser_expectation, raw_results)
bind_states = partial(extract_pulser_states, raw_results)

# In the qutip backend pulser uses the convention of 0 (1) being
# the excited (ground) state. Hence we must reverse the state vector.
self.statevector = final_state.full().flatten()
if self.channel == "rydberg_global":
self.statevector = self.statevector[::-1]
# at the moment there does not seem an effective or better alternative than
self.spins = self.get_spins()
# self.spins = self.get_spins()

if self.wtimes:
t2 = time()
print(f"time in compute and simulation = {t2 - t1} s.")
exec_time = t2 - t1
metadata["exec_time"] = exec_time
print(f"time in compute and simulation = {exec_time} s.")

# Return the clean SimResult
# SimResult can now call bound_statevector() with zero arguments!
self._results = SimResult(
statevector_func=bind_statevector,
counts_func=bind_counts,
expectation_func=bind_expectation,
states_generator=bind_states,
metadata=metadata,
)
return self.results


def _format_pulse(pulse):
Expand Down
Loading
Loading