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 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
@@ -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
+