AutoSQUID 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- AutoSQUID/__init__.py +47 -0
- AutoSQUID/analysis.py +153 -0
- AutoSQUID/config.py +99 -0
- AutoSQUID/daq.py +110 -0
- AutoSQUID/measurement.py +139 -0
- AutoSQUID/plotting.py +116 -0
- AutoSQUID/scc.py +42 -0
- AutoSQUID/serial_io.py +60 -0
- AutoSQUID/temperature.py +42 -0
- AutoSQUID/tuning.py +80 -0
- AutoSQUID/util.py +6 -0
- autosquid-0.1.3.dist-info/METADATA +847 -0
- autosquid-0.1.3.dist-info/RECORD +16 -0
- autosquid-0.1.3.dist-info/WHEEL +5 -0
- autosquid-0.1.3.dist-info/licenses/LICENSE +674 -0
- autosquid-0.1.3.dist-info/top_level.txt +1 -0
AutoSQUID/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""AutoSQUID — shared library for the SQUID measurement-cycle + auto-S-tune notebooks.
|
|
2
|
+
|
|
3
|
+
Edit values in the notebook (build a Config); call these functions with that cfg. Modules:
|
|
4
|
+
util generic helpers (clamp)
|
|
5
|
+
config Config dataclass (all knobs + derived paths/register)
|
|
6
|
+
scc pure SCC framing (assemble_command, pfl_register, dac_data)
|
|
7
|
+
analysis pure detection + I/O (surge/jump, temp label, PCS102 read/write, ledger, resume)
|
|
8
|
+
serial_io SCC writes (reset, s_lock/s_tune, set_* DACs) [pyserial]
|
|
9
|
+
daq NI reads + channel detect + chunked acquisition [nidaqmx]
|
|
10
|
+
temperature MXC read via the injected `cfg.temp_reader` + background logger [no instrument import]
|
|
11
|
+
measurement run_cycle state machine (the interval loop lives in the notebook)
|
|
12
|
+
tuning auto_s_tune (live-center the locked output near 0 by stepping S-flux; secant search)
|
|
13
|
+
plotting plot_run / plot_psd (raw trace + temperature; one-sided Welch PSD, read from disk) [matplotlib/pandas]
|
|
14
|
+
|
|
15
|
+
The DAQ + serial backends (`nidaqmx`, `pyserial`) are imported at module top (the package assumes the
|
|
16
|
+
STAR Cryo + NI rig), so `import AutoSQUID` needs those two. The MXC thermometer backend is **not** imported —
|
|
17
|
+
set `cfg.temp_reader` (a `fn(channel)->T in K`) in the notebook, so the temperature source is lab-swappable.
|
|
18
|
+
The pure modules (`scc`, `analysis`, `config`) have no hardware deps and import anywhere for unit tests.
|
|
19
|
+
"""
|
|
20
|
+
__version__ = "0.1.3"
|
|
21
|
+
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .util import clamp
|
|
24
|
+
from .scc import assemble_command, pfl_register, dac_data
|
|
25
|
+
from .analysis import (is_surge_spec, chunk_jump, format_temp_label,
|
|
26
|
+
save_pcs102, save_temp_csv, read_daq_file,
|
|
27
|
+
log_experiment, log_action, scan_indices, LEDGER_COLS)
|
|
28
|
+
from .serial_io import (reset, fire_reset, s_lock, s_tune,
|
|
29
|
+
set_squid_flux, set_array_flux, set_squid_bias, set_array_bias)
|
|
30
|
+
from .daq import daq_read, daq_mean, live_mean, classify, detect_ai_channel, acquire_finite_chunked
|
|
31
|
+
from .temperature import read_temp, TempLogger
|
|
32
|
+
from .measurement import run_cycle, reset_and_verify, resolve_temp_label
|
|
33
|
+
from .tuning import auto_s_tune
|
|
34
|
+
from .plotting import plot_run, clean_trace_names, plot_overlay, plot_psd
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"Config",
|
|
39
|
+
"assemble_command", "pfl_register", "dac_data", "clamp",
|
|
40
|
+
"is_surge_spec", "chunk_jump", "format_temp_label",
|
|
41
|
+
"save_pcs102", "save_temp_csv", "read_daq_file", "log_experiment", "log_action", "scan_indices", "LEDGER_COLS",
|
|
42
|
+
"reset", "fire_reset", "s_lock", "s_tune", "set_squid_flux", "set_array_flux", "set_squid_bias", "set_array_bias",
|
|
43
|
+
"daq_read", "daq_mean", "live_mean", "classify", "detect_ai_channel", "acquire_finite_chunked",
|
|
44
|
+
"read_temp", "TempLogger",
|
|
45
|
+
"run_cycle", "reset_and_verify", "resolve_temp_label",
|
|
46
|
+
"auto_s_tune", "plot_run", "clean_trace_names", "plot_overlay", "plot_psd",
|
|
47
|
+
]
|
AutoSQUID/analysis.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Pure analysis + I/O for SQUID traces: surge/jump detection, temperature labels, PCS102 read/write,
|
|
2
|
+
the experiment ledger + action log, and resume bookkeeping.
|
|
3
|
+
|
|
4
|
+
No instrument hardware — numpy + pandas + stdlib only (the detection/label functions need only numpy).
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import datetime
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_surge_spec(v,
|
|
14
|
+
win=2000,
|
|
15
|
+
n_baseline_chunks=1, jump_sigma=6.0, rail_v=9.5, first_chunk_v=0.1,
|
|
16
|
+
min_std=5e-5, stuck_frac=0.1, stuck_max_frac=0.02):
|
|
17
|
+
"""Post-hoc surge detector: rail catch, already-surged first-chunk pre-check, a stuck/flat catch (a
|
|
18
|
+
healthy noise trace fluctuates; a frozen one's per-chunk std collapses), and baseline-deviation
|
|
19
|
+
|mu_chunk - mu0|/sigma0 > jump_sigma. Returns (bad, reason)."""
|
|
20
|
+
v = np.asarray(v, dtype=float)
|
|
21
|
+
if np.max(np.abs(v)) > rail_v:
|
|
22
|
+
return True, f"railed (|V|max={np.max(np.abs(v)):.2f} > {rail_v})"
|
|
23
|
+
nc = max(len(v) // win, 1)
|
|
24
|
+
chunks = np.array_split(v, nc)
|
|
25
|
+
mu = np.array([c.mean() for c in chunks])
|
|
26
|
+
sd = np.array([c.std() for c in chunks])
|
|
27
|
+
if abs(mu[0]) > first_chunk_v:
|
|
28
|
+
return True, f"first-chunk mean {mu[0]:+.3f} V > {first_chunk_v} V (already surged?)"
|
|
29
|
+
live = float(sd.max()) # liveliest chunk's std = the trace's noise level
|
|
30
|
+
if live < min_std: # no chunk shows real noise -> flat/dead throughout
|
|
31
|
+
return True, f"flat/dead trace (max chunk std {live:.2e} V < {min_std})"
|
|
32
|
+
stuck = sd < stuck_frac * live # chunks whose noise collapsed vs the live level
|
|
33
|
+
if stuck.mean() > stuck_max_frac: # frozen/stuck over more than stuck_max_frac of the run
|
|
34
|
+
return True, f"stuck/frozen: {100 * stuck.mean():.0f}% of chunks have std < {stuck_frac:g}x the live level"
|
|
35
|
+
k = min(n_baseline_chunks, nc)
|
|
36
|
+
mu0 = mu[:k].mean()
|
|
37
|
+
sigma0 = max(sd[:k].mean(), 1e-7)
|
|
38
|
+
dev = np.abs(mu - mu0) / sigma0
|
|
39
|
+
if dev.max() > jump_sigma:
|
|
40
|
+
return True, f"baseline deviation {dev.max():.1f} sigma0 at chunk {int(dev.argmax())}/{nc}"
|
|
41
|
+
return False, "ok"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def chunk_jump(seg_mean, seg_absmax, mu0, jump_v=1.0, rail_v=9.5, baseline_v=0.1):
|
|
45
|
+
"""Per-chunk live check; returns (kind, reason) or None. In the baseline window (mu0 is None) any
|
|
46
|
+
excursion is 'bad_baseline' (reset didn't hold); afterward it's 'rail' or 'jump'."""
|
|
47
|
+
if mu0 is None:
|
|
48
|
+
if seg_absmax > rail_v:
|
|
49
|
+
return ("bad_baseline", f"railed at start (|V|max={seg_absmax:.2f}); reset did not hold")
|
|
50
|
+
if abs(seg_mean) > baseline_v:
|
|
51
|
+
return ("bad_baseline", f"high baseline {seg_mean:+.3f} V (>{baseline_v} V); reset did not hold")
|
|
52
|
+
return None
|
|
53
|
+
if seg_absmax > rail_v:
|
|
54
|
+
return ("rail", f"railed (|V|max={seg_absmax:.2f} > {rail_v})")
|
|
55
|
+
if abs(seg_mean - mu0) > jump_v:
|
|
56
|
+
return ("jump", f"{jump_v:g} V jump (|d-mu|={abs(seg_mean - mu0):.3f} V)")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_temp_label(T_K):
|
|
61
|
+
"""Filename label: <100 mK -> nearest 1 mK; 100 mK-1 K -> nearest 10 mK; >=1 K -> nearest 0.1 K ('p' decimal)."""
|
|
62
|
+
mK = T_K * 1000.0
|
|
63
|
+
if T_K < 1.0:
|
|
64
|
+
step = 1 if mK < 100 else 10
|
|
65
|
+
return f"{int(round(mK / step)) * step}mK"
|
|
66
|
+
s = f"{round(T_K, 1):.1f}".rstrip('0').rstrip('.')
|
|
67
|
+
return s.replace('.', 'p') + "K"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_pcs102(path, v, scan_interval_s, channels=1):
|
|
71
|
+
"""Write a 1-D voltage array to PCS102 DAQ .txt format (tab-separated, 1-based POINT index; matches data/)."""
|
|
72
|
+
n = len(v); now = datetime.datetime.now()
|
|
73
|
+
header = ("PCS102\n"
|
|
74
|
+
f"DATE={now:%m-%d-%Y}\n"
|
|
75
|
+
f"TIME={now:%H:%M:%S}\n"
|
|
76
|
+
f"CHANNELS={channels}\n"
|
|
77
|
+
f"DATAPOINTS={n}\n"
|
|
78
|
+
f"SCANINTVAL={scan_interval_s:.2e}\n"
|
|
79
|
+
+ " " * 9 + "\n" + "TEXT: \n" + " " * 9 + "\n"
|
|
80
|
+
+ " " * 3 + "POINT" + " " * 9 + "CHAN_01(V)" + " " * 6 + "\n" + " " * 3 + "\n")
|
|
81
|
+
with open(path, "w") as f:
|
|
82
|
+
f.write(header)
|
|
83
|
+
np.savetxt(f, np.column_stack([np.arange(1, n + 1), v]), fmt="%8d\t % .6f\t")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_temp_csv(path, samples):
|
|
87
|
+
"""Write the temperature log (list of (t_rel_s, T_K)) to a 2-column CSV."""
|
|
88
|
+
with open(path, "w") as f:
|
|
89
|
+
f.write("time_s,T_K\n")
|
|
90
|
+
for t_rel, T in samples:
|
|
91
|
+
f.write(f"{t_rel:.3f},{T:.6f}\n")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_daq_file(path, filename):
|
|
95
|
+
"""Read a PCS102 DAQ .txt into (header_info, df) — from data-analysis/Calculations during measurement."""
|
|
96
|
+
truepath = os.path.join(path, filename)
|
|
97
|
+
with open(truepath, 'r') as file:
|
|
98
|
+
lines = file.readlines()
|
|
99
|
+
header_info = {}
|
|
100
|
+
for line in lines[:6]:
|
|
101
|
+
if '=' in line:
|
|
102
|
+
key, value = line.strip().split('=')
|
|
103
|
+
if key in ("DATE", "TIME"): header_info[key] = value
|
|
104
|
+
elif key in ("CHANNELS", "DATAPOINTS"): header_info[key] = int(value)
|
|
105
|
+
elif key == "SCANINTVAL": header_info[key] = float(value)
|
|
106
|
+
else: header_info[key] = value
|
|
107
|
+
else:
|
|
108
|
+
header_info[line.strip()] = None
|
|
109
|
+
text_index = next(i for i, line in enumerate(lines) if "TEXT:" in line)
|
|
110
|
+
column_names = lines[text_index + 2].strip().split()
|
|
111
|
+
df = pd.read_csv(truepath, sep=r'\s+', skiprows=text_index + 3, header=None, names=column_names)
|
|
112
|
+
df.set_index("POINT", inplace=True)
|
|
113
|
+
return header_info, df
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
LEDGER_COLS = ["timestamp", "scan_interval_us", "n_target", "n_acquired", "n_clean", "attempt",
|
|
117
|
+
"outcome", "jump_index", "jump_time_s", "n_resets", "mean_V", "std_V",
|
|
118
|
+
"T_start_K", "T_end_K", "filename"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def log_experiment(path, row):
|
|
122
|
+
"""Append one attempt as a tab-separated row to the experiment ledger (writes header if new)."""
|
|
123
|
+
new = not os.path.exists(path)
|
|
124
|
+
with open(path, "a") as f:
|
|
125
|
+
if new:
|
|
126
|
+
f.write("\t".join(LEDGER_COLS) + "\n")
|
|
127
|
+
f.write("\t".join(str(row.get(c, "")) for c in LEDGER_COLS) + "\n")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def log_action(path, action, detail=""):
|
|
131
|
+
"Append a timestamped action line (reset / reset_fail / bad_baseline / temperature / measurement attempt) to the action log — actions + attempted names only, NEVER measurement results."
|
|
132
|
+
new = not os.path.exists(path)
|
|
133
|
+
with open(path, "a") as f:
|
|
134
|
+
if new:
|
|
135
|
+
f.write("timestamp\taction\tdetail\n")
|
|
136
|
+
f.write(f"{datetime.datetime.now().isoformat(timespec='seconds')}\t{action}\t{detail}\n")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scan_indices(outdir, core):
|
|
140
|
+
"""Scan DAQ_[<date>_]{core}_{k}[_OUTCOME].txt on disk for this (interval,temp,npts) `core`, IGNORING the
|
|
141
|
+
date token, so traces measured on any day in the folder all count; return (n_clean, next_idx): clean-trace
|
|
142
|
+
count and the next free order index (max existing index + 1, over clean AND failed) so a resume never
|
|
143
|
+
overwrites and the index stays continuous regardless of the day measured."""
|
|
144
|
+
n_clean = 0; max_idx = 0
|
|
145
|
+
pat = re.compile(rf"DAQ_(?:[^_]+_)?{re.escape(core)}_(\d+)(_[A-Z]+)?\.txt") # optional date token, any day
|
|
146
|
+
for p in outdir.glob("DAQ_*.txt"):
|
|
147
|
+
m = pat.fullmatch(p.name)
|
|
148
|
+
if not m:
|
|
149
|
+
continue
|
|
150
|
+
max_idx = max(max_idx, int(m.group(1)))
|
|
151
|
+
if m.group(2) is None:
|
|
152
|
+
n_clean += 1
|
|
153
|
+
return n_clean, max_idx + 1
|
AutoSQUID/config.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Config: every knob for the SQUID measurement-cycle / auto-S-tune stack, in one dataclass.
|
|
2
|
+
|
|
3
|
+
The notebook constructs a Config (that is where you EDIT values); the library functions all take it as
|
|
4
|
+
their first argument. Derived values (scan-interval list, filename tag, output dir, PFL register) are
|
|
5
|
+
computed properties, so there is no stale state to keep in sync.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, List, Optional, Union
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .scc import pfl_register
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
# ---- acquisition ----
|
|
19
|
+
scan_interval_s: Union[float, List[float]] = field(default_factory=lambda: [100e-6]) # one cycle per entry
|
|
20
|
+
n_points: int = 10_000_000 # length of the consecutive run
|
|
21
|
+
temp_label: str = "auto" # "auto" -> read+round the current MXC temperature; or a literal e.g. "14mK"
|
|
22
|
+
n_trials: int = 2 # target number of CLEAN traces per scan interval
|
|
23
|
+
|
|
24
|
+
# ---- live jump check ----
|
|
25
|
+
chunk: int = 100_000 # points per read = jump-check cadence (does NOT break the consecutive run)
|
|
26
|
+
jump_v: float = 0.5 # flag a baseline-mean slip >= this (V)
|
|
27
|
+
rail_v: float = 9.5 # true-rail catch (V); range-agnostic
|
|
28
|
+
baseline_chunks: int = 1 # first chunk(s) set the baseline mean mu0
|
|
29
|
+
|
|
30
|
+
# ---- temperature logging ----
|
|
31
|
+
temp_every_s: float = 30.0 # MXC sample period (background thread)
|
|
32
|
+
temp_channel: int = 6 # thermometer channel passed to temp_reader
|
|
33
|
+
temp_reader: Optional[Callable[[int], float]] = None # LAB-SPECIFIC: set in the notebook to fn(channel)->T in K
|
|
34
|
+
# (e.g. cfg.temp_reader = lambda ch: read_latest_temp(ch)[1]); NOT imported by the package
|
|
35
|
+
|
|
36
|
+
# ---- control (SCC serial) ----
|
|
37
|
+
port: str = "COM3" # control COM port (PCS102DA may stay open on another port to hold S-lock)
|
|
38
|
+
channel: int = 1 # SCC address = locked PFL channel
|
|
39
|
+
reset_opcode: int = 0x50
|
|
40
|
+
baud: int = 9600
|
|
41
|
+
register_override: Optional[int] = None # None -> standard SQUID-locked register (0x408)
|
|
42
|
+
|
|
43
|
+
# ---- verify / DAQ / output ----
|
|
44
|
+
verify_n: int = 1000 # post-reset verify read
|
|
45
|
+
verify_fs: int = 10_000
|
|
46
|
+
scan_n: int = 4000 # per-channel liveliness probe
|
|
47
|
+
scan_fs: int = 50_000
|
|
48
|
+
baseline_v: float = 0.10 # |mean| below this = cleared / locked baseline
|
|
49
|
+
vrange: float = 1.0 # NI AI range (V); +/-1 V matches the previous measurements
|
|
50
|
+
max_attempts: int = 4 # max total acquisitions per interval (clean + failed) before moving on
|
|
51
|
+
force_device: Optional[str] = None # e.g. 'Dev1' to skip device auto-pick
|
|
52
|
+
force_ai: Optional[str] = None # e.g. 'Dev1/ai0' to skip channel auto-pick
|
|
53
|
+
daq_ai: str = "" # NI analog-in carrying the SQUID output (set by detect_ai_channel)
|
|
54
|
+
data_root: str = "" # data root; outdir = data_root / user / date (set per install)
|
|
55
|
+
user: str = "" # data folder owner
|
|
56
|
+
date: str = "" # date subfolder ("" = the USER folder itself)
|
|
57
|
+
|
|
58
|
+
# ---- derived ----
|
|
59
|
+
@property
|
|
60
|
+
def scan_intervals(self) -> List[float]:
|
|
61
|
+
"Scan interval(s) normalized to a list (accepts a scalar)."
|
|
62
|
+
s = self.scan_interval_s
|
|
63
|
+
return [s] if isinstance(s, (int, float)) else list(s)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def npts_tag(self) -> str:
|
|
67
|
+
"Filename point-count tag, e.g. 10_000_000 -> '10Mpts'."
|
|
68
|
+
n = self.n_points
|
|
69
|
+
return f"{n // 1_000_000}Mpts" if n % 1_000_000 == 0 else f"{n}pts"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def base_path(self) -> str:
|
|
73
|
+
"Per-user data folder: data_root / user."
|
|
74
|
+
return os.path.join(self.data_root, self.user)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def outdir(self) -> Path:
|
|
78
|
+
"Folder where traces, temp CSVs, and the ledger are written/read (base_path + date)."
|
|
79
|
+
return Path(os.path.join(self.base_path, self.date))
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def register(self) -> int:
|
|
83
|
+
"The PFL feedback register to use (override, else standard SQUID-locked 0x408)."
|
|
84
|
+
return self.register_override if self.register_override is not None else pfl_register(stage1_locked=True)
|
|
85
|
+
|
|
86
|
+
def core_name(self, scan_interval_s: float) -> str:
|
|
87
|
+
"Date-agnostic identifying core, e.g. '100us_14mK_10Mpts' — matches this (interval, temp, npts) trace on ANY date."
|
|
88
|
+
return f"{int(round(scan_interval_s * 1e6))}us_{self.temp_label}_{self.npts_tag}"
|
|
89
|
+
|
|
90
|
+
def base_name(self, scan_interval_s: float) -> str:
|
|
91
|
+
"WRITE stem for a new trace, e.g. 'DAQ_Jun01_100us_14mK_10Mpts' (date prefix + core; order index/outcome appended later). Existing traces are MATCHED by core_name, which ignores the date."
|
|
92
|
+
return f"DAQ_{datetime.now().strftime('%b%d')}_{self.core_name(scan_interval_s)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def require_fields(cfg, names, context):
|
|
96
|
+
"Raise if any of `names` is unset (None or '') on cfg — call at the top of a function that needs them."
|
|
97
|
+
missing = [n for n in names if getattr(cfg, n) is None or getattr(cfg, n) == ""]
|
|
98
|
+
if missing:
|
|
99
|
+
raise RuntimeError(f"{context}: set cfg.{', cfg.'.join(missing)} before running")
|
AutoSQUID/daq.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""NI DAQ reads: finite reads, channel auto-detect, and the consecutive chunked acquisition with the
|
|
2
|
+
live jump/surge check. Hardware: nidaqmx. Every read is a FINITE task (start -> read N -> close); the
|
|
3
|
+
chunked acquisition drains one finite N-sample task gap-free (so chunking never breaks the run).
|
|
4
|
+
"""
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import nidaqmx
|
|
9
|
+
from nidaqmx.constants import AcquisitionType, TerminalConfiguration
|
|
10
|
+
from nidaqmx.stream_readers import AnalogSingleChannelReader
|
|
11
|
+
|
|
12
|
+
from .analysis import chunk_jump
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def daq_task(cfg, channel, fs, n):
|
|
16
|
+
"Create + configure a FINITE single-channel AI task on `channel` at fs Hz for n samples (caller closes)."
|
|
17
|
+
t = nidaqmx.Task()
|
|
18
|
+
t.ai_channels.add_ai_voltage_chan(channel, min_val=-cfg.vrange, max_val=cfg.vrange,
|
|
19
|
+
terminal_config=TerminalConfiguration.RSE)
|
|
20
|
+
t.timing.cfg_samp_clk_timing(fs, sample_mode=AcquisitionType.FINITE, samps_per_chan=n)
|
|
21
|
+
return t
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def daq_read(cfg, channel, n, fs):
|
|
25
|
+
"Finite read of n samples at fs Hz from one AI channel; returns a 1-D array (V)."
|
|
26
|
+
with daq_task(cfg, channel, fs, n) as t:
|
|
27
|
+
return np.asarray(t.read(number_of_samples_per_channel=n, timeout=n / fs + 5.0))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def daq_mean(cfg, channel=None, n=2000, fs=10_000):
|
|
31
|
+
"Mean voltage of a short read of the locked SQUID output (V) — the auto-S-tune DC read."
|
|
32
|
+
return float(np.mean(daq_read(cfg, channel or cfg.daq_ai, n, fs)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def live_mean(cfg, channel=None, seconds=1.0, fs=10_000):
|
|
36
|
+
"""A short 'live view' of the locked output (what the eye watches while centering): one finite read of
|
|
37
|
+
`seconds`, returned as dict(mean, std, min, max)."""
|
|
38
|
+
v = daq_read(cfg, channel or cfg.daq_ai, int(round(seconds * fs)), fs)
|
|
39
|
+
return {"mean": float(v.mean()), "std": float(v.std()),
|
|
40
|
+
"min": float(v.min()), "max": float(v.max())}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classify(v):
|
|
44
|
+
"Label an AI-channel probe as live / dead-flat / railed."
|
|
45
|
+
mx, sd, mu = np.max(np.abs(v)), v.std(), v.mean()
|
|
46
|
+
if mx > 9.9 or abs(mu) > 9.5: return "RAILED"
|
|
47
|
+
if sd < 5e-5: return "dead/flat"
|
|
48
|
+
return "live"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_ai_channel(cfg):
|
|
52
|
+
"List NI devices, pick the PCIe-6320, probe each AI channel, and return (device_name, daq_ai). Honors cfg.force_*."
|
|
53
|
+
sysl = nidaqmx.system.System.local()
|
|
54
|
+
devices = list(sysl.devices)
|
|
55
|
+
assert devices, "No NI-DAQmx devices found. Is the PCIe-6320 installed and is this the bench PC?"
|
|
56
|
+
dev = (sysl.devices[cfg.force_device] if cfg.force_device
|
|
57
|
+
else next((d for d in devices if "6320" in (d.product_type or "")), devices[0]))
|
|
58
|
+
chans = [c.name for c in dev.ai_physical_chans]
|
|
59
|
+
if cfg.force_ai:
|
|
60
|
+
return dev, cfg.force_ai
|
|
61
|
+
live = []
|
|
62
|
+
for ch in chans:
|
|
63
|
+
try:
|
|
64
|
+
if classify(daq_read(cfg, ch, cfg.scan_n, cfg.scan_fs)) == "live":
|
|
65
|
+
live.append(ch)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
if not live:
|
|
69
|
+
raise RuntimeError(f"no live AI channel on {dev.name} (probed {len(chans)}): check the SQUID lock / "
|
|
70
|
+
"wiring, or pin the channel with cfg.force_ai.")
|
|
71
|
+
daq_ai = next((ch for ch in live if ch.endswith("ai0")), live[0])
|
|
72
|
+
return dev, daq_ai
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def acquire_finite_chunked(cfg, channel, n, fs):
|
|
76
|
+
"""ONE consecutive FINITE read of n samples, drained in cfg.chunk-point reads with the live chunk_jump
|
|
77
|
+
check (the sample clock is gap-free, so chunking does NOT break the run). Returns
|
|
78
|
+
(v[:got], got, flag|None) where flag=dict(kind,index,time_s,reason), kind in {jump,rail,bad_baseline}."""
|
|
79
|
+
|
|
80
|
+
# The FIFO buffer only holds a few thousand samples, so read_many_sample is not going to interrupt the run.
|
|
81
|
+
# Check out the following on buffers:
|
|
82
|
+
# https://knowledge.ni.com/KnowledgeArticleDetails?id=kA00Z000000PAraSAG&l=en-US
|
|
83
|
+
# https://www.ni.com/docs/en-US/bundle/pcie-6320-specs/resource/374459d.pdf
|
|
84
|
+
|
|
85
|
+
buf = np.empty(n, dtype=np.float64)
|
|
86
|
+
got = 0; mu0 = None; base = []
|
|
87
|
+
with daq_task(cfg, channel, fs, n) as t:
|
|
88
|
+
reader = AnalogSingleChannelReader(t.in_stream)
|
|
89
|
+
t.start()
|
|
90
|
+
while got < n:
|
|
91
|
+
m = min(cfg.chunk, n - got)
|
|
92
|
+
reader.read_many_sample(buf[got:got + m], number_of_samples_per_channel=m, timeout=m / fs + 10.0)
|
|
93
|
+
seg = buf[got:got + m]
|
|
94
|
+
res = chunk_jump(float(seg.mean()), float(np.max(np.abs(seg))), mu0, cfg.jump_v, cfg.rail_v, cfg.baseline_v)
|
|
95
|
+
if mu0 is None:
|
|
96
|
+
base.append(float(seg.mean()))
|
|
97
|
+
if len(base) >= cfg.baseline_chunks:
|
|
98
|
+
mu0 = float(np.mean(base))
|
|
99
|
+
got += m
|
|
100
|
+
if res:
|
|
101
|
+
kind, reason = res
|
|
102
|
+
with warnings.catch_warnings():
|
|
103
|
+
warnings.filterwarnings("ignore", message=".*200010.*")
|
|
104
|
+
t.stop()
|
|
105
|
+
return buf[:got].copy(), got, {
|
|
106
|
+
"kind": kind,
|
|
107
|
+
"index": got,
|
|
108
|
+
"time_s": got / fs,
|
|
109
|
+
"reason": reason}
|
|
110
|
+
return buf[:got].copy(), got, None
|
AutoSQUID/measurement.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The measurement-cycle state machine: run_cycle (one scan interval). The interval loop lives in the
|
|
2
|
+
notebook (§2); resolve_temp_label sets the auto temperature label before the loop runs.
|
|
3
|
+
|
|
4
|
+
Collects cfg.n_trials CLEAN traces per interval within cfg.max_attempts real acquisitions; each acquisition
|
|
5
|
+
is order-indexed (clean -> {base}_{i}.txt, failed -> {base}_{i}_{JUMP|SURGE|RAIL|BADBASE}.txt); resumes from
|
|
6
|
+
existing files (never overwrites); a bad-baseline / non-clearing reset is systemic and stops the sweep.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
import datetime
|
|
10
|
+
import serial
|
|
11
|
+
|
|
12
|
+
from .analysis import (is_surge_spec, save_pcs102, save_temp_csv, log_experiment, log_action,
|
|
13
|
+
scan_indices, format_temp_label)
|
|
14
|
+
from .config import require_fields
|
|
15
|
+
from .serial_io import fire_reset
|
|
16
|
+
from .daq import acquire_finite_chunked, daq_read
|
|
17
|
+
from .temperature import TempLogger, read_temp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reset_and_verify(cfg, tries=5, settle_s=0.3):
|
|
21
|
+
"Fire reset + verify the locked baseline up to `tries` times; True once |mean| < cfg.baseline_v."
|
|
22
|
+
with serial.Serial(cfg.port, cfg.baud, bytesize=8, parity="N", stopbits=1, timeout=1) as ser:
|
|
23
|
+
for k in range(1, tries + 1):
|
|
24
|
+
fire_reset(ser, cfg)
|
|
25
|
+
time.sleep(settle_s)
|
|
26
|
+
m = daq_read(cfg, cfg.daq_ai, cfg.verify_n, cfg.verify_fs).mean()
|
|
27
|
+
cleared = abs(m) < cfg.baseline_v
|
|
28
|
+
print(f" Reset try {k}: mean={m:+.4f} V -> {'CLEARED' if cleared else 'still off-zero'}")
|
|
29
|
+
if cleared:
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def resolve_temp_label(cfg):
|
|
34
|
+
"If cfg.temp_label == 'auto', read + validate the MXC temperature and set the rounded label on cfg."
|
|
35
|
+
if cfg.temp_label == "auto":
|
|
36
|
+
T = read_temp(cfg)
|
|
37
|
+
if T is None or not (0 < T < 1000):
|
|
38
|
+
raise RuntimeError(f"bad MXC temperature read: {T} (logger down / wrong channel?)")
|
|
39
|
+
cfg.temp_label = format_temp_label(T)
|
|
40
|
+
return cfg.temp_label
|
|
41
|
+
|
|
42
|
+
def run_cycle(cfg, scan_interval_s):
|
|
43
|
+
"""One scan interval: collect cfg.n_trials CLEAN traces within cfg.max_attempts real acquisitions.
|
|
44
|
+
Returns 'reset_fail' (a non-clearing/bad-baseline reset -> stop the sweep) or 'ok'."""
|
|
45
|
+
require_fields(cfg, ["data_root", "user"], "run_cycle")
|
|
46
|
+
if cfg.temp_label == "auto":
|
|
47
|
+
require_fields(cfg, ["temp_reader"], "run_cycle with temp_label='auto'")
|
|
48
|
+
require_fields(cfg, ["daq_ai"], "run_cycle")
|
|
49
|
+
fs = 1.0 / scan_interval_s
|
|
50
|
+
tau_us = int(round(scan_interval_s * 1e6))
|
|
51
|
+
base = cfg.base_name(scan_interval_s) # WRITE stem (carries today's date)
|
|
52
|
+
core = cfg.core_name(scan_interval_s) # date-agnostic core for matching existing traces
|
|
53
|
+
cfg.outdir.mkdir(parents=True, exist_ok=True) # ensure the output folder exists before any reset/write
|
|
54
|
+
ledger = cfg.outdir / "experiment_log.txt"
|
|
55
|
+
actionlog = cfg.outdir / "action_log.txt"
|
|
56
|
+
est = cfg.n_points * scan_interval_s
|
|
57
|
+
n_resets = 0
|
|
58
|
+
|
|
59
|
+
def _log(outcome, **kw):
|
|
60
|
+
"Append a ledger row, filling the per-interval constants; caller passes the varying fields."
|
|
61
|
+
log_experiment(ledger, dict(timestamp=datetime.datetime.now().isoformat(timespec="seconds"),
|
|
62
|
+
scan_interval_us=tau_us, n_target=cfg.n_points, n_resets=n_resets, outcome=outcome, **kw))
|
|
63
|
+
|
|
64
|
+
def _action(action, detail=""):
|
|
65
|
+
"Append a line to the action log (actions + attempted names; never measurement results)."
|
|
66
|
+
log_action(actionlog, action, detail)
|
|
67
|
+
|
|
68
|
+
def _reset(tag, n_clean):
|
|
69
|
+
"reset_and_verify (counts toward n_resets); log RESET/RESET_FAIL with the upcoming index. Returns ok."
|
|
70
|
+
nonlocal n_resets
|
|
71
|
+
ok = reset_and_verify(cfg); n_resets += 1
|
|
72
|
+
_action("RESET" if ok else "RESET_FAIL", f"attempt {tag}")
|
|
73
|
+
if not ok:
|
|
74
|
+
_log("RESET_FAIL", n_clean=n_clean, attempt=tag, filename="")
|
|
75
|
+
return ok
|
|
76
|
+
|
|
77
|
+
n_clean, i = scan_indices(cfg.outdir, core) # count + next index over ALL dates of this core
|
|
78
|
+
if n_clean >= cfg.n_trials:
|
|
79
|
+
print(f"\n=== {base} · already has {n_clean}/{cfg.n_trials} clean; skipping ==="); return "ok"
|
|
80
|
+
if n_clean or i > 1:
|
|
81
|
+
print(f"\n=== {base} · resuming: {n_clean}/{cfg.n_trials} clean on disk; next index {i} ===")
|
|
82
|
+
if not _reset(i, n_clean):
|
|
83
|
+
print(" Reset did not clear (systemic); STOPPING."); return "reset_fail"
|
|
84
|
+
|
|
85
|
+
n_attempts = 0
|
|
86
|
+
while n_clean < cfg.n_trials and n_attempts < cfg.max_attempts:
|
|
87
|
+
print(f"\n=== {base} · index {i} · {n_attempts}/{cfg.max_attempts} used · {n_clean}/{cfg.n_trials} clean · "
|
|
88
|
+
f"{cfg.n_points} pts @ {fs:.0f} Hz (~{est:.0f} s) ===")
|
|
89
|
+
_t0 = datetime.datetime.now()
|
|
90
|
+
print(f" start {_t0:%H:%M:%S} · expected done ~{_t0 + datetime.timedelta(seconds=est):%H:%M:%S} (~{est:.0f} s)")
|
|
91
|
+
try:
|
|
92
|
+
T_now = read_temp(cfg) # MXC temperature just before this attempt
|
|
93
|
+
_action("TEMP", f"{T_now:.4f} K" if T_now is not None else "read failed (None)")
|
|
94
|
+
except Exception as e:
|
|
95
|
+
_action("TEMP", f"read failed ({e})")
|
|
96
|
+
_action("MEASURE", f"{base}_{i}") # attempted name + index (no result)
|
|
97
|
+
tl = TempLogger(cfg); tl.start(); t0 = time.time()
|
|
98
|
+
try:
|
|
99
|
+
v, got, flag = acquire_finite_chunked(cfg, cfg.daq_ai, cfg.n_points, fs)
|
|
100
|
+
finally:
|
|
101
|
+
tl.stop(); tl.join(timeout=cfg.temp_every_s + 5)
|
|
102
|
+
|
|
103
|
+
kind = flag["kind"] if flag else None # classify -> (outcome, file tag, reason); tag is ONE token
|
|
104
|
+
if kind == "bad_baseline": outcome, tag, reason = "BAD_BASELINE", "BADBASE", flag["reason"]
|
|
105
|
+
elif kind == "rail": outcome, tag, reason = "RAIL", "RAIL", flag["reason"]
|
|
106
|
+
elif kind == "jump": outcome, tag, reason = "JUMP", "JUMP", flag["reason"]
|
|
107
|
+
else:
|
|
108
|
+
surged, sreason = is_surge_spec(v, rail_v=cfg.rail_v, first_chunk_v=cfg.baseline_v)
|
|
109
|
+
outcome, tag, reason = ("SURGE", "SURGE", sreason) if surged else ("CLEAN", "", "ok")
|
|
110
|
+
|
|
111
|
+
if outcome != "BAD_BASELINE": n_attempts += 1
|
|
112
|
+
if outcome == "CLEAN": n_clean += 1
|
|
113
|
+
stem = f"{base}_{i}" + (f"_{tag}" if tag else "")
|
|
114
|
+
save_pcs102(cfg.outdir / f"{stem}.txt", v, scan_interval_s)
|
|
115
|
+
save_temp_csv(cfg.outdir / (stem.replace("DAQ", "TEMP", 1) + ".csv"), tl.samples) # TEMP_<core>_<i>.csv
|
|
116
|
+
Ts = [s[1] for s in tl.samples] or [float("nan")]
|
|
117
|
+
_log(outcome, n_acquired=got, n_clean=n_clean, attempt=i, filename=f"{stem}.txt",
|
|
118
|
+
jump_index=flag["index"] if flag else -1, jump_time_s=f"{flag['time_s']:.3f}" if flag else "",
|
|
119
|
+
mean_V=f"{v.mean():.6f}", std_V=f"{v.std():.6f}", T_start_K=f"{Ts[0]:.6f}", T_end_K=f"{Ts[-1]:.6f}")
|
|
120
|
+
print(f" {outcome} ({reason}) · idx {i} · N={got} · {time.time()-t0:.0f}s · "
|
|
121
|
+
f"mean={v.mean():+.4f} std={v.std():.4f} V · T {Ts[0]:.4f}->{Ts[-1]:.4f} K · {n_clean}/{cfg.n_trials} clean")
|
|
122
|
+
i += 1
|
|
123
|
+
|
|
124
|
+
if outcome == "BAD_BASELINE":
|
|
125
|
+
_action("BAD_BASELINE", reason)
|
|
126
|
+
print(" Bad baseline -> reset is not holding (systemic); STOPPING."); return "reset_fail"
|
|
127
|
+
if outcome == "CLEAN":
|
|
128
|
+
continue
|
|
129
|
+
if n_clean < cfg.n_trials and n_attempts < cfg.max_attempts and not _reset(i, n_clean):
|
|
130
|
+
print(" Reset did not clear (systemic); STOPPING."); return "reset_fail"
|
|
131
|
+
|
|
132
|
+
if n_clean >= cfg.n_trials:
|
|
133
|
+
print(f" DONE: {n_clean}/{cfg.n_trials} clean traces in {n_attempts} acquisition(s) this run.")
|
|
134
|
+
else:
|
|
135
|
+
_log("BUDGET_EXHAUSTED", n_clean=n_clean, attempt="", jump_index=-1, jump_time_s="", filename="")
|
|
136
|
+
print(f" BUDGET EXHAUSTED: {n_clean}/{cfg.n_trials} clean after {cfg.max_attempts} acquisitions; moving on.")
|
|
137
|
+
return "ok"
|
|
138
|
+
|
|
139
|
+
|