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.
Files changed (95) hide show
  1. pytestlab/__init__.py +69 -0
  2. pytestlab/_log.py +62 -0
  3. pytestlab/analysis/__init__.py +7 -0
  4. pytestlab/analysis/fft.py +91 -0
  5. pytestlab/analysis/fr_analysis.py +38 -0
  6. pytestlab/bench.py +618 -0
  7. pytestlab/cli.py +916 -0
  8. pytestlab/common/__init__.py +5 -0
  9. pytestlab/common/enums.py +89 -0
  10. pytestlab/common/health.py +19 -0
  11. pytestlab/compliance/__init__.py +19 -0
  12. pytestlab/compliance/audit.py +88 -0
  13. pytestlab/compliance/patch.py +120 -0
  14. pytestlab/compliance/signature.py +146 -0
  15. pytestlab/compliance/tsa.py +51 -0
  16. pytestlab/config/__init__.py +16 -0
  17. pytestlab/config/_mixins.py +37 -0
  18. pytestlab/config/accuracy.py +62 -0
  19. pytestlab/config/base.py +21 -0
  20. pytestlab/config/bench_config.py +80 -0
  21. pytestlab/config/bench_loader.py +36 -0
  22. pytestlab/config/config.py +20 -0
  23. pytestlab/config/dc_active_load_config.py +98 -0
  24. pytestlab/config/instrument_config.py +31 -0
  25. pytestlab/config/loader.py +179 -0
  26. pytestlab/config/multimeter_config.py +145 -0
  27. pytestlab/config/oscilloscope_config.py +63 -0
  28. pytestlab/config/power_meter_config.py +14 -0
  29. pytestlab/config/power_supply_config.py +78 -0
  30. pytestlab/config/spectrum_analyzer_config.py +18 -0
  31. pytestlab/config/virtual_instrument_config.py +7 -0
  32. pytestlab/config/vna_config.py +16 -0
  33. pytestlab/config/waveform_generator_config.py +38 -0
  34. pytestlab/errors.py +145 -0
  35. pytestlab/experiments/__init__.py +6 -0
  36. pytestlab/experiments/database.py +724 -0
  37. pytestlab/experiments/experiments.py +164 -0
  38. pytestlab/experiments/results.py +357 -0
  39. pytestlab/experiments/sweep.py +658 -0
  40. pytestlab/gui/__init__.py +23 -0
  41. pytestlab/gui/async_utils.py +63 -0
  42. pytestlab/gui/builder.py +209 -0
  43. pytestlab/instruments/AutoInstrument.py +565 -0
  44. pytestlab/instruments/DCActiveLoad.py +361 -0
  45. pytestlab/instruments/Multimeter.py +309 -0
  46. pytestlab/instruments/Oscilloscope.py +1643 -0
  47. pytestlab/instruments/PowerMeter.py +86 -0
  48. pytestlab/instruments/PowerSupply.py +539 -0
  49. pytestlab/instruments/SpectrumAnalyser.py +55 -0
  50. pytestlab/instruments/VectorNetworkAnalyser.py +72 -0
  51. pytestlab/instruments/VirtualInstrument.py +79 -0
  52. pytestlab/instruments/WaveformGenerator.py +1704 -0
  53. pytestlab/instruments/__init__.py +8 -0
  54. pytestlab/instruments/backends/__init__.py +6 -0
  55. pytestlab/instruments/backends/async_visa_backend.py +167 -0
  56. pytestlab/instruments/backends/lamb.py +189 -0
  57. pytestlab/instruments/backends/recording_backend.py +110 -0
  58. pytestlab/instruments/backends/replay_backend.py +162 -0
  59. pytestlab/instruments/backends/session_recording_backend.py +148 -0
  60. pytestlab/instruments/backends/sim_backend.py +658 -0
  61. pytestlab/instruments/backends/visa_backend.py +134 -0
  62. pytestlab/instruments/instrument.py +670 -0
  63. pytestlab/instruments/scpi_engine.py +481 -0
  64. pytestlab/measurements/__init__.py +8 -0
  65. pytestlab/measurements/session.py +365 -0
  66. pytestlab/profiles/__init__.py +0 -0
  67. pytestlab/profiles/keysight/34460A.yaml +27 -0
  68. pytestlab/profiles/keysight/34470A.yaml +30 -0
  69. pytestlab/profiles/keysight/DSOX1202G.yaml +102 -0
  70. pytestlab/profiles/keysight/DSOX1204G.yaml +132 -0
  71. pytestlab/profiles/keysight/DSOX3054G.yaml +122 -0
  72. pytestlab/profiles/keysight/E36313A.yaml +37 -0
  73. pytestlab/profiles/keysight/E5071C_VNA.yaml +13 -0
  74. pytestlab/profiles/keysight/EDU33212A.yaml +53 -0
  75. pytestlab/profiles/keysight/EDU34450A.yaml +176 -0
  76. pytestlab/profiles/keysight/EDU36311A.yaml +122 -0
  77. pytestlab/profiles/keysight/EDU36311A_recorded.yaml +48 -0
  78. pytestlab/profiles/keysight/EL33133A.yaml +126 -0
  79. pytestlab/profiles/keysight/HD304MSO.yaml +148 -0
  80. pytestlab/profiles/keysight/MSOX2024A.yaml +122 -0
  81. pytestlab/profiles/keysight/MXR404A.yaml +102 -0
  82. pytestlab/profiles/keysight/N9000A_SA.yaml +12 -0
  83. pytestlab/profiles/keysight/U2000A_PM.yaml +10 -0
  84. pytestlab/profiles/pytestlab/binary_wave_data.bin +1 -0
  85. pytestlab/profiles/pytestlab/virtual_instrument.yaml +68 -0
  86. pytestlab/profiles/rohdeschwarz/NGE102B.yaml +144 -0
  87. pytestlab/schemas/awg.json +166 -0
  88. pytestlab/schemas/dc_active_load.json +511 -0
  89. pytestlab/schemas/dmm.json +120 -0
  90. pytestlab/schemas/oscilloscope.json +316 -0
  91. pytestlab/schemas/psu.json +86 -0
  92. pytestlab-0.2.1.dist-info/METADATA +317 -0
  93. pytestlab-0.2.1.dist-info/RECORD +95 -0
  94. pytestlab-0.2.1.dist-info/WHEEL +4 -0
  95. 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