Skip to content

Rydberg arrays

The model these functions implement, with its units and conventions, is specified in The Rydberg feature model.

hqnn_forge.rydberg

hqnn_forge.rydberg

A small simulated array of neutral atoms, evolving continuously in time under the Rydberg Hamiltonian. The model, its units and every convention are specified in docs/rydberg-model.md; this package implements it.

Units: ħ = 1, angular frequencies in rad/µs, lengths in µm, times in µs.

The matrices are dense and built in torch (float64 / complex128), without a PennyLane device: PennyLane's own qml.pulse.rydberg_interaction and rydberg_drive need JAX, which this library does not depend on.

Exported symbols

AtomRegister Atom positions in µm; chain and ring constructors, C6/r^6 couplings, blockade radius. rydberg_hamiltonian Dense 2^n x 2^n Hamiltonian of a register, for one or a batch of pulses. evolve ρ(t) from all atoms in |g⟩: exact without dephasing, Strang splitting with it. readout ⟨n_i⟩ of every atom, optionally ⟨n_i n_j⟩ of every pair, from the diagonal of ρ. PulseEncoding Inputs to bounded detunings, Δ_i = Δ_max · σ(x_i), with the fixed Ω and T. RydbergFeatureMap Fixed map from a batch of inputs to ⟨n_i⟩ (optionally ⟨n_i n_j⟩), exact or from shots. DEFAULT_C6 C6 of two 87Rb atoms in 70S, 2π × 862 690 MHz µm^6, in rad/µs · µm^6. MAX_ATOMS Largest register rydberg_hamiltonian, evolve, readout and the feature map accept (10).

DEFAULT_C6 module-attribute

DEFAULT_C6: float = 2 * math.pi * 862690.0

C6 of two ⁸⁷Rb atoms in 70S₁/₂, in rad/µs · µm⁶: 2π × 862 690 MHz µm⁶ = 5 420 441, positive (repulsive). It is the value fixed in docs/rydberg-model.md and the default of PennyLane's qml.pulse.rydberg_interaction (which takes it in MHz µm⁶). No function here defaults to it: c6 is always passed, so the atom is stated at the call.

MAX_ATOMS module-attribute

MAX_ATOMS: int = 10

Largest register rydberg_hamiltonian builds a matrix for. N atoms have 2^N basis states, so one Hamiltonian, like one density matrix, holds 4^N complex128 entries, 16 · 4^N bytes: 16 MiB at 10 atoms, and 4 times more for each further atom (1 GiB at 13), per sample of a batch. The model of docs/rydberg-model.md uses 4 to 6 atoms.

PulseEncoding

The map from inputs to bounded pulse parameters: Δ_i = Δ_max · σ(x_i).

One input per atom sets that atom's detuning through the logistic function σ(x) = 1/(1 + e^(−x)); the Rabi frequency Ω and the pulse duration T are fixed, the same for every atom and sample. Input column i gives the detuning of atom i. The module docstring states the conventions and derives what the defaults give.

Parameters:

Name Type Description Default
n_features int

Width of the inputs, an integer >= 1: the number of atoms.

required
omega float

Rabi frequency Ω in rad/µs, finite and positive (the coefficient of X in the Hamiltonian is Ω/2). Required: Ω sets the scale of the model, and docs/rydberg-model.md names 4π rad/µs as its reference value, not as a default.

required
t float | None

Pulse duration T in µs, finite and >= 0. Default: π/Ω, a π pulse (ΩT = π).

None
delta_max float | None

Upper bound Δ_max of the detunings in rad/µs, finite and positive; the lower bound is 0. Default: √3 Ω, the first zero of the single-atom feature at ΩT = π.

None

Attributes:

Name Type Description
n_features int
omega float
t float

The resolved duration, π/Ω if none was given.

delta_max float

The resolved bound, √3 Ω if none was given.

Raises:

Type Description
ValueError

If n_features is not an integer >= 1, omega or delta_max is not a finite number > 0, t is not a finite number >= 0, or omega is so small or so large that a default (π/Ω or √3 Ω) is not finite.

Examples:

>>> import math
>>> from hqnn_forge.rydberg import PulseEncoding
>>> encoding = PulseEncoding(2, omega=4 * math.pi)
>>> encoding.t  # ΩT = π
0.25
>>> round(encoding.delta_max / encoding.omega, 12)  # √3
1.732050807569
>>> (encoding.detunings([[0.0, math.log(3)]]) / encoding.delta_max).tolist()  # σ(x)
[[0.5, 0.75]]

n_features property

n_features: int

Width of the inputs: one per atom.

omega property

omega: float

Rabi frequency Ω in rad/µs.

t property

t: float

Pulse duration T in µs.

delta_max property

delta_max: float

Upper bound Δ_max of the detunings in rad/µs.

detunings

detunings(X: Any) -> torch.Tensor

The detunings Δ_i = Δ_max · σ(x_i) of a batch of inputs.

Parameters:

Name Type Description Default
X Any

Inputs of shape (n_samples, n_features), any real-valued array-like. They are converted to float64 and not modified.

required

Returns:

Type Description
Tensor

Shape (n_samples, n_features), float64, in rad/µs, on the device of X and without a gradient: entry [b, i] is the detuning of atom i for sample b.

Raises:

Type Description
TypeError

If X is complex or boolean.

ValueError

If X does not have shape (n_samples, n_features), or holds a NaN or an infinity (a non-finite input is an error, not a value to squash).

Notes

0 < Δ_i < Δ_max holds for every finite input in exact arithmetic. In float64, σ(x) rounds to 1 for x above about 37 and to 0 for x below about −700, where Δ_i equals a bound. The exact lower value depends on how the torch build evaluates σ: −709.8 where e^(−x) overflows, −745.2 where e^x itself rounds to 0.

get_config

get_config() -> dict[str, Any]

The constructor arguments, as a fresh JSON-serialisable dict.

t and delta_max are the resolved values, so PulseEncoding(**encoding.get_config()) is the same pulse whatever the defaults are.

RydbergFeatureMap

A fixed map from a batch of inputs to the excitation probabilities of a Rydberg array.

Each sample sets the detunings of the atoms (encoding), the array evolves for the pulse duration under the Hamiltonian of docs/rydberg-model.md and dephasing at rate γ, and the features are ⟨n_i⟩ of every atom, optionally followed by ⟨n_i n_j⟩ of every pair. The module docstring gives the pipeline, the conventions (input i feeds atom i, the pair order) and the closed form without interactions.

The map has no trainable parameters and nothing to fit: it is a preprocessing step, like PCANormalizer, and not an encoding layer.

Parameters:

Name Type Description Default
register Any

The atoms' positions: an AtomRegister, or its positions in µm, shape (n_atoms, 2), as get_config records them. 1 to MAX_ATOMS atoms.

required
encoding PulseEncoding | Mapping[str, Any]

The PulseEncoding (detuning bound, Ω and T), or its get_config() dict. Its n_features must equal the number of atoms.

required
c6 float

The C6 coefficient in rad/µs · µm⁶, e.g. DEFAULT_C6. Required, also with interactions=False.

required
gamma float

Dephasing rate γ in rad/µs, >= 0: the rate of the local collapse operators √γ n_i, so a single-atom coherence decays at γ/2. Required; 0.0 is the noiseless map.

required
n_steps int | None

Number of splitting steps of the solver, an integer >= 1. Required when gamma > 0, without a default (see evolve on how to choose it); not used when gamma = 0, where the evolution is exact. It is passed unchanged to every solver call and never derived from the data, so the features of a sample do not depend, beyond rounding, on which other samples it is processed with (see transform).

None
interactions bool

False drops the term Σ_{i<j} V_ij n_i n_j and nothing else: the non-interacting control, in which feature i depends on input i alone.

True
correlations bool

Append the pair correlations ⟨n_i n_j⟩, i < j, in the order (0,1), (0,2), …, (N−2,N−1).

False

Attributes:

Name Type Description
n_features_in int

Width of the inputs, the number of atoms N.

n_features_out int

Width of the features: N, or N + N(N−1)/2 with correlations=True.

evolution_time float

The pulse duration T in µs, encoding.t.

register, encoding, c6, gamma, n_steps, interactions, correlations

The settings, read-only.

Raises:

Type Description
TypeError

If encoding is neither a PulseEncoding nor a mapping, is a mapping with a key PulseEncoding does not take or without one it requires (n_features, omega), or interactions or correlations is not a bool.

ValueError

If the register has more than MAX_ATOMS atoms or another number of atoms than encoding.n_features; if c6 is not a finite number or gamma not a finite number >= 0; if n_steps is not an integer >= 1, or is missing with gamma > 0; or if positions or an encoding config are refused by AtomRegister or PulseEncoding.

Examples:

>>> import math
>>> import torch
>>> from hqnn_forge.rydberg import DEFAULT_C6, AtomRegister, PulseEncoding, RydbergFeatureMap
>>> omega = 4 * math.pi
>>> spacing = AtomRegister.blockade_radius(DEFAULT_C6, omega)  # V = Ω
>>> control = RydbergFeatureMap(
...     AtomRegister.chain(4, spacing),
...     PulseEncoding(4, omega=omega),
...     c6=DEFAULT_C6,
...     gamma=0.0,
...     interactions=False,
... )
>>> X = torch.tensor([[-40.0, 0.0, 0.0, 40.0]])
>>> [round(v, 3) for v in control.transform(X)[0].tolist()]  # f(1), f(√1.75), ·, f(2)
[1.0, 0.437, 0.437, 0.0]
>>> noisy = RydbergFeatureMap(
...     AtomRegister.chain(4, spacing),
...     PulseEncoding(4, omega=omega),
...     c6=DEFAULT_C6,
...     gamma=omega,
...     n_steps=200,
...     correlations=True,
... )
>>> noisy.n_features_out
10
>>> rebuilt = RydbergFeatureMap(**noisy.get_config())
>>> torch.equal(rebuilt.transform(X), noisy.transform(X))
True

register property

register: AtomRegister

The atoms' positions.

encoding property

encoding: PulseEncoding

The map from inputs to detunings, with Ω and T.

c6 property

c6: float

The C6 coefficient in rad/µs · µm⁶.

gamma property

gamma: float

Dephasing rate γ in rad/µs.

n_steps property

n_steps: int | None

Splitting steps of the solver; None only when gamma = 0.

interactions property

interactions: bool

Whether the Hamiltonian keeps its n_i n_j term.

correlations property

correlations: bool

Whether the pair correlations are appended to the features.

n_features_in property

n_features_in: int

Width of the inputs: the number of atoms.

n_features_out property

n_features_out: int

Width of the features: N, or N + N(N−1)/2 with correlations.

evolution_time property

evolution_time: float

The pulse duration T in µs.

transform

transform(X: Any, *, shots: int | None = None, generator: Generator | None = None, chunk_size: int | None = None) -> torch.Tensor

The features of a batch of inputs.

Parameters:

Name Type Description Default
X Any

Inputs of shape (n_samples, n_features_in), any real-valued array-like; column i sets the detuning of atom i.

required
shots int | None

None (default): exact expectation values. An integer S >= 1: each feature is estimated from S bitstrings sampled from the diagonal of that sample's ρ, as the mean of b_i (and of b_i b_j). The estimate is unbiased with standard error √(F(1 − F)/S) ≤ 1/(2√S) for a feature of value F. There is no default number of shots.

None
generator Generator | None

The torch.Generator the bitstrings are drawn with. Required when shots is given, so that every estimate comes from a stated source of randomness; it is advanced by the call, and a generator with the same seed repeats the estimates. Not used with shots=None.

None
chunk_size int | None

Samples evolved at a time, an integer >= 1. Default: as many as hold 2^20 matrix entries, max(1, 2^20 / 4^N). It changes the result by rounding only (see the Notes).

None

Returns:

Type Description
Tensor

Shape (n_samples, n_features_out), float64, on the device of X, without a gradient: ⟨n_0⟩, …, ⟨n_{N−1}⟩ and, with correlations=True, the pairs in the order (0,1), (0,2), …, (N−2,N−1).

Raises:

Type Description
TypeError

If X is complex or boolean, or generator is not a torch.Generator.

ValueError

If X does not have shape (n_samples, n_features_in) or holds a NaN or an infinity; if shots or chunk_size is not an integer >= 1 or None; or if shots is given without a generator.

Notes
  • Memory. The samples are evolved chunk_size at a time and the features written into one preallocated result, so the working memory is that of one chunk: the solver holds a handful of complex128 tensors of chunk_size · 4^N entries (16 · 4^N bytes per sample each; 16 MiB each at the default).
  • Chunked and unchunked results are equal to rounding, not bit for bit. Every sample is evolved with the same T, γ and n_steps whatever the chunk, and bitstrings are drawn sample by sample in order, so no setting and no random draw depends on chunk_size. What is left is the rounding of the solver's batched matrix products, which a linear-algebra backend may sum in another order for another batch size. Measured on a CPU, features and states of two chunkings do not differ at all up to 4 atoms; from 5 atoms on they differ by up to 1e-15 at gamma = 0 and by up to 2e-16 per solver step with dephasing (3e-13 at 2000 steps), the size of the rounding error either result has anyway, and orders of magnitude below the splitting error. The same holds for states. With shots the drawn bitstrings were the same in every chunking tried, and the estimates are the correctly rounded value of count / S, the same bit for bit in every chunking as long as the drawn bitstrings are the same. For bit-for-bit repeatable features without shots, keep chunk_size (and the machine) fixed.
  • Precision. X is converted to float64 whatever its dtype. With shots, a population that rounding left slightly below 0 is taken as 0.

states

states(X: Any, *, chunk_size: int | None = None) -> torch.Tensor

The density matrices ρ(T) the features are read from.

readout(feature_map.states(X), pairs=feature_map.correlations) equals feature_map.transform(X).

Parameters:

Name Type Description Default
X Any

Inputs of shape (n_samples, n_features_in), as for transform.

required
chunk_size int | None

Samples evolved at a time, as for transform: it changes the states by rounding only. It bounds the solver's working memory, not the result, which holds n_samples · 4^N entries.

None

Returns:

Type Description
Tensor

Shape (n_samples, 2^N, 2^N), complex128, on the device of X, without a gradient. Entry [b, k, l] is ⟨k|ρ|l⟩ of sample b in the basis of rydberg_hamiltonian (atom 0 the most significant bit of the index).

Raises:

Type Description
TypeError

If X is complex or boolean.

ValueError

If X does not have shape (n_samples, n_features_in) or holds a NaN or an infinity, or chunk_size is not an integer >= 1 or None.

get_config

get_config() -> dict[str, Any]

Every setting of the map, as a fresh JSON-serialisable dict.

The keys are the constructor's arguments, so type(m)(**m.get_config()) builds a map with the same features, also after a round trip through JSON:

register The positions in µm, a list of [x, y] per atom. encoding PulseEncoding.get_config(): n_features, Ω (omega), T (t) and the bound delta_max, in rad/µs and µs. c6, gamma, n_steps, interactions, correlations As passed; n_steps is the solver's step count, None if none was given (gamma = 0).

The number of shots and the chunk size are arguments of transform, not settings of the map, and are not recorded.

AtomRegister

The positions of the atoms of an array, in µm, in a plane.

Atoms are counted from 0 in the order of the rows of positions. That order is the order of everything derived from the register: row and column i of interaction_matrix, and tensor factor (PennyLane wire) i of rydberg_hamiltonian.

Parameters:

Name Type Description Default
positions Any

Shape (n_atoms, 2), one (x, y) row per atom, in µm. Any real-valued array-like; it is copied and stored as float64. The positions are constants: the copy carries no graph, so a tensor that requires grad is refused instead of losing its gradient unnoticed.

required

Raises:

Type Description
TypeError

If positions is complex or boolean.

ValueError

If positions does not have shape (n_atoms, 2) with at least one atom, is a tensor that requires grad, holds a NaN or an infinity, or places two atoms at the same point (their coupling C6 / 0 would be infinite).

Notes

Only exact duplicates are refused. How close two atoms may sensibly be is a statement about the atom, not the geometry: the C6 / r⁶ form is a long-distance one, and docs/rydberg-model.md does not use it below 4 µm. The register has no upper limit on the number of atoms; the dense Hamiltonian has (MAX_ATOMS).

Examples:

>>> register = AtomRegister.chain(3, spacing=5.0)
>>> register.positions.tolist()
[[0.0, 0.0], [5.0, 0.0], [10.0, 0.0]]
>>> v = register.interaction_matrix(c6=15625.0)  # V = C6 / 5^6 = 1
>>> v[0, 1].item(), v[0, 2].item()
(1.0, 0.015625)

positions property

positions: Tensor

A copy of the positions: float64, shape (n_atoms, 2), in µm.

n_atoms property

n_atoms: int

Number of atoms.

chain classmethod

chain(n_atoms: int, spacing: float) -> AtomRegister

An open chain: atom i at r_i = (i · spacing, 0).

This is the geometry of docs/rydberg-model.md. Atoms i and j are |i − j| · spacing apart, so with V = C6 / spacing⁶ the nearest-neighbour coupling, V_ij = V / |i − j|⁶.

Parameters:

Name Type Description Default
n_atoms int

Number of atoms, at least 1.

required
spacing float

Distance between neighbouring atoms in µm, finite and positive.

required

Raises:

Type Description
ValueError

If n_atoms is not an integer >= 1 or spacing is not a finite number > 0.

ring classmethod

ring(n_atoms: int, spacing: float) -> AtomRegister

A closed ring: the corners of a regular polygon of side spacing.

Atom i sits at R · (cos θ_i, sin θ_i) with θ_i = 2π i / n, counterclockwise from the positive x axis around the origin. Two atoms k steps apart around the ring subtend the angle 2π k / n and are the chord 2 R sin(π k / n) apart. Setting the k = 1 chord to spacing fixes the radius::

R = spacing / (2 sin(π / n))

so every atom has both ring neighbours at spacing, including atoms n − 1 and 0. For n = 2 this is two atoms spacing apart (R = spacing / 2). For n = 1 there is no chord and the formula has no finite value; the single atom is placed at the origin.

Parameters:

Name Type Description Default
n_atoms int

Number of atoms, at least 1.

required
spacing float

Distance between neighbouring atoms in µm (the chord, not the arc), finite and positive.

required

Raises:

Type Description
ValueError

If n_atoms is not an integer >= 1 or spacing is not a finite number > 0.

interaction_matrix

interaction_matrix(c6: float) -> torch.Tensor

The van der Waals couplings V_ij = C6 / |r_i − r_j|⁶ of every pair.

With d_ij = r_i − r_j the squared distance is |d_ij|² = d_ij,x² + d_ij,y², and |d_ij|⁶ = (|d_ij|²)³, which is how it is computed (no square root). d_ij = −d_ji enters only squared, so the matrix is symmetric exactly, not just to rounding. The diagonal is set to 0: an atom does not interact with itself, and the Hamiltonian sums over pairs i < j only.

Parameters:

Name Type Description Default
c6 float

The C6 coefficient in rad/µs · µm⁶. Positive is repulsive (it raises the energy of a doubly excited pair), as for DEFAULT_C6; the sign is kept.

required

Returns:

Type Description
Tensor

Shape (n_atoms, n_atoms), float64, in rad/µs: entry [i, j] couples atoms i and j.

Raises:

Type Description
ValueError

If c6 is not a finite number, or a coupling is not finite because two atoms are too close for float64.

blockade_radius staticmethod

blockade_radius(c6: float, omega: float) -> float

The blockade radius R_b = (C6/Ω)^(1/6), in µm.

It is the distance at which the coupling equals the Rabi frequency: C6 / R_b⁶ = Ω. Closer pairs have V_ij > Ω and cannot both be excited by a resonant drive; so V/Ω = (R_b/a)⁶ for a pair at distance a. The radius depends on the atom and the drive, not on the positions, so it can be called on the class as well as on a register. For DEFAULT_C6 and Ω = 4π rad/µs it is 8.69 µm.

Parameters:

Name Type Description Default
c6 float

The C6 coefficient in rad/µs · µm⁶, positive.

required
omega float

The Rabi frequency Ω in rad/µs, positive (the coefficient of X in the Hamiltonian is Ω/2).

required

Returns:

Type Description
float

R_b in µm.

Raises:

Type Description
ValueError

If c6 or omega is not a finite number > 0.

rydberg_hamiltonian

rydberg_hamiltonian(register: AtomRegister, omega: Any, delta: Any, *, c6: float, interactions: bool = True) -> torch.Tensor

The dense Rydberg Hamiltonian of a register, for one or a batch of pulses.

::

H = Σ_i Ω_i/2 · X_i  −  Σ_i Δ_i n_i  +  Σ_{i<j} C6/|r_i − r_j|⁶ · n_i n_j

with n_i = |r⟩⟨r|_i, in rad/µs. The derivation of every matrix element, the sign conventions and the basis ordering (atom i is tensor factor and PennyLane wire i, atom 0 the most significant bit of the index) are in the module docstring.

Parameters:

Name Type Description Default
register AtomRegister

The atoms' positions. N = register.n_atoms may be 1 to MAX_ATOMS.

required
omega Any

Rabi frequency Ω in rad/µs: a scalar (one global drive), or one value per atom with the atoms on the last axis. Accepted shapes are (), (N,), (batch, N) and (batch, 1), the last being one global Ω per sample. A one-dimensional omega is always read along the atoms, never along the batch, so it has length N: shape (batch,) raises unless batch == N, also for a batch of 1. The sign is not restricted; a negative Ω is a drive of phase π.

required
delta Any

Detuning Δ_i in rad/µs, one per atom: shape (N,), or (batch, N) for a batch.

required
c6 float

The C6 coefficient in rad/µs · µm⁶, e.g. DEFAULT_C6. Required, and checked, also with interactions=False, so that the control is the same call with one flag changed.

required
interactions bool

False drops the term Σ_{i<j} V_ij n_i n_j and nothing else. The result is then the Kronecker sum of the single-atom Hamiltonians [[0, Ω_i/2], [Ω_i/2, −Δ_i]]: the non-interacting control (V_ij = 0 exactly, not a large spacing).

True

Returns:

Type Description
Tensor

complex128, on the device of delta. Shape (2^N, 2^N) if neither omega nor delta has a batch axis, else (batch, 2^N, 2^N) with sample b built from omega[b] and delta[b]; an argument without a batch axis is shared by all samples. The imaginary part is zero.

Raises:

Type Description
TypeError

If register is not an AtomRegister, or omega or delta is complex or boolean.

ValueError

If the register has more than MAX_ATOMS atoms; if delta or omega has another shape than those above or their batch sizes differ (a batch of 1 is not broadcast to another batch size); if either holds a NaN or an infinity; or if c6 is not a finite number.

Notes
  • Precision. omega and delta are converted to float64 whatever their dtype, and the result is complex128.
  • Gradients. The matrix is linear in omega and delta and built with differentiable operations: ∂H/∂Δ_i = −n_i and ∂H/∂Ω_i = X_i/2 reach tensors that require them. The positions and c6 are constants.
  • Batched and unbatched calls agree bit for bit. Each diagonal entry is accumulated atom by atom in the same order either way, so sample b of a batched call equals the unbatched call on omega[b] and delta[b] exactly.
  • Drive phase. With a phase φ the drive term reads Ω/2 · Σ_i (cos φ X_i − sin φ Y_i) (the trainable analog layer, #420). This function is the case φ = 0. Everything after delta is keyword-only, so a phase argument defaulting to 0 can be added without changing any existing call.

Examples:

>>> import torch
>>> from hqnn_forge.rydberg import AtomRegister, rydberg_hamiltonian
>>> one = AtomRegister([[0.0, 0.0]])
>>> rydberg_hamiltonian(one, 2.0, [0.5], c6=1.0).real.tolist()
[[0.0, 1.0], [1.0, -0.5]]
>>> pair = AtomRegister.chain(2, spacing=2.0)  # V = C6 / 2^6
>>> h = rydberg_hamiltonian(pair, 2.0, [0.5, 0.25], c6=64.0)
>>> h.real.diagonal().tolist()  # |gg>, |gr>, |rg>, |rr>
[0.0, -0.25, -0.5, 0.25]
>>> rydberg_hamiltonian(pair, 2.0, torch.zeros(8, 2), c6=64.0).shape
torch.Size([8, 4, 4])

evolve

evolve(hamiltonian: Tensor, t: float, *, gamma: float = 0.0, n_steps: int | None = None) -> torch.Tensor

The state ρ(t) of an array that starts with every atom in |g⟩.

Solves, for one or a batch of time-independent Hamiltonians::

dρ/dt = −i [H, ρ] + γ Σ_i ( n_i ρ n_i − ½ { n_i , ρ } ),    ρ(0) = |g…g⟩⟨g…g|

the Lindblad equation of docs/rydberg-model.md with the local collapse operators L_i = √γ n_i. The module docstring derives every formula used here, with the sign and ordering conventions.

  • gamma = 0: ρ(t) = ψψ† with ψ = e^(−iHt) |g…g⟩, exact, from one eigendecomposition per sample and without time stepping.
  • gamma > 0: n_steps steps of dt = t / n_steps of the symmetric (Strang) splitting e^(𝓛_D dt/2) · e^(𝓛_H dt) · e^(𝓛_D dt/2). The unitary step is ρ → U ρ U† with U = e^(−iH dt) from the same eigendecomposition; the dissipative step multiplies ρ_ab by exp(−γ τ d_H(a, b) / 2), d_H the Hamming distance of the basis states a and b. Both are exact; the splitting has a global error of second order in dt.

Parameters:

Name Type Description Default
hamiltonian Tensor

Shape (2^N, 2^N), or (batch, 2^N, 2^N) for a batch, in rad/µs, e.g. from rydberg_hamiltonian; N may be 1 to MAX_ATOMS. Hermitian, real or complex. Its basis ordering defines that of the result: index 0 is the initial state.

required
t float

Evolution time in µs, >= 0, the same for every sample.

required
gamma float

Dephasing rate γ in rad/µs, >= 0, the same for every atom and sample. It is the rate of the collapse operators √γ n_i: a single-atom coherence decays at γ/2 (T₂ = 2/γ), and ρ_ab at γ/2 · d_H(a, b).

0.0
n_steps int | None

Number of splitting steps, an integer >= 1. Required when gamma > 0 and without a default: the step that is accurate enough depends on γ and on the level spacings of H (the interaction, in the blockade), which this function does not guess. Not used when gamma = 0, where the result is exact.

None

Returns:

Type Description
Tensor

ρ(t), complex128, with the shape and device of hamiltonian. Entry [..., a, b] is ⟨a|ρ|b⟩. Trace 1, Hermitian and positive semidefinite up to rounding, at any step size; the rounding error of the trace grows by a few 1e-16 per step.

Raises:

Type Description
TypeError

If hamiltonian is not a real or complex floating tensor.

ValueError

If hamiltonian has another shape, is for more than MAX_ATOMS atoms, holds a NaN or an infinity, or is not Hermitian; if t or gamma is not a finite number >= 0; if n_steps is not an integer >= 1, or is missing with gamma > 0.

Notes
  • Precision. The Hamiltonian is converted to complex128 whatever its dtype.
  • Hermiticity is checked per sample: the largest |H − H†| entry may be at most 1e-12 of the sample's largest |H| entry, or 100 machine epsilons of the input's dtype where that is more (1.2e-5 for single precision, and no more than that below it).
  • Choosing n_steps. Halving dt divides the error by 4, so two runs with n_steps and 2 · n_steps differ by about 3/4 of the coarser run's error. dt must be small against 1/γ and against the inverse of the largest level spacing of H.
  • Cost. One eigendecomposition per sample, then two 2^N × 2^N matrix products per step and sample.
  • Gradients. Every operation is differentiable and none is in place on a tensor that carries a gradient (the only in-place write sets the constant initial state), so gradients reach the tensors hamiltonian was built from, and they match finite differences where the spectrum of H is not degenerate. They pass through torch.linalg.eigh, whose backward pass holds 1/(E_m − E_k): where two eigenvalues coincide the gradient is wrong without any error, finite but not the derivative (#505). Two atoms with equal detunings and no interaction are enough. Nothing here needs a gradient; the features are precomputed.
  • Time-dependent pulses are out of scope. A piecewise-constant one would repeat the loop below per segment, starting from the previous segment's state.

Examples:

>>> import math
>>> from hqnn_forge.rydberg import AtomRegister, evolve, readout, rydberg_hamiltonian
>>> one = AtomRegister([[0.0, 0.0]])
>>> h = rydberg_hamiltonian(one, 2.0, [0.0], c6=1.0)  # Ω = 2 rad/µs, resonant
>>> rho = evolve(h, math.pi / 2)  # Ωt = π: a π pulse
>>> round(readout(rho).item(), 12)
1.0
>>> damped = evolve(h, math.pi / 2, gamma=0.2, n_steps=200)
>>> round(readout(damped).item(), 3)  # 1 − πγ/(8Ω) = 0.961 to first order in γ
0.962

readout

readout(rho: Tensor, *, pairs: bool = False) -> torch.Tensor

Excitation probabilities ⟨n_i⟩ of every atom, and optionally ⟨n_i n_j⟩.

With b_i(k) = (k >> (N−1−i)) & 1 the bit of atom i in the index k of a basis state (atom 0 the most significant bit)::

⟨n_i⟩     = Tr[ρ n_i]     = Σ_k b_i(k) ρ_kk
⟨n_i n_j⟩ = Tr[ρ n_i n_j] = Σ_k b_i(k) b_j(k) ρ_kk

Only the diagonal of ρ is read, the outcome probabilities of measuring every atom in the (|g⟩, |r⟩) basis. These are exact expectation values, not estimates from shots.

Parameters:

Name Type Description Default
rho Tensor

Density matrix of shape (2^N, 2^N), or (batch, 2^N, 2^N), e.g. from evolve. Real or complex; the imaginary part of the diagonal, zero for a Hermitian matrix, is dropped. It is not checked to be a density matrix.

required
pairs bool

Also return the pair correlations ⟨n_i n_j⟩ for i < j.

False

Returns:

Type Description
Tensor

float64, on the device of rho, with the atoms (and pairs) on the last axis: shape (N,) or (batch, N) holding ⟨n_0⟩, …, ⟨n_{N−1}⟩. With pairs=True the N(N−1)/2 pair correlations are appended in the order (0,1), (0,2), …, (0,N−1), (1,2), …, (N−2,N−1), which gives N + N(N−1)/2 values; one atom has no pair.

Raises:

Type Description
TypeError

If rho is not a real or complex floating tensor.

ValueError

If rho has another shape, is for more than MAX_ATOMS atoms, or holds a NaN or an infinity.

Examples:

>>> import torch
>>> from hqnn_forge.rydberg import readout
>>> rho = torch.diag(torch.tensor([0.125, 0.125, 0.25, 0.5]))  # |gg>, |gr>, |rg>, |rr>
>>> readout(rho).tolist()  # atom 0 is excited in |rg> and |rr>
[0.75, 0.625]
>>> readout(rho, pairs=True).tolist()
[0.75, 0.625, 0.5]

The feature map: encoding, single-atom feature, readout order and shots

hqnn_forge.rydberg.feature_map

hqnn_forge.rydberg.feature_map

The fixed feature map of docs/rydberg-model.md: a batch of classical inputs goes in, a matrix of excitation probabilities comes out, and every physical setting is recorded.

Units: ħ = 1, angular frequencies (Ω, Δ, γ, C6/r⁶) in rad/µs, lengths in µm, times in µs. The inputs and the features are dimensionless.

The map, step by step

For one sample x ∈ ℝ^N and a register of N atoms::

x_i  →  Δ_i = Δ_max · σ(x_i)                           (PulseEncoding)
     →  H = Ω/2 Σ_i X_i − Σ_i Δ_i n_i + Σ_{i<j} V_ij n_i n_j   (rydberg_hamiltonian)
     →  ρ(T), from every atom in |g⟩, with dephasing γ   (evolve)
     →  F_i = ⟨n_i⟩ = Tr[ρ(T) n_i]                       (readout)
  • Which input feeds which atom. Column i of X sets the detuning of atom i, the atom at row i of the register's positions, which is tensor factor (PennyLane wire) i of the Hamiltonian and feature column i of the result. There is one input per atom, so the input width equals the number of atoms.
  • What carries data. Only the detunings. Ω, T, the positions, C6 and γ are fixed controls, the same for every sample.
  • Sign of Δ. Δ is the laser frequency minus the transition frequency and enters H as −Δ n; the encoding only produces Δ_i > 0.
Encoding

::

Δ_i(x_i) = Δ_max · σ(x_i),        σ(x) = 1 / (1 + e^(−x))

σ is strictly increasing from 0 (x → −∞) to 1 (x → +∞), with σ(0) = ½, so Δ_i lies in the open interval (0, Δ_max) for every finite input. The bound is enforced by this squashing function and not by clipping, which would send every input beyond the range to the same detuning. There is no scale parameter: scaling the inputs is preprocessing.

The single-atom feature

Without interactions and dephasing each atom evolves alone under H_1 = [[0, Ω/2], [Ω/2, −Δ]] in the basis (|g⟩, |r⟩). With n = (1 − Z)/2::

H_1 = −(Δ/2) · 1 + ½ (Ω X + Δ Z),        (Ω X + Δ Z)² = (Ω² + Δ²) · 1 = Ω_eff² · 1

so, with Ω_eff = √(Ω² + Δ²)::

e^(−i H_1 T) = e^(iΔT/2) [ cos(Ω_eff T/2) · 1 − i sin(Ω_eff T/2) · (Ω X + Δ Z)/Ω_eff ]

⟨r| e^(−i H_1 T) |g⟩ = −i e^(iΔT/2) · (Ω/Ω_eff) · sin(Ω_eff T/2)

⟨n⟩(T) = Ω²/Ω_eff² · sin²(Ω_eff T/2)

With the default pulse ΩT = π and s = Ω_eff/Ω = √(1 + (Δ/Ω)²) this is f(s) = sin²(π s/2) / s². The default bound Δ_max = √3 Ω is the detuning with s = 2, the first zero of f: f falls strictly from 1 at Δ = 0 to 0 at Δ_max, so the feature

::

F_i = f( √(1 + 3 σ(x_i)²) )            (interactions off, γ = 0, default pulse)

is strictly decreasing in x_i and depends on no other input. With γ > 0 and no interactions the state is still a product of single-atom states, and each feature solves, for its own Δ_i, with p = ρ_rr and c = ρ_gr::

dp/dt = Ω · Im c,        dc/dt = −(γ/2 + iΔ) c − i (Ω/2)(2p − 1)
Readout and its order

The features are ⟨n_0⟩, …, ⟨n_{N−1}⟩, in the order of the atoms. With correlations=True the pair correlations ⟨n_i n_j⟩, i < j, are appended in the order (0,1), (0,2), …, (0,N−1), (1,2), …, (N−2,N−1), which gives N + N(N−1)/2 features. Both are sums over the diagonal of ρ (readout).

Shots

By default the features are exact expectation values. With shots=S they are estimated as a device would: S bitstrings b^(1), …, b^(S) are drawn for each sample, each with probability ρ_kk for the basis state k (bit b_i = 1 when atom i is found in |r⟩), and::

F̂_i = (1/S) Σ_s b_i^(s),        F̂_ij = (1/S) Σ_s b_i^(s) b_j^(s)

b_i is 1 with probability Σ_k b_i(k) ρ_kk = ⟨n_i⟩ and 0 otherwise, so F̂_i is the mean of S independent Bernoulli variables: unbiased, with variance F_i (1 − F_i)/S, i.e. standard error √(F_i (1 − F_i)/S) ≤ 1/(2√S). The same holds for b_i b_j with ⟨n_i n_j⟩. All features of a sample are computed from the same S bitstrings, so their errors are correlated, as on a device.

Not an encoding layer

RydbergFeatureMap is a preprocessing step, like PCANormalizer, followed by any classifier. It is not an nn.Module, has no PennyLane circuit (qlayer) and no trainable weights, so the encoding-layer contract (hqnn_forge.encoding.EncodingLayer) does not apply to it, and neither do the gate-based noise options (noise_level, apply_depolarizing_noise): its noise is the dephasing rate γ. Nothing is differentiated; the results carry no gradient.

Sign conventions, basis ordering and matrix elements

hqnn_forge.rydberg.hamiltonian

hqnn_forge.rydberg.hamiltonian

The Hamiltonian of docs/rydberg-model.md as a dense matrix.

Each atom is a two-level system of its ground state |g⟩ = |0⟩ and one Rydberg state |r⟩ = |1⟩. In the frame rotating at the laser frequency, with the rotating-wave approximation and ħ = 1::

H = Σ_i Ω_i/2 · X_i  −  Σ_i Δ_i n_i  +  Σ_{i<j} V_ij n_i n_j

n_i = |r⟩⟨r|_i = (1 − Z_i)/2        V_ij = C6 / |r_i − r_j|⁶

Units: angular frequencies (Ω, Δ, V and H itself) in rad/µs, lengths in µm, C6 in rad/µs · µm⁶.

Sign conventions
  • Ω is the Rabi frequency, not half of it. The coefficient of X is Ω/2: a resonant atom has ⟨n⟩(t) = sin²(Ωt/2), and its two levels are split by Ω (eigenvalues ±Ω/2).
  • Δ is the laser frequency minus the atomic transition frequency. The atom's energy ω₀ n minus the frame's ω_L n is −Δ n: a positive Δ lowers |r⟩.
  • A positive C6 is repulsive. It raises a doubly excited pair by V_ij, so two atoms at distance r have ⟨rr|H|rr⟩ = −Δ_0 − Δ_1 + C6/r⁶.

For one atom, in the basis (|g⟩, |r⟩), H = [[0, Ω/2], [Ω/2, −Δ]].

Basis ordering

Atom i (counted from 0, the row of the register) is tensor factor i from the left and PennyLane wire i. The basis state |b_0 b_1 … b_{N−1}⟩, with b_i = 1 for |r⟩, has the index::

k = Σ_i b_i 2^(N−1−i)        ⇔        b_i(k) = (k >> (N−1−i)) & 1

so atom 0 is the most significant bit. For two atoms the order is |gg⟩, |gr⟩, |rg⟩, |rr⟩ and n_0 = diag(0, 0, 1, 1). This is the matrix qml.matrix(op, wire_order=range(N)) returns for the same operator.

Matrix elements

In that basis n_i is diagonal with entry b_i(k), and X_i flips bit N−1−i of the index. Term by term:

  • detuning: −Σ_i Δ_i b_i(k) on the diagonal;
  • interaction: +Σ_{i<j} V_ij b_i(k) b_j(k) on the diagonal;
  • drive: Ω_i/2 at (k, k XOR 2^(N−1−i)) for every k and i. No two of these coincide (different i flip different bits) and none is on the diagonal, so every row holds exactly N off-diagonal entries.

The matrix is real and symmetric. It is returned as complex128 because what consumes it (exp(−iHt), a density matrix) is complex, and because a drive phase makes it complex (see the Notes of rydberg_hamiltonian).

References
  • Bernien et al. (2017) "Probing many-body dynamics on a 51-atom quantum simulator", Nature 551, 579–584.
  • Wurtz et al. (2023) "Aquila: QuEra's 256-qubit neutral-atom quantum computer", arXiv:2306.11727.

The master equation, the dissipator and the splitting

hqnn_forge.rydberg.lindblad

hqnn_forge.rydberg.lindblad

Time evolution of a small Rydberg array under its Hamiltonian and Markovian dephasing, and the readout of the resulting state. This is the solver of docs/rydberg-model.md.

Units: ħ = 1, the Hamiltonian and γ in rad/µs, times in µs, so H t and γ t are dimensionless. Basis ordering is that of hqnn_forge.rydberg.hamiltonian: atom i is tensor factor i from the left, and bit b_i(k) = (k >> (N−1−i)) & 1 of the index k of a basis state is 1 when atom i is in |r⟩ (atom 0 the most significant bit). Index 0 is |g…g⟩.

Master equation

::

dρ/dt = 𝓛_H ρ + 𝓛_D ρ

𝓛_H ρ = −i [H, ρ]
𝓛_D ρ = Σ_i ( L_i ρ L_i† − ½ { L_i† L_i , ρ } ),        L_i = √γ n_i

with one collapse operator per atom, n_i = |r⟩⟨r|_i, and ρ(0) = |g…g⟩⟨g…g|. There is no global variant of the operator and no decay term.

The dissipator, elementwise

n_i is diagonal in the computational basis with entry a_i on the state a, and n_i² = n_i, so L_i† L_i = γ n_i. On the matrix element ρ_ab between basis states a and b::

(n_i ρ n_i)_ab        =  a_i b_i ρ_ab
−½ ({n_i, ρ})_ab      = −½ (a_i + b_i) ρ_ab
sum                   = −½ (a_i + b_i − 2 a_i b_i) ρ_ab = −½ (a_i − b_i)² ρ_ab

(the last step uses a_i² = a_i for a bit). (a_i − b_i)² is 1 where the two states differ on atom i and 0 otherwise, so the sum over the atoms counts the differing atoms, the Hamming distance d_H(a, b)::

(𝓛_D ρ)_ab = −(γ/2) · d_H(a, b) · ρ_ab

𝓛_D is therefore diagonal in the elements of ρ, and its exponential is an elementwise multiplication, exactly, for any τ::

(e^(𝓛_D τ) ρ)_ab = exp(−γ τ d_H(a, b) / 2) · ρ_ab
  • What γ means. The coherence of a single atom (d_H = 1) decays at rate γ/2, i.e. T₂ = 2/γ without a drive. A coherence between states that differ on d atoms decays at d γ/2. Populations (d_H = 0) are untouched. √γ n_i and √(γ/4) Z_i give the same equation; a collapse operator √κ Z_i corresponds to γ = 4κ.
  • No ordering enters. d_H is symmetric in the atoms, so the factor does not depend on which bit belongs to which atom.
The unitary part

H is constant in time. With its eigendecomposition H = V E V† (E real and diagonal, V unitary)::

U(τ) = e^(−iHτ) = V e^(−iEτ) V†,        e^(𝓛_H τ) ρ = U(τ) ρ U(τ)†

The sign is that of the Schrödinger equation i dψ/dt = Hψ. For one resonant atom it gives ψ(t) = (cos(Ωt/2), −i sin(Ωt/2)) in the basis (|g⟩, |r⟩), so ⟨n⟩(t) = sin²(Ωt/2) and ρ_gr = +(i/2) sin(Ωt).

Without dephasing the state stays pure, ρ(t) = ψ(t) ψ(t)† with::

ψ(t) = U(t) e_0,        ψ_k(t) = Σ_m V_km e^(−i E_m t) conj(V_0m)

e_0 being the basis vector of |g…g⟩. This is exact at any t.

Splitting

With dephasing the two parts do not commute, and the evolution over t is taken in n steps of dt = t/n by the symmetric (Strang) splitting::

S(dt) = e^(𝓛_D dt/2) · e^(𝓛_H dt) · e^(𝓛_D dt/2),        ρ(t) ≈ S(dt)^n ρ(0)

read from the right: damp for dt/2, rotate for dt, damp for dt/2. Both factors are exact, so the only error is that of the splitting.

  • Order. S(dt) is symmetric, S(dt) S(−dt) = 1, so its logarithm holds only odd powers of dt: S(dt) = exp((𝓛_H + 𝓛_D) dt + O(dt³)). The error of one step is O(dt³) and the global error after n = t/dt steps is of second order, O(t · dt²): halving dt divides it by 4.
  • Its size. The dt³ term is built from the double commutators [𝓛_D, [𝓛_D, 𝓛_H]] and [𝓛_H, [𝓛_H, 𝓛_D]], of order γ² ‖H‖ and γ ‖H‖². dt therefore has to resolve both 1/γ and the largest level spacing of H, which in the blockade is the interaction V, not Ω. It vanishes when H is diagonal (no drive): 𝓛_H then also acts elementwise, the two parts commute and one step is exact.
  • Every step is a physical map at any dt. U ρ U† is unitary conjugation. The damping factor exp(−γ τ d_H(a, b)/2) is the matrix of a product of single-atom dephasing channels: it has a unit diagonal and is positive semidefinite, so the elementwise product keeps trace, Hermiticity and non-negative eigenvalues (Schur product theorem). A step that is too large gives an inaccurate state, never an unphysical one.
  • The maximally mixed state is a fixed point of every step, as it is of the equation: the factor is 1 on the diagonal and U 1 U† = 1.
  • Adjacent half steps are merged. The damping factors of consecutive steps multiply, e^(𝓛_D dt/2) e^(𝓛_D dt/2) = e^(𝓛_D dt), exactly. The loop applies one half step at the start, a full step between rotations and one half step at the end, which is S(dt)^n term by term.
Readout

⟨n_i⟩ = Tr[ρ n_i] = Σ_k b_i(k) ρ_kk and ⟨n_i n_j⟩ = Σ_k b_i(k) b_j(k) ρ_kk: both are sums over the diagonal of ρ, the outcome probabilities of measuring every atom in the (|g⟩, |r⟩) basis.

The register's two uses

hqnn_forge.rydberg.register

hqnn_forge.rydberg.register

The positions of a neutral-atom array and what follows from them alone: the pairwise van der Waals couplings and the blockade radius.

Units are those of docs/rydberg-model.md: ħ = 1, lengths in µm, angular frequencies in rad/µs, so C6 is in rad/µs · µm⁶.

One register serves both ways of using the array:

  • the analog model (rydberg_hamiltonian) takes the continuous couplings V_ij = C6 / |r_i − r_j|⁶ from AtomRegister.interaction_matrix;
  • a gate-based entangler connects the pairs closer than the blockade radius R_b = (C6/Ω)^(1/6) (AtomRegister.blockade_radius). Since V_ij > Ω exactly when |r_i − r_j| < R_b, that unit-disk graph is the set of pairs with V_ij > Ω: the two views are the same geometry. A pair at exactly R_b has V_ij = Ω and lies on the boundary of the graph; which side it falls on is for the entangler (#418) to fix, and the register does not decide it.

A register holds positions and nothing else. The atomic species (C6) and the drive (Ω) are arguments of the methods that need them.

References
  • Saffman, Walker & Mølmer (2010) "Quantum information with Rydberg atoms", Reviews of Modern Physics 82, 2313.
  • Bernien et al. (2017) "Probing many-body dynamics on a 51-atom quantum simulator", Nature 551, 579–584.