File size: 8,871 Bytes
da98415 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | """
qdot/perception/features.py
============================
Physics-aware feature extractors for 2D stability diagrams.
Ported from the hackathon and promoted to first-class modules.
In the new architecture these functions are a *validator layer*
that sits after the CNN, not before it. The CNN is the primary
classifier; these features act as physics-consistency checks that
can set the `physics_override` flag on a Classification.
Three extractors:
log_preprocess(array) — log₁₀(|G| + ε) normalised to [0,1]
fft_peak_ratio(array) — dominant periodicity ratio; > 3.5 → single-dot signal
diagonal_strength(array) — Laplacian + gradient angle; high → charge transition lines
All functions accept np.ndarray of any 2D shape.
"""
from __future__ import annotations
import numpy as np
# ---------------------------------------------------------------------------
# 1. Log-space preprocessing
# ---------------------------------------------------------------------------
def log_preprocess(array: np.ndarray, eps: float = 1e-9) -> np.ndarray:
"""
Compress conductance dynamic range via log₁₀ and normalise to [0, 1].
This is always the *first* transformation applied to raw conductance
data before any feature extraction or CNN inference. It handles the
several-orders-of-magnitude variation in conductance across a stability
diagram by compressing it into a perceptually uniform scale.
Args:
array: Raw conductance array, any shape, values ≥ 0.
eps: Floor to prevent log(0). Default 1e-9 is safe for typical
normalised conductance (adapter already clips to ≥ 0).
Returns:
Float32 array of the same shape, values ∈ [0, 1].
"""
arr = np.asarray(array, dtype=np.float64)
arr = np.clip(arr, 0.0, None) # guard against negative noise
log_arr = np.log10(arr + eps)
lo, hi = log_arr.min(), log_arr.max()
if hi - lo > 1e-12:
out = (log_arr - lo) / (hi - lo)
else:
out = np.full_like(log_arr, 0.5)
return out.astype(np.float32)
# ---------------------------------------------------------------------------
# 2. FFT peak ratio
# ---------------------------------------------------------------------------
def fft_peak_ratio(array: np.ndarray) -> float:
"""
Measure the dominance of the strongest periodicity in a 2D scan.
A single quantum dot produces regularly-spaced Coulomb peaks along
one gate axis — a single dominant frequency. A double dot produces
a honeycomb with multiple periodicities. A featureless scan produces
a noisy spectrum with no dominant peak.
Method:
1. Apply log-preprocessing (if not already done — idempotent).
2. Compute 2D FFT magnitude spectrum, zero-DC.
3. peak_ratio = max_power / mean_power of top-5 frequencies.
Returns:
peak_ratio ∈ ℝ. Empirically, > 3.5 indicates a single dominant
periodicity (single-dot signature). This threshold is used as a
physics validator, not a hard classifier.
"""
arr = log_preprocess(array).astype(np.float64)
# 2D FFT, shift DC to centre
spectrum = np.abs(np.fft.fftshift(np.fft.fft2(arr))) ** 2
# Zero out the DC component (centre pixel) to avoid it dominating
cy, cx = spectrum.shape[0] // 2, spectrum.shape[1] // 2
spectrum[cy, cx] = 0.0
flat = spectrum.flatten()
flat.sort() # ascending
if flat[-1] < 1e-20: # all-zero spectrum → no signal
return 0.0
# Ratio of top peak to mean of top-5 (avoids single outlier artefacts)
top5 = flat[-5:]
mean_top5 = top5.mean() if top5.mean() > 1e-20 else 1e-20
return float(flat[-1] / mean_top5)
# ---------------------------------------------------------------------------
# 3. Diagonal edge detector
# ---------------------------------------------------------------------------
def diagonal_strength(array: np.ndarray) -> float:
"""
Measure the fraction of image gradient energy aligned with the ±45°
diagonals characteristic of charge transition lines in stability diagrams.
Charge state boundaries appear as diagonal lines because a change in
either gate voltage shifts the electrochemical potential of both dots
(capacitive coupling). The slope is set by the ratio of the lever arms
— always diagonal for symmetric devices, and close to diagonal for
asymmetric ones.
Method:
1. Log-preprocess.
2. Compute gradient magnitude + angle (Sobel operators).
3. diagonal_strength = fraction of gradient energy with angle ∈
(±45° ± 22.5°) or (±135° ± 22.5°).
Returns:
diagonal_strength ∈ [0, 1]. Values > 0.4 indicate strong diagonal
structure (consistent with double-dot or single-dot transitions).
"""
arr = log_preprocess(array).astype(np.float64)
# Sobel kernels (3×3) for x and y gradients
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64)
sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64)
# Manual 2D convolution (avoids OpenCV dependency here)
from scipy.signal import convolve2d
gx = convolve2d(arr, sobel_x, mode="same", boundary="symm")
gy = convolve2d(arr, sobel_y, mode="same", boundary="symm")
magnitude = np.sqrt(gx ** 2 + gy ** 2)
angle_deg = np.degrees(np.arctan2(gy, gx)) # ∈ [-180, 180]
total_energy = (magnitude ** 2).sum()
if total_energy < 1e-20:
return 0.0
# Diagonals are at ±45° and ±135°. Accept ±22.5° tolerance.
angle_mod = np.abs(angle_deg) % 180 # fold to [0, 180)
is_diagonal = (
((angle_mod >= 22.5) & (angle_mod < 67.5)) | # ~45°
((angle_mod >= 112.5) & (angle_mod < 157.5)) # ~135°
)
diagonal_energy = (magnitude[is_diagonal] ** 2).sum()
return float(diagonal_energy / total_energy)
# ---------------------------------------------------------------------------
# 4. Composite physics feature vector
# ---------------------------------------------------------------------------
def physics_features(array: np.ndarray) -> dict:
"""
Compute all physics validator features for a single 2D patch.
Returns a dict compatible with Classification.features.
Keys:
fft_peak_ratio — dominance of single periodicity
diagonal_strength — fraction of gradient energy on diagonals
mean_conductance — mean of log-preprocessed array (proxy for regime)
conductance_std — std of log-preprocessed array (proxy for feature contrast)
"""
preprocessed = log_preprocess(array)
return {
"fft_peak_ratio": fft_peak_ratio(array),
"diagonal_strength": diagonal_strength(array),
"mean_conductance": float(preprocessed.mean()),
"conductance_std": float(preprocessed.std()),
}
# ---------------------------------------------------------------------------
# 5. Physics heuristic override rules
# ---------------------------------------------------------------------------
# Thresholds — these are defaults. InspectionAgent receives device-adaptive
# versions derived from CIM parameters (lever_arm, E_c) at runtime.
PEAK_RATIO_SD_THRESHOLD = 3.5 # above → single-dot signature
DIAGONAL_STRENGTH_MIN = 0.25 # below → no transition lines → likely featureless
def physics_override_label(
cnn_label: str,
features: dict,
peak_ratio_threshold: float = PEAK_RATIO_SD_THRESHOLD,
diagonal_min: float = DIAGONAL_STRENGTH_MIN,
) -> tuple[str | None, str]:
"""
Apply physics heuristics to check for inconsistencies with the CNN label.
Returns:
(override_label, reason) where override_label is None if no override
is warranted, or a ChargeLabel string if the heuristics suggest the
CNN is wrong.
Override conditions (conservative — only flag clear contradictions):
- CNN says double-dot but peak_ratio > peak_ratio_threshold AND
diagonal_strength < diagonal_min → likely single-dot
- CNN says anything but diagonal_strength < 0.10 AND
conductance_std < 0.05 → featureless → misc
"""
pr = features.get("fft_peak_ratio", 0.0)
ds = features.get("diagonal_strength", 1.0)
std = features.get("conductance_std", 1.0)
# Featureless check — catches SC and Barrier
if ds < 0.10 and std < 0.05:
if cnn_label != "misc":
return "misc", f"Featureless (diag={ds:.2f}, std={std:.3f})"
# Single-dot signal in a purported double-dot
if cnn_label == "double-dot" and pr > peak_ratio_threshold and ds < diagonal_min:
return "single-dot", f"Strong single periodicity (peak_ratio={pr:.1f})"
return None, ""
|