Skip to content

Sensing Protocols

The sensing subpackage orchestrates complete sensing experiments by combining spin-Hamiltonian computations with pulse-sequence timing and shot-noise SNR estimation.


SpinDefectSim.sensing.protocols

SensingExperiment

from SpinDefectSim.sensing.protocols import SensingExperiment

Container for a single lock-in sensing experiment. Holds two branches — a signal branch (field with the quantity to sense) and a reference branch (field without it) — and computes ODMR observables for both.

Inherits: PhysicalParams, PlottingMixin

Constructor

SensingExperiment(
    sp_with: SpinParams,
    sp_no: SpinParams,
    E_fields_Vpm: np.ndarray,
    defaults: Optional[Defaults] = None,
    quantization_axes: Optional[np.ndarray] = None,
    B_extra_fields_with: Optional[np.ndarray] = None,
    B_extra_fields_no: Optional[np.ndarray] = None,
)

Parameters:

Name Type Description
sp_with SpinParams Spin parameters for the signal branch (field on)
sp_no SpinParams Spin parameters for the reference branch (field off)
E_fields_Vpm np.ndarray Local E-field at each defect, shape (N, 3), in V/m
defaults Defaults \| None Global defaults
quantization_axes np.ndarray \| None Per-defect z′-axes, shape (N, 3)
B_extra_fields_with np.ndarray \| None Per-defect stray B-field for signal branch, shape (N, 3), in T
B_extra_fields_no np.ndarray \| None Per-defect stray B-field for reference branch, shape (N, 3), in T

Lazy-cached properties:

  • transitions_with — list of N transition-frequency arrays for the signal branch
  • transitions_no — list of N transition-frequency arrays for the reference branch

cw_odmr

def cw_odmr(f_axis_Hz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Compute CW ODMR spectra for both branches and their difference.

Parameters:

Name Type Description
f_axis_Hz np.ndarray MW frequency axis (Hz)

Returns: (pl_with, pl_no, dpl) — each 1-D array over f_axis_Hz

Output Description
pl_with Normalised PL spectrum, signal branch
pl_no Normalised PL spectrum, reference branch
dpl Lock-in difference: pl_with − pl_no

Example:

f = np.linspace(3.35e9, 3.57e9, 400)
pl_w, pl_no, dpl = exp.cw_odmr(f)


ramsey

def ramsey(tau_range_s: Optional[np.ndarray] = None) -> tuple

Compute the Ramsey free-induction decay (FID) lock-in signal ΔS(τ) for the ensemble.

The free-precession time axis is automatically chosen if not supplied, spanning [0, 2 × T₂*] with 200 points.

Parameters:

Name Type Description
tau_range_s np.ndarray \| None Free-precession time values (s); if None, auto-generated from defaults.T2star

Returns: (tau_s, S_with, S_no, dS, tau_opt, dS_peak)

Output Description
tau_s Free-precession time axis (s)
S_with Echo amplitude vs τ, signal branch
S_no Echo amplitude vs τ, reference branch
dS Lock-in difference: S_with − S_no
tau_opt τ that maximises |dS| (s)
dS_peak Peak differential signal at tau_opt

echo_static

def echo_static(tau_range_s: Optional[np.ndarray] = None) -> tuple

Compute the Hahn-echo lock-in signal ΔS(τ) for a static (DC) field measurement.

Returns the same (tau_s, S_with, S_no, dS, tau_opt, dS_peak) tuple as ramsey, but uses defaults.T2echo for the coherence time and the echo decoherence envelope.


echo_odmr_lockIn

def echo_odmr_lockIn(f_axis_Hz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Frequency-domain echo-detected lock-in spectrum.

Returns: (dpl, pl_with, pl_no) — same convention as cw_odmr, but using T₂-limited linewidths


snr

def snr(delta_S: np.ndarray, N_avg: float) -> np.ndarray

Signal-to-noise ratio after N_avg averaging cycles.

Parameters:

Name Type Description
delta_S np.ndarray Differential signal
N_avg float Number of averaging cycles

Returns: np.ndarray same shape as delta_S


n_avg_to_detect

def n_avg_to_detect(delta_S: np.ndarray, snr_target: float = 5.0) -> np.ndarray

Number of averaging cycles needed to reach snr_target.

Parameters:

Name Type Description
delta_S np.ndarray Differential signal
snr_target float Target SNR (default 5σ)

Returns: np.ndarray same shape as delta_S


SpinDefectSim.sensing.sequences

Pulse sequence classes track hardware gate times (π/2 pulses, π pulses, readout window) and compute per-shot timing and repetition rates.

PulseSequence (abstract base)

from SpinDefectSim.sensing.sequences import PulseSequence

Constructor fields (all sequences inherit these)

Attribute Default Description
t_pi_half_s 10e-9 π/2 pulse duration (s)
t_pi_s 20e-9 π pulse duration (s)
t_readout_s 300e-9 Optical readout window (s)
t_init_s 1.0e-6 Initialization laser pulse (s)

total_time

def total_time(tau_s: Union[float, np.ndarray]) -> Union[float, np.ndarray]

Wall-clock time per shot:

\[T_\text{shot}(\tau) = t_\text{init} + t_\text{pulses} + n_\text{fp} \cdot \tau + t_\text{readout}\]

Parameters:

Name Type Description
tau_s float \| np.ndarray Free-precession time (s)

Returns: total shot time (same type as input)


repetition_rate

def repetition_rate(tau_s: Union[float, np.ndarray]) -> Union[float, np.ndarray]

Shot repetition rate R(τ) = 1 / T_shot(τ) in Hz.


n_avg_in_time

def n_avg_in_time(T_int_s: float, tau_s: Union[float, np.ndarray]) -> Union[float, np.ndarray]

Number of averaging cycles achievable in a total integration time T_int_s (s).


pulse_time_s

def pulse_time_s() -> float

Total time spent in MW pulses per shot: \(n_{\pi/2} \cdot t_{\pi/2} + n_\pi \cdot t_\pi\).


summary

def summary(tau_s: float) -> dict

Return a dict of all timing quantities at a given τ.


RamseySequence

from SpinDefectSim.sensing.sequences import RamseySequence

Topology: init → π/2 → [τ] → π/2 → readout

Property Value
n_pi_half_pulses 2
n_pi_pulses 0
n_free_precession_intervals 1

Example:

seq = RamseySequence(t_pi_half_s=12e-9, t_readout_s=500e-9)
print(seq.total_time(200e-9))   # shot time at τ = 200 ns
print(seq)


HahnEchoSequence

from SpinDefectSim.sensing.sequences import HahnEchoSequence

Topology: init → π/2 → [τ] → π → [τ] → π/2 → readout

Property Value
n_pi_half_pulses 2
n_pi_pulses 1
n_free_precession_intervals 2

XY8Sequence

from SpinDefectSim.sensing.sequences import XY8Sequence

XY-8 dynamical decoupling: π/2 → [τ/2 – (X π – τ – Y π – τ – X π – τ – Y π – τ)×2 – τ/2] → π/2

Property Value
n_pi_half_pulses 2
n_pi_pulses 8
n_free_precession_intervals 16

Note: The free-precession intervals in XY8 are each τ/2, so total_time(tau_s) uses half the delay for each of the 16 intervals; internally n_fp = 16 and the caller supplies τ/2.


SpinDefectSim.sensing.snr

Low-level SNR functions. These are also available as methods on SensingExperiment.


noise_floor

from SpinDefectSim.sensing.snr import noise_floor

def noise_floor(
    contrast: Optional[float] = None,
    n_photons: Optional[int] = None,
) -> float

Single-shot photon-shot-noise floor:

\[\sigma = \frac{1}{C \sqrt{N_\text{ph}}}\]

Parameters:

Name Type Description
contrast float \| None CW ODMR contrast C; if None, uses DEFAULT.get_contrast()
n_photons int \| None Photons per readout; if None, uses DEFAULT.n_photons

Returns: float — noise floor σ


snr

from SpinDefectSim.sensing.snr import snr

def snr(
    delta_S: np.ndarray,
    N_avg: float,
    contrast: Optional[float] = None,
    n_photons: Optional[int] = None,
) -> np.ndarray

Signal-to-noise ratio:

\[\text{SNR} = \frac{|\Delta S| \sqrt{N_\text{avg}}}{\sigma}\]

Parameters:

Name Type Description
delta_S np.ndarray Differential signal ΔS
N_avg float Number of averaging cycles
contrast float \| None Override contrast
n_photons int \| None Override photon count

Returns: np.ndarray same shape as delta_S


n_avg_for_threshold

from SpinDefectSim.sensing.snr import n_avg_for_threshold

def n_avg_for_threshold(
    delta_S: np.ndarray,
    snr_target: float = 5.0,
    contrast: Optional[float] = None,
    n_photons: Optional[int] = None,
) -> np.ndarray

Number of averages needed to reach snr_target:

\[N_\text{avg} = \left(\frac{\sigma \cdot \text{SNR}_\text{target}}{|\Delta S|}\right)^2\]

Returns: np.ndarray same shape as delta_S

Example:

from SpinDefectSim.sensing.snr import n_avg_for_threshold
from SpinDefectSim.sensing.sequences import HahnEchoSequence

N = n_avg_for_threshold(dS_peak, snr_target=5.0, contrast=0.02, n_photons=500)
seq = HahnEchoSequence()
T_total = N * seq.total_time(tau_opt)
print(f"Integration time: {T_total * 1e3:.1f} ms")