pytestlab 0.2.1__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.
- pytestlab/__init__.py +69 -0
- pytestlab/_log.py +62 -0
- pytestlab/analysis/__init__.py +7 -0
- pytestlab/analysis/fft.py +91 -0
- pytestlab/analysis/fr_analysis.py +38 -0
- pytestlab/bench.py +618 -0
- pytestlab/cli.py +916 -0
- pytestlab/common/__init__.py +5 -0
- pytestlab/common/enums.py +89 -0
- pytestlab/common/health.py +19 -0
- pytestlab/compliance/__init__.py +19 -0
- pytestlab/compliance/audit.py +88 -0
- pytestlab/compliance/patch.py +120 -0
- pytestlab/compliance/signature.py +146 -0
- pytestlab/compliance/tsa.py +51 -0
- pytestlab/config/__init__.py +16 -0
- pytestlab/config/_mixins.py +37 -0
- pytestlab/config/accuracy.py +62 -0
- pytestlab/config/base.py +21 -0
- pytestlab/config/bench_config.py +80 -0
- pytestlab/config/bench_loader.py +36 -0
- pytestlab/config/config.py +20 -0
- pytestlab/config/dc_active_load_config.py +98 -0
- pytestlab/config/instrument_config.py +31 -0
- pytestlab/config/loader.py +179 -0
- pytestlab/config/multimeter_config.py +145 -0
- pytestlab/config/oscilloscope_config.py +63 -0
- pytestlab/config/power_meter_config.py +14 -0
- pytestlab/config/power_supply_config.py +78 -0
- pytestlab/config/spectrum_analyzer_config.py +18 -0
- pytestlab/config/virtual_instrument_config.py +7 -0
- pytestlab/config/vna_config.py +16 -0
- pytestlab/config/waveform_generator_config.py +38 -0
- pytestlab/errors.py +145 -0
- pytestlab/experiments/__init__.py +6 -0
- pytestlab/experiments/database.py +724 -0
- pytestlab/experiments/experiments.py +164 -0
- pytestlab/experiments/results.py +357 -0
- pytestlab/experiments/sweep.py +658 -0
- pytestlab/gui/__init__.py +23 -0
- pytestlab/gui/async_utils.py +63 -0
- pytestlab/gui/builder.py +209 -0
- pytestlab/instruments/AutoInstrument.py +565 -0
- pytestlab/instruments/DCActiveLoad.py +361 -0
- pytestlab/instruments/Multimeter.py +309 -0
- pytestlab/instruments/Oscilloscope.py +1643 -0
- pytestlab/instruments/PowerMeter.py +86 -0
- pytestlab/instruments/PowerSupply.py +539 -0
- pytestlab/instruments/SpectrumAnalyser.py +55 -0
- pytestlab/instruments/VectorNetworkAnalyser.py +72 -0
- pytestlab/instruments/VirtualInstrument.py +79 -0
- pytestlab/instruments/WaveformGenerator.py +1704 -0
- pytestlab/instruments/__init__.py +8 -0
- pytestlab/instruments/backends/__init__.py +6 -0
- pytestlab/instruments/backends/async_visa_backend.py +167 -0
- pytestlab/instruments/backends/lamb.py +189 -0
- pytestlab/instruments/backends/recording_backend.py +110 -0
- pytestlab/instruments/backends/replay_backend.py +162 -0
- pytestlab/instruments/backends/session_recording_backend.py +148 -0
- pytestlab/instruments/backends/sim_backend.py +658 -0
- pytestlab/instruments/backends/visa_backend.py +134 -0
- pytestlab/instruments/instrument.py +670 -0
- pytestlab/instruments/scpi_engine.py +481 -0
- pytestlab/measurements/__init__.py +8 -0
- pytestlab/measurements/session.py +365 -0
- pytestlab/profiles/__init__.py +0 -0
- pytestlab/profiles/keysight/34460A.yaml +27 -0
- pytestlab/profiles/keysight/34470A.yaml +30 -0
- pytestlab/profiles/keysight/DSOX1202G.yaml +102 -0
- pytestlab/profiles/keysight/DSOX1204G.yaml +132 -0
- pytestlab/profiles/keysight/DSOX3054G.yaml +122 -0
- pytestlab/profiles/keysight/E36313A.yaml +37 -0
- pytestlab/profiles/keysight/E5071C_VNA.yaml +13 -0
- pytestlab/profiles/keysight/EDU33212A.yaml +53 -0
- pytestlab/profiles/keysight/EDU34450A.yaml +176 -0
- pytestlab/profiles/keysight/EDU36311A.yaml +122 -0
- pytestlab/profiles/keysight/EDU36311A_recorded.yaml +48 -0
- pytestlab/profiles/keysight/EL33133A.yaml +126 -0
- pytestlab/profiles/keysight/HD304MSO.yaml +148 -0
- pytestlab/profiles/keysight/MSOX2024A.yaml +122 -0
- pytestlab/profiles/keysight/MXR404A.yaml +102 -0
- pytestlab/profiles/keysight/N9000A_SA.yaml +12 -0
- pytestlab/profiles/keysight/U2000A_PM.yaml +10 -0
- pytestlab/profiles/pytestlab/binary_wave_data.bin +1 -0
- pytestlab/profiles/pytestlab/virtual_instrument.yaml +68 -0
- pytestlab/profiles/rohdeschwarz/NGE102B.yaml +144 -0
- pytestlab/schemas/awg.json +166 -0
- pytestlab/schemas/dc_active_load.json +511 -0
- pytestlab/schemas/dmm.json +120 -0
- pytestlab/schemas/oscilloscope.json +316 -0
- pytestlab/schemas/psu.json +86 -0
- pytestlab-0.2.1.dist-info/METADATA +317 -0
- pytestlab-0.2.1.dist-info/RECORD +95 -0
- pytestlab-0.2.1.dist-info/WHEEL +4 -0
- pytestlab-0.2.1.dist-info/entry_points.txt +2 -0
pytestlab/bench.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Union, Dict, Any, Optional, List
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from .config.bench_loader import load_bench_yaml, build_validation_context, run_custom_validations
|
|
8
|
+
from .config.bench_config import BenchConfigExtended, InstrumentEntry
|
|
9
|
+
from .instruments import AutoInstrument
|
|
10
|
+
from .instruments.instrument import Instrument
|
|
11
|
+
from .common.health import HealthReport, HealthStatus
|
|
12
|
+
from .experiments.experiments import Experiment
|
|
13
|
+
from .experiments.database import MeasurementDatabase
|
|
14
|
+
|
|
15
|
+
# Configure logging
|
|
16
|
+
logger = logging.getLogger("pytestlab.bench")
|
|
17
|
+
|
|
18
|
+
class SafetyLimitError(Exception):
|
|
19
|
+
"""Raised when an operation violates safety limits."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
class InstrumentMacroError(Exception):
|
|
23
|
+
"""Raised when an automation macro fails to execute."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
class SafeInstrumentWrapper:
|
|
27
|
+
"""Wraps an instrument to enforce safety limits defined in the bench config.
|
|
28
|
+
|
|
29
|
+
This class acts as a proxy to an underlying instrument object. It intercepts
|
|
30
|
+
calls to methods that could be dangerous (like `set_voltage` on a power
|
|
31
|
+
supply) and checks them against the defined safety limits before passing
|
|
32
|
+
the call to the actual instrument. This helps prevent accidental damage to
|
|
33
|
+
equipment or the device under test.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
_inst: The actual instrument instance being wrapped.
|
|
37
|
+
_safety_limits: The safety limit configuration for this instrument.
|
|
38
|
+
_instrument_type: Type of instrument being wrapped (e.g., 'power_supply', 'waveform_generator').
|
|
39
|
+
"""
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
instrument: Instrument,
|
|
43
|
+
safety_limits: Any,
|
|
44
|
+
instrument_type: Optional[str] = None
|
|
45
|
+
):
|
|
46
|
+
self._inst = instrument
|
|
47
|
+
self._safety_limits = safety_limits
|
|
48
|
+
self._instrument_type = instrument_type or self._detect_instrument_type()
|
|
49
|
+
|
|
50
|
+
def _detect_instrument_type(self) -> str:
|
|
51
|
+
"""Attempt to detect instrument type based on available methods."""
|
|
52
|
+
if hasattr(self._inst, "set_voltage") and hasattr(self._inst, "set_current"):
|
|
53
|
+
return "power_supply"
|
|
54
|
+
elif hasattr(self._inst, "set_frequency") and hasattr(self._inst, "set_amplitude"):
|
|
55
|
+
return "waveform_generator"
|
|
56
|
+
elif hasattr(self._inst, "set_load") and hasattr(self._inst, "set_mode"):
|
|
57
|
+
return "dc_active_load"
|
|
58
|
+
return "unknown"
|
|
59
|
+
|
|
60
|
+
def __getattr__(self, name):
|
|
61
|
+
"""Dynamically wraps methods to enforce safety checks."""
|
|
62
|
+
orig = getattr(self._inst, name)
|
|
63
|
+
|
|
64
|
+
# Power Supply safety limits
|
|
65
|
+
if self._instrument_type == "power_supply":
|
|
66
|
+
if name == "set_voltage":
|
|
67
|
+
return self._safe_set_voltage_wrapper(orig)
|
|
68
|
+
elif name == "set_current":
|
|
69
|
+
return self._safe_set_current_wrapper(orig)
|
|
70
|
+
|
|
71
|
+
# Waveform Generator safety limits
|
|
72
|
+
elif self._instrument_type == "waveform_generator":
|
|
73
|
+
if name == "set_amplitude":
|
|
74
|
+
return self._safe_set_amplitude_wrapper(orig)
|
|
75
|
+
elif name == "set_frequency":
|
|
76
|
+
return self._safe_set_frequency_wrapper(orig)
|
|
77
|
+
|
|
78
|
+
# DC Active Load safety limits
|
|
79
|
+
elif self._instrument_type == "dc_active_load":
|
|
80
|
+
if name == "set_load":
|
|
81
|
+
return self._safe_set_load_wrapper(orig)
|
|
82
|
+
|
|
83
|
+
# For any other method, return it unwrapped
|
|
84
|
+
return orig
|
|
85
|
+
|
|
86
|
+
def _safe_set_voltage_wrapper(self, orig_method):
|
|
87
|
+
"""Wraps set_voltage method with safety checks."""
|
|
88
|
+
def safe_set_voltage(channel, voltage, *a, **k):
|
|
89
|
+
max_v = None
|
|
90
|
+
# Check if channel-specific voltage limits are defined
|
|
91
|
+
if self._safety_limits and self._safety_limits.channels:
|
|
92
|
+
ch_limits = self._safety_limits.channels.get(channel)
|
|
93
|
+
if ch_limits and ch_limits.voltage and "max" in ch_limits.voltage:
|
|
94
|
+
max_v = ch_limits.voltage["max"]
|
|
95
|
+
# If a limit is found, check if the requested voltage exceeds it
|
|
96
|
+
if max_v is not None and voltage > max_v:
|
|
97
|
+
raise SafetyLimitError(
|
|
98
|
+
f"Refusing to set voltage {voltage}V, which is above the safety limit of {max_v}V."
|
|
99
|
+
)
|
|
100
|
+
# If safe, call the original method
|
|
101
|
+
return orig_method(channel, voltage, *a, **k)
|
|
102
|
+
return safe_set_voltage
|
|
103
|
+
|
|
104
|
+
def _safe_set_current_wrapper(self, orig_method):
|
|
105
|
+
"""Wraps set_current method with safety checks."""
|
|
106
|
+
def safe_set_current(channel, current, *a, **k):
|
|
107
|
+
max_c = None
|
|
108
|
+
if self._safety_limits and self._safety_limits.channels:
|
|
109
|
+
ch_limits = self._safety_limits.channels.get(channel)
|
|
110
|
+
if ch_limits and ch_limits.current and "max" in ch_limits.current:
|
|
111
|
+
max_c = ch_limits.current["max"]
|
|
112
|
+
if max_c is not None and current > max_c:
|
|
113
|
+
raise SafetyLimitError(
|
|
114
|
+
f"Refusing to set current {current}A, which is above the safety limit of {max_c}A."
|
|
115
|
+
)
|
|
116
|
+
return orig_method(channel, current, *a, **k)
|
|
117
|
+
return safe_set_current
|
|
118
|
+
|
|
119
|
+
def _safe_set_amplitude_wrapper(self, orig_method):
|
|
120
|
+
"""Wraps set_amplitude method with safety checks."""
|
|
121
|
+
def safe_set_amplitude(channel, amplitude, *a, **k):
|
|
122
|
+
max_amp = None
|
|
123
|
+
if self._safety_limits and self._safety_limits.channels:
|
|
124
|
+
ch_limits = self._safety_limits.channels.get(channel)
|
|
125
|
+
if ch_limits and ch_limits.amplitude and "max" in ch_limits.amplitude:
|
|
126
|
+
max_amp = ch_limits.amplitude["max"]
|
|
127
|
+
if max_amp is not None and amplitude > max_amp:
|
|
128
|
+
raise SafetyLimitError(
|
|
129
|
+
f"Refusing to set amplitude {amplitude}V, which is above the safety limit of {max_amp}V."
|
|
130
|
+
)
|
|
131
|
+
return orig_method(channel, amplitude, *a, **k)
|
|
132
|
+
return safe_set_amplitude
|
|
133
|
+
|
|
134
|
+
def _safe_set_frequency_wrapper(self, orig_method):
|
|
135
|
+
"""Wraps set_frequency method with safety checks."""
|
|
136
|
+
def safe_set_frequency(channel, frequency, *a, **k):
|
|
137
|
+
max_freq = None
|
|
138
|
+
if self._safety_limits and self._safety_limits.channels:
|
|
139
|
+
ch_limits = self._safety_limits.channels.get(channel)
|
|
140
|
+
if ch_limits and ch_limits.frequency and "max" in ch_limits.frequency:
|
|
141
|
+
max_freq = ch_limits.frequency["max"]
|
|
142
|
+
if max_freq is not None and frequency > max_freq:
|
|
143
|
+
raise SafetyLimitError(
|
|
144
|
+
f"Refusing to set frequency {frequency}Hz, which is above the safety limit of {max_freq}Hz."
|
|
145
|
+
)
|
|
146
|
+
return orig_method(channel, frequency, *a, **k)
|
|
147
|
+
return safe_set_frequency
|
|
148
|
+
|
|
149
|
+
def _safe_set_load_wrapper(self, orig_method):
|
|
150
|
+
"""Wraps set_load method with safety checks for DC Active Loads."""
|
|
151
|
+
def safe_set_load(value, *a, **k):
|
|
152
|
+
max_load = None
|
|
153
|
+
if self._safety_limits and self._safety_limits.load and "max" in self._safety_limits.load:
|
|
154
|
+
max_load = self._safety_limits.load["max"]
|
|
155
|
+
if max_load is not None and value > max_load:
|
|
156
|
+
raise SafetyLimitError(
|
|
157
|
+
f"Refusing to set load to {value}, which is above the safety limit of {max_load}."
|
|
158
|
+
)
|
|
159
|
+
return orig_method(value, *a, **k)
|
|
160
|
+
return safe_set_load
|
|
161
|
+
|
|
162
|
+
class Bench:
|
|
163
|
+
"""Manages a collection of test instruments as a single entity.
|
|
164
|
+
|
|
165
|
+
The `Bench` class is the primary entry point for interacting with a test setup
|
|
166
|
+
defined in a YAML configuration file. It handles:
|
|
167
|
+
- Loading and validating the bench configuration.
|
|
168
|
+
- Asynchronously initializing and connecting to all specified instruments.
|
|
169
|
+
- Wrapping instruments with safety limit enforcement where specified.
|
|
170
|
+
- Running pre- and post-experiment automation hooks.
|
|
171
|
+
- Providing easy access to instruments by their aliases (e.g., `bench.psu1`).
|
|
172
|
+
- Exposing traceability and planning information from the config.
|
|
173
|
+
"""
|
|
174
|
+
def __init__(self, config: BenchConfigExtended):
|
|
175
|
+
self.config = config
|
|
176
|
+
self._instrument_instances: Dict[str, Instrument] = {}
|
|
177
|
+
self._instrument_wrappers: Dict[str, Any] = {}
|
|
178
|
+
self._channel_config: Dict[str, List[int]] = {} # Stores channel config for each instrument
|
|
179
|
+
self._experiment: Optional[Experiment] = None
|
|
180
|
+
self._db: Optional[MeasurementDatabase] = None
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def open(cls, filepath: Union[str, Path]) -> "Bench":
|
|
184
|
+
"""Loads, validates, and initializes a bench from a YAML configuration file.
|
|
185
|
+
|
|
186
|
+
This class method acts as the main factory for creating a `Bench` instance.
|
|
187
|
+
It orchestrates the loading of the YAML file, the execution of any custom
|
|
188
|
+
validation rules, and the asynchronous initialization of all instruments.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
filepath: The path to the bench.yaml configuration file.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A fully initialized `Bench` instance, ready for use.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
FileNotFoundError: If the specified YAML file doesn't exist.
|
|
198
|
+
ValidationError: If the configuration fails validation.
|
|
199
|
+
InstrumentConfigurationError: If instrument configuration is invalid.
|
|
200
|
+
"""
|
|
201
|
+
logger.info(f"Loading bench configuration from {filepath}")
|
|
202
|
+
config = load_bench_yaml(filepath)
|
|
203
|
+
|
|
204
|
+
# Run custom validations
|
|
205
|
+
logger.debug("Running custom validations on bench configuration")
|
|
206
|
+
context = build_validation_context(config)
|
|
207
|
+
run_custom_validations(config, context)
|
|
208
|
+
|
|
209
|
+
bench = cls(config)
|
|
210
|
+
bench._initialize_instruments()
|
|
211
|
+
bench._run_automation_hook("pre_experiment")
|
|
212
|
+
logger.info(f"Bench '{config.bench_name}' initialized successfully")
|
|
213
|
+
|
|
214
|
+
# Initialize the experiment and database
|
|
215
|
+
bench.initialize_experiment()
|
|
216
|
+
bench.initialize_database()
|
|
217
|
+
|
|
218
|
+
return bench
|
|
219
|
+
|
|
220
|
+
def _initialize_instruments(self):
|
|
221
|
+
"""Initializes and connects to all instruments defined in the config."""
|
|
222
|
+
# Importing compliance ensures that the necessary patches are applied
|
|
223
|
+
# before any instruments are created, which might generate results.
|
|
224
|
+
|
|
225
|
+
logger.info("Initializing instruments")
|
|
226
|
+
connection_errors = []
|
|
227
|
+
|
|
228
|
+
for alias, entry in self.config.instruments.items():
|
|
229
|
+
try:
|
|
230
|
+
self._initialize_instrument(alias, entry)
|
|
231
|
+
logger.info(f"Instrument '{alias}' initialized successfully")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
error_msg = f"Failed to initialize instrument '{alias}': {str(e)}"
|
|
234
|
+
logger.error(error_msg)
|
|
235
|
+
connection_errors.append(error_msg)
|
|
236
|
+
|
|
237
|
+
# Continue with other instruments even if one fails
|
|
238
|
+
if getattr(self.config, 'continue_on_instrument_error', False):
|
|
239
|
+
warnings.warn(f"Failed to initialize instrument '{alias}'. Continuing with other instruments.", UserWarning)
|
|
240
|
+
else:
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
if connection_errors:
|
|
244
|
+
logger.warning(f"Some instruments failed to connect: {len(connection_errors)} errors")
|
|
245
|
+
|
|
246
|
+
def _initialize_instrument(self, alias: str, entry: InstrumentEntry):
|
|
247
|
+
"""Initialize a single instrument from its configuration entry."""
|
|
248
|
+
# Determine the final simulation mode
|
|
249
|
+
simulate_flag = self.config.simulate
|
|
250
|
+
if entry.simulate is not None:
|
|
251
|
+
simulate_flag = entry.simulate
|
|
252
|
+
|
|
253
|
+
# Extract backend hints
|
|
254
|
+
backend_type_hint = None
|
|
255
|
+
timeout_override_ms = None
|
|
256
|
+
if entry.backend:
|
|
257
|
+
backend_type_hint = entry.backend.get("type")
|
|
258
|
+
timeout_override_ms = entry.backend.get("timeout_ms")
|
|
259
|
+
|
|
260
|
+
# Extract channel configuration if present
|
|
261
|
+
# (No channel config in InstrumentEntry; skip extraction)
|
|
262
|
+
|
|
263
|
+
# Create instrument instance
|
|
264
|
+
logger.debug(f"Creating instrument '{alias}' from profile '{entry.profile}'")
|
|
265
|
+
instrument = AutoInstrument.from_config(
|
|
266
|
+
config_source=entry.profile,
|
|
267
|
+
simulate=simulate_flag,
|
|
268
|
+
backend_type_hint=backend_type_hint,
|
|
269
|
+
address_override=entry.address,
|
|
270
|
+
serial_number=entry.serial_number, # <-- Pass serial_number to factory
|
|
271
|
+
timeout_override_ms=timeout_override_ms
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Connect to the backend
|
|
275
|
+
logger.debug(f"Connecting instrument '{alias}' to backend")
|
|
276
|
+
instrument.connect_backend()
|
|
277
|
+
|
|
278
|
+
# Detect instrument type for safety wrapper
|
|
279
|
+
instrument_type = self._detect_instrument_type(instrument)
|
|
280
|
+
|
|
281
|
+
# Apply safety limits if configured
|
|
282
|
+
if entry.safety_limits:
|
|
283
|
+
wrapped = SafeInstrumentWrapper(instrument, entry.safety_limits, instrument_type)
|
|
284
|
+
logger.debug(f"Instrument '{alias}' is running with a safety wrapper")
|
|
285
|
+
self._instrument_instances[alias] = instrument
|
|
286
|
+
self._instrument_wrappers[alias] = wrapped
|
|
287
|
+
setattr(self, alias, wrapped)
|
|
288
|
+
else:
|
|
289
|
+
# Otherwise, add the raw instrument to the bench
|
|
290
|
+
self._instrument_instances[alias] = instrument
|
|
291
|
+
setattr(self, alias, instrument)
|
|
292
|
+
|
|
293
|
+
def _detect_instrument_type(self, instrument: Instrument) -> str:
|
|
294
|
+
"""Detect the type of instrument based on its methods and attributes."""
|
|
295
|
+
if hasattr(instrument, "set_voltage") and hasattr(instrument, "set_current"):
|
|
296
|
+
return "power_supply"
|
|
297
|
+
elif hasattr(instrument, "set_frequency") and hasattr(instrument, "set_amplitude"):
|
|
298
|
+
return "waveform_generator"
|
|
299
|
+
elif hasattr(instrument, "set_load") and hasattr(instrument, "set_mode"):
|
|
300
|
+
return "dc_active_load"
|
|
301
|
+
elif hasattr(instrument, "set_measurement_function") and hasattr(instrument, "measure"):
|
|
302
|
+
return "multimeter"
|
|
303
|
+
elif hasattr(instrument, "set_timebase") and hasattr(instrument, "read_channels"):
|
|
304
|
+
return "oscilloscope"
|
|
305
|
+
return "unknown"
|
|
306
|
+
|
|
307
|
+
def _run_automation_hook(self, hook: str):
|
|
308
|
+
"""Executes automation commands for a given hook (e.g., 'pre_experiment').
|
|
309
|
+
|
|
310
|
+
This method runs a series of commands defined in the `automation` section
|
|
311
|
+
of the bench config. It supports running shell commands, Python scripts,
|
|
312
|
+
and instrument macros.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
hook: The name of the hook to run (e.g., "pre_experiment").
|
|
316
|
+
"""
|
|
317
|
+
hooks = getattr(self.config.automation, hook, None) if self.config.automation else None
|
|
318
|
+
if not hooks:
|
|
319
|
+
logger.debug(f"No automation hooks defined for '{hook}'")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
logger.info(f"Executing {len(hooks)} automation hooks for '{hook}'")
|
|
323
|
+
|
|
324
|
+
for i, cmd in enumerate(hooks, 1):
|
|
325
|
+
logger.debug(f"Running automation hook {i}/{len(hooks)}: {cmd}")
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
if cmd.strip().startswith("python "):
|
|
329
|
+
self._run_python_script(cmd)
|
|
330
|
+
elif ":" in cmd:
|
|
331
|
+
self._run_instrument_macro(cmd)
|
|
332
|
+
else:
|
|
333
|
+
self._run_shell_command(cmd)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
error_msg = f"Failed to execute automation hook: {cmd}. Error: {str(e)}"
|
|
336
|
+
logger.error(error_msg)
|
|
337
|
+
if not getattr(self.config, 'continue_on_automation_error', False):
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
def _run_python_script(self, cmd: str):
|
|
341
|
+
"""Run a Python script as part of an automation hook."""
|
|
342
|
+
script = cmd.strip().split(" ", 1)[1]
|
|
343
|
+
logger.info(f"[Automation] Running Python script: {script}")
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
result = subprocess.run(
|
|
347
|
+
[sys.executable, script],
|
|
348
|
+
check=True,
|
|
349
|
+
capture_output=True,
|
|
350
|
+
text=True
|
|
351
|
+
)
|
|
352
|
+
logger.debug(f"Script output: {result.stdout.strip()}")
|
|
353
|
+
if result.stderr:
|
|
354
|
+
logger.warning(f"Script stderr: {result.stderr.strip()}")
|
|
355
|
+
except subprocess.CalledProcessError as e:
|
|
356
|
+
logger.error(f"Script execution failed: {e}")
|
|
357
|
+
if e.stdout:
|
|
358
|
+
logger.debug(f"Script stdout: {e.stdout.strip()}")
|
|
359
|
+
if e.stderr:
|
|
360
|
+
logger.error(f"Script stderr: {e.stderr.strip()}")
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
def _run_shell_command(self, cmd: str):
|
|
364
|
+
"""Run a shell command as part of an automation hook."""
|
|
365
|
+
logger.info(f"[Automation] Running shell command: {cmd}")
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
result = subprocess.run(
|
|
369
|
+
cmd,
|
|
370
|
+
shell=True,
|
|
371
|
+
check=True,
|
|
372
|
+
capture_output=True,
|
|
373
|
+
text=True
|
|
374
|
+
)
|
|
375
|
+
logger.debug(f"Command output: {result.stdout.strip()}")
|
|
376
|
+
if result.stderr:
|
|
377
|
+
logger.warning(f"Command stderr: {result.stderr.strip()}")
|
|
378
|
+
except subprocess.CalledProcessError as e:
|
|
379
|
+
logger.error(f"Command execution failed: {e}")
|
|
380
|
+
if e.stdout:
|
|
381
|
+
logger.debug(f"Command stdout: {e.stdout.strip()}")
|
|
382
|
+
if e.stderr:
|
|
383
|
+
logger.error(f"Command stderr: {e.stderr.strip()}")
|
|
384
|
+
raise
|
|
385
|
+
|
|
386
|
+
def _run_instrument_macro(self, cmd: str):
|
|
387
|
+
"""Run an instrument macro command as part of an automation hook."""
|
|
388
|
+
alias, instr_cmd = cmd.split(":", 1)
|
|
389
|
+
alias = alias.strip()
|
|
390
|
+
instr_cmd = instr_cmd.strip()
|
|
391
|
+
|
|
392
|
+
# Get the instrument instance (wrapper or raw)
|
|
393
|
+
inst = self._instrument_wrappers.get(alias) or self._instrument_instances.get(alias)
|
|
394
|
+
if inst is None:
|
|
395
|
+
error_msg = f"Instrument '{alias}' not found for macro '{cmd}'"
|
|
396
|
+
logger.error(error_msg)
|
|
397
|
+
raise InstrumentMacroError(error_msg)
|
|
398
|
+
|
|
399
|
+
logger.info(f"[Automation] Running instrument macro: {alias}: {instr_cmd}")
|
|
400
|
+
|
|
401
|
+
# Handle common macros
|
|
402
|
+
if instr_cmd.lower() == "output all off":
|
|
403
|
+
self._execute_output_all_off(inst, alias)
|
|
404
|
+
elif instr_cmd.lower() == "autoscale":
|
|
405
|
+
self._execute_autoscale(inst, alias)
|
|
406
|
+
else:
|
|
407
|
+
self._execute_custom_macro(inst, alias, instr_cmd)
|
|
408
|
+
|
|
409
|
+
def _execute_output_all_off(self, inst, alias: str):
|
|
410
|
+
"""Execute the 'output all OFF' macro for an instrument."""
|
|
411
|
+
if not hasattr(inst, "output"):
|
|
412
|
+
error_msg = f"Instrument '{alias}' does not support 'output' method"
|
|
413
|
+
logger.error(error_msg)
|
|
414
|
+
raise InstrumentMacroError(error_msg)
|
|
415
|
+
|
|
416
|
+
# Get channels for this instrument from config or use default range
|
|
417
|
+
channels = self._channel_config.get(alias, range(1, 4))
|
|
418
|
+
|
|
419
|
+
# Turn off all channels
|
|
420
|
+
errors = []
|
|
421
|
+
for ch in channels:
|
|
422
|
+
try:
|
|
423
|
+
logger.debug(f"Turning off output for {alias} channel {ch}")
|
|
424
|
+
inst.output(ch, False)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
error_msg = f"Failed to turn off output for {alias} channel {ch}: {str(e)}"
|
|
427
|
+
logger.warning(error_msg)
|
|
428
|
+
errors.append(error_msg)
|
|
429
|
+
|
|
430
|
+
if errors:
|
|
431
|
+
logger.warning(f"{len(errors)} errors occurred while turning off outputs")
|
|
432
|
+
if not getattr(self.config, 'continue_on_automation_error', False):
|
|
433
|
+
raise InstrumentMacroError(f"Failed to turn off all outputs for '{alias}'")
|
|
434
|
+
|
|
435
|
+
def _execute_autoscale(self, inst, alias: str):
|
|
436
|
+
"""Execute the 'autoscale' macro for an instrument."""
|
|
437
|
+
if not hasattr(inst, "auto_scale"):
|
|
438
|
+
error_msg = f"Instrument '{alias}' does not support 'auto_scale' method"
|
|
439
|
+
logger.error(error_msg)
|
|
440
|
+
raise InstrumentMacroError(error_msg)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
logger.debug(f"Executing auto scale for {alias}")
|
|
444
|
+
inst.auto_scale()
|
|
445
|
+
except Exception as e:
|
|
446
|
+
error_msg = f"Failed to autoscale for {alias}: {str(e)}"
|
|
447
|
+
logger.error(error_msg)
|
|
448
|
+
raise InstrumentMacroError(error_msg) from e
|
|
449
|
+
|
|
450
|
+
def _execute_custom_macro(self, inst, alias: str, macro: str):
|
|
451
|
+
"""Execute a custom macro command."""
|
|
452
|
+
logger.warning(f"Unknown macro for {alias}: {macro}. Custom macros not implemented.")
|
|
453
|
+
|
|
454
|
+
def close_all(self):
|
|
455
|
+
"""Runs post-experiment hooks and closes all instrument connections."""
|
|
456
|
+
logger.info("Closing bench and running post-experiment hooks")
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
self._run_automation_hook("post_experiment")
|
|
460
|
+
except Exception as e:
|
|
461
|
+
logger.error(f"Error in post-experiment hooks: {str(e)}")
|
|
462
|
+
|
|
463
|
+
# Close all instrument connections
|
|
464
|
+
logger.debug("Closing instrument connections")
|
|
465
|
+
close_tasks = [
|
|
466
|
+
inst.close() for inst in self._instrument_instances.values()
|
|
467
|
+
if hasattr(inst, "close")
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
if close_tasks:
|
|
471
|
+
results = []
|
|
472
|
+
for task in close_tasks:
|
|
473
|
+
try:
|
|
474
|
+
if callable(task):
|
|
475
|
+
result = task()
|
|
476
|
+
else:
|
|
477
|
+
result = task
|
|
478
|
+
results.append(result)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
results.append(e)
|
|
481
|
+
errors = [r for r in results if isinstance(r, Exception)]
|
|
482
|
+
if errors:
|
|
483
|
+
logger.error(f"Errors during instrument cleanup: {errors}")
|
|
484
|
+
logger.error(f"{len(errors)} errors occurred while closing instruments")
|
|
485
|
+
for err in errors:
|
|
486
|
+
logger.error(f"Instrument close error: {str(err)}")
|
|
487
|
+
|
|
488
|
+
def health_check(self) -> Dict[str, HealthReport]:
|
|
489
|
+
"""Run health checks on all instruments that support it.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
A dictionary mapping instrument aliases to their health reports.
|
|
493
|
+
"""
|
|
494
|
+
logger.info("Running health check on all instruments")
|
|
495
|
+
health_reports = {}
|
|
496
|
+
|
|
497
|
+
for alias, inst in self._instrument_instances.items():
|
|
498
|
+
if hasattr(inst, "health_check"):
|
|
499
|
+
try:
|
|
500
|
+
logger.debug(f"Running health check for {alias}")
|
|
501
|
+
health_reports[alias] = inst.health_check()
|
|
502
|
+
except Exception as e:
|
|
503
|
+
logger.error(f"Health check failed for {alias}: {str(e)}")
|
|
504
|
+
health_reports[alias] = HealthReport(
|
|
505
|
+
status=HealthStatus.ERROR,
|
|
506
|
+
errors=[f"Health check failed: {str(e)}"]
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
logger.debug(f"Instrument {alias} does not support health checks")
|
|
510
|
+
|
|
511
|
+
return health_reports
|
|
512
|
+
|
|
513
|
+
def __enter__(self):
|
|
514
|
+
"""Synchronous context manager entry."""
|
|
515
|
+
return self
|
|
516
|
+
|
|
517
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
518
|
+
"""Synchronous context manager exit."""
|
|
519
|
+
self.close_all()
|
|
520
|
+
|
|
521
|
+
def __aenter__(self):
|
|
522
|
+
"""Async context manager entry."""
|
|
523
|
+
return self
|
|
524
|
+
|
|
525
|
+
def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
526
|
+
"""Async context manager exit."""
|
|
527
|
+
self.close_all()
|
|
528
|
+
|
|
529
|
+
def __getattr__(self, name: str) -> Instrument:
|
|
530
|
+
"""Access instruments by alias."""
|
|
531
|
+
if name in self._instrument_wrappers:
|
|
532
|
+
return self._instrument_wrappers[name]
|
|
533
|
+
if name in self._instrument_instances:
|
|
534
|
+
return self._instrument_instances[name]
|
|
535
|
+
raise AttributeError(f"The bench has no instrument with the alias '{name}'.")
|
|
536
|
+
|
|
537
|
+
def __dir__(self):
|
|
538
|
+
"""Include instrument aliases in dir() output for autocomplete."""
|
|
539
|
+
return list(super().__dir__()) + list(self._instrument_instances.keys())
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def instruments(self) -> Dict[str, Instrument]:
|
|
543
|
+
"""Provides programmatic access to all instrument instances.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
A dictionary where keys are instrument aliases and values are the
|
|
547
|
+
corresponding instrument instances.
|
|
548
|
+
"""
|
|
549
|
+
return self._instrument_instances
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def experiment(self) -> Optional[Experiment]:
|
|
553
|
+
"""Access the managed Experiment object."""
|
|
554
|
+
return self._experiment
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def db(self) -> Optional[MeasurementDatabase]:
|
|
558
|
+
"""Access the managed MeasurementDatabase object."""
|
|
559
|
+
return self._db
|
|
560
|
+
|
|
561
|
+
def initialize_experiment(self):
|
|
562
|
+
"""Create an Experiment object from the bench configuration."""
|
|
563
|
+
if self.config.experiment:
|
|
564
|
+
self._experiment = Experiment(
|
|
565
|
+
name=self.config.experiment.title,
|
|
566
|
+
description=self.config.experiment.description,
|
|
567
|
+
notes=self.config.experiment.notes or ""
|
|
568
|
+
)
|
|
569
|
+
logger.info(f"Initialized experiment '{self.config.experiment.title}'")
|
|
570
|
+
|
|
571
|
+
def initialize_database(self, db_path: Optional[Union[str, Path]] = None):
|
|
572
|
+
"""Initialize the database if a path is provided in the config or arguments."""
|
|
573
|
+
db_path = db_path or (self.config.experiment.database_path if self.config.experiment else None)
|
|
574
|
+
if db_path:
|
|
575
|
+
self._db = MeasurementDatabase(db_path)
|
|
576
|
+
logger.info(f"Connected to database at '{db_path}'")
|
|
577
|
+
|
|
578
|
+
def save_experiment(self, notes: str = "") -> Optional[str]:
|
|
579
|
+
"""Save the current experiment to the database.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
notes: Optional notes to add to the experiment before saving.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
The codename of the saved experiment, or None if not saved.
|
|
586
|
+
"""
|
|
587
|
+
if self._experiment and self._db:
|
|
588
|
+
logger.info(f"Saving experiment '{self._experiment.name}' to database")
|
|
589
|
+
return self._db.store_experiment(None, self._experiment, notes=notes)
|
|
590
|
+
elif not self._db:
|
|
591
|
+
logger.warning("No database is configured. Experiment will not be saved.")
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
# --- Accessors for traceability, measurement plan, etc. ---
|
|
595
|
+
@property
|
|
596
|
+
def traceability(self):
|
|
597
|
+
"""Access traceability information."""
|
|
598
|
+
return self.config.traceability
|
|
599
|
+
|
|
600
|
+
@property
|
|
601
|
+
def measurement_plan(self):
|
|
602
|
+
"""Access measurement plan."""
|
|
603
|
+
return self.config.measurement_plan
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def experiment_notes(self):
|
|
607
|
+
"""Access experiment notes."""
|
|
608
|
+
return self.config.experiment.notes if self.config.experiment else None
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def version(self):
|
|
612
|
+
"""Access bench version."""
|
|
613
|
+
return self.config.version
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def changelog(self):
|
|
617
|
+
"""Access changelog."""
|
|
618
|
+
return self.config.changelog
|