Instruments

Instrument Drivers

This section documents the main instrument driver classes provided by PyTestLab. All drivers support both real and simulated backends, with a simple, readable API.


Core Instrument Classes

pytestlab.instruments.AutoInstrument

Classes

AutoInstrument

A factory class for creating and configuring instrument objects.

This class provides a high-level interface to instantiate various types of instruments based on configuration files, instrument types, or other identifiers. It handles the complexities of locating configuration data, selecting the appropriate communication backend (e.g., VISA, simulation), and initializing the correct instrument driver.

The primary methods are from_config for creating an instrument from a configuration source and from_type for creating one based on a generic instrument category.

Functions
from_config(config_source, *args, serial_number=None, debug_mode=False, simulate=None, backend_type_hint=None, address_override=None, timeout_override_ms=None, backend_override=None) classmethod

Initializes an instrument from a configuration source.

This is the primary factory method for creating instrument instances. It orchestrates the entire setup process: 1. Loads configuration from a dictionary, a local file, or a CDN URL. 2. Determines whether to run in simulation or live mode. 3. Selects and instantiates the appropriate communication backend (Sim, VISA, Lamb). 4. Instantiates the final instrument driver with the config and backend.

Note: This method creates and configures the instrument object but does not establish the connection. The caller must explicitly call instrument.connect_backend() on the returned object.

PARAMETER DESCRIPTION
config_source

A dictionary containing the configuration, a string identifier for a CDN/local profile, or a file path.

TYPE: str | dict[str, Any] | InstrumentConfig

serial_number

An optional serial number to override the one in the config.

TYPE: str | None DEFAULT: None

debug_mode

If True, prints detailed logs during the setup process.

TYPE: bool DEFAULT: False

simulate

Explicitly enable or disable simulation mode, overriding environment variables and config settings.

TYPE: bool | None DEFAULT: None

backend_type_hint

Manually specify the backend ('visa' or 'lamb'), bypassing automatic detection.

TYPE: str | None DEFAULT: None

address_override

Use a specific communication address, overriding the one in the config.

TYPE: str | None DEFAULT: None

timeout_override_ms

Use a specific communication timeout in milliseconds.

TYPE: int | None DEFAULT: None

RETURNS DESCRIPTION
Instrument[Any]

An initialized instrument object ready to be connected.

RAISES DESCRIPTION
FileNotFoundError

If the configuration source is a string and the corresponding file cannot be found.

InstrumentConfigurationError

If the configuration is invalid or a required setting is missing.

TypeError

If config_source is not a dictionary or a string.

Source code in pytestlab/instruments/AutoInstrument.py
@classmethod
def from_config(
    cls: type[AutoInstrument],
    config_source: str | dict[str, Any] | PydanticInstrumentConfig,  # Adjusted type hint
    *args,
    serial_number: str | None = None,
    debug_mode: bool = False,
    simulate: bool | None = None,
    backend_type_hint: str | None = None,
    address_override: str | None = None,
    timeout_override_ms: int | None = None,
    backend_override: InstrumentIO | None = None,  # Add this new parameter
) -> Instrument[Any]:
    """Initializes an instrument from a configuration source.

    This is the primary factory method for creating instrument instances. It
    orchestrates the entire setup process:
    1. Loads configuration from a dictionary, a local file, or a CDN URL.
    2. Determines whether to run in simulation or live mode.
    3. Selects and instantiates the appropriate communication backend (Sim, VISA, Lamb).
    4. Instantiates the final instrument driver with the config and backend.

    Note: This method creates and configures the instrument object but does not
    establish the connection. The caller must explicitly call `instrument.connect_backend()` on the returned object.

    Args:
        config_source: A dictionary containing the configuration, a string
                       identifier for a CDN/local profile, or a file path.
        serial_number: An optional serial number to override the one in the config.
        debug_mode: If True, prints detailed logs during the setup process.
        simulate: Explicitly enable or disable simulation mode, overriding
                  environment variables and config settings.
        backend_type_hint: Manually specify the backend ('visa' or 'lamb'),
                           bypassing automatic detection.
        address_override: Use a specific communication address, overriding the
                          one in the config.
        timeout_override_ms: Use a specific communication timeout in milliseconds.

    Returns:
        An initialized instrument object ready to be connected.

    Raises:
        FileNotFoundError: If the configuration source is a string and the
                           corresponding file cannot be found.
        InstrumentConfigurationError: If the configuration is invalid or a
                                      required setting is missing.
        TypeError: If `config_source` is not a dictionary or a string.
    """
    # Support serial_number as positional second argument
    if len(args) > 0 and isinstance(args[0], str):
        serial_number = args[0]

    config_data: dict[str, Any]

    # Step 1: Add the backend_override check at the beginning
    backend_instance: InstrumentIO
    config_model: PydanticInstrumentConfig

    if backend_override:
        backend_instance = backend_override
        if debug_mode:
            print(f"Using provided backend override: {type(backend_instance).__name__}")
        # When overriding, we still need the config model. The rest of the logic can be simplified.
        if isinstance(config_source, PydanticInstrumentConfig):
            config_model = config_source
        elif isinstance(config_source, dict):
            # Check if this is a config dict with a 'profile' key
            if "profile" in config_source:
                # Load the profile first, then merge with other config
                profile_source = config_source["profile"]
                config_model = load_profile(profile_source)
            else:
                # Treat the dict as profile data directly
                config_model = load_profile(config_source)
        else:
            config_model = load_profile(config_source)
    else:
        # Step 1: Load configuration data from the provided source
        if isinstance(config_source, PydanticInstrumentConfig):
            config_model = config_source
            config_data = config_model.model_dump(mode="python")
        elif isinstance(config_source, dict):
            # Check if this is a config dict with a 'profile' key
            if "profile" in config_source:
                # Load the profile first, then merge with other config
                profile_source = config_source["profile"]
                config_model = load_profile(profile_source)
                config_data = config_model.model_dump(mode="python")
                # Merge any additional config like address
                for key, value in config_source.items():
                    if key != "profile":
                        config_data[key] = value
            else:
                # Treat the dict as profile data directly
                config_data = config_source
                config_model = load_profile(config_data)
        elif isinstance(config_source, str):
            # Determine if this looks like a file path or a CDN identifier
            # File paths typically contain path separators or file extensions
            is_file_path = (
                os.path.sep in config_source
                or "/" in config_source
                or config_source.endswith(".yaml")
                or config_source.endswith(".json")
                or os.path.exists(config_source)
            )

            if is_file_path:
                # Try local file system first for file paths
                try:
                    config_data = cls.get_config_from_local(config_source)
                    if debug_mode:
                        print(
                            f"Successfully loaded configuration for '{config_source}' from local."
                        )
                except FileNotFoundError:
                    # Fallback to CDN if local fails (unlikely for file paths)
                    try:
                        config_data = cls.get_config_from_cdn(config_source)
                        if debug_mode:
                            print(
                                f"Successfully loaded configuration for '{config_source}' from CDN."
                            )
                    except FileNotFoundError:
                        raise FileNotFoundError(
                            f"Configuration '{config_source}' not found in local paths or CDN."
                        ) from None
            else:
                # Try CDN first for identifiers
                try:
                    config_data = cls.get_config_from_cdn(config_source)
                    if debug_mode:
                        print(
                            f"Successfully loaded configuration for '{config_source}' from CDN."
                        )
                except FileNotFoundError:
                    try:
                        # Fallback to local file system if not found on CDN
                        config_data = cls.get_config_from_local(config_source)
                        if debug_mode:
                            print(
                                f"Successfully loaded configuration for '{config_source}' from local."
                            )
                    except FileNotFoundError:
                        # If not found in either location, raise an error
                        raise FileNotFoundError(
                            f"Configuration '{config_source}' not found in CDN or local paths."
                        ) from None
            config_model = load_profile(config_data)
        else:
            raise TypeError(
                "config_source must be a file path (str), a dict, or an InstrumentConfig object."
            )

        # Override the serial number in the config if one is provided as an argument
        if serial_number is not None and hasattr(config_model, "serial_number"):
            config_model.serial_number = serial_number

        # Step 2: Determine the final simulation mode based on a clear priority
        final_simulation_mode: bool
        if simulate is not None:
            # Highest priority: explicit argument to the function
            final_simulation_mode = simulate
            if debug_mode:
                print(f"Simulation mode explicitly set to {final_simulation_mode} by argument.")
        else:
            # Second priority: environment variable
            env_simulate = os.getenv("PYTESTLAB_SIMULATE")
            if env_simulate is not None:
                final_simulation_mode = env_simulate.lower() in ("true", "1", "yes")
                if debug_mode:
                    print(
                        f"Simulation mode set to {final_simulation_mode} by PYTESTLAB_SIMULATE environment variable."
                    )
            else:
                # Lowest priority: default to False
                final_simulation_mode = False
                if debug_mode:
                    print(
                        f"Simulation mode defaulted to {final_simulation_mode} (no explicit argument or PYTESTLAB_SIMULATE)."
                    )

        # Step 3: Determine the actual communication address and timeout
        actual_address: str | None
        if address_override is not None:
            # Argument override has the highest priority for address
            actual_address = address_override
            if debug_mode:
                print(f"Address overridden to '{actual_address}'.")
        else:
            # Otherwise, get the address from the configuration data
            actual_address = getattr(
                config_model, "address", getattr(config_model, "resource_name", None)
            )
            if debug_mode:
                print(f"Address from config: '{actual_address}'.")

        actual_timeout: int
        default_communication_timeout_ms = 30000  # Default if not in override or config
        if timeout_override_ms is not None:
            actual_timeout = timeout_override_ms
            if debug_mode:
                print(f"Timeout overridden to {actual_timeout}ms.")
        else:
            # Assuming 'communication.timeout_ms' or 'communication_timeout_ms' might exist
            # Prefer 'communication_timeout_ms' as per previous logic if 'communication' object isn't standard
            timeout_from_config = getattr(config_model, "communication_timeout_ms", None)
            comm = getattr(config_model, "communication", None)
            if comm is not None:
                timeout_from_config = getattr(comm, "timeout_ms", timeout_from_config)

            if isinstance(timeout_from_config, int) and timeout_from_config > 0:
                actual_timeout = timeout_from_config
                if debug_mode:
                    print(f"Timeout from config: {actual_timeout}ms.")
            else:
                actual_timeout = default_communication_timeout_ms
                if debug_mode:
                    print(
                        f"Warning: Invalid or missing timeout in config, using default {actual_timeout}ms."
                    )

        if not isinstance(actual_timeout, int) or actual_timeout <= 0:  # Final safety check
            actual_timeout = default_communication_timeout_ms
            if debug_mode:
                print(f"Warning: Corrected invalid timeout to default {actual_timeout}ms.")

        # Step 4: Instantiate the appropriate backend based on the mode and configuration
        if final_simulation_mode:
            # Helper to resolve sim profile path
            def resolve_sim_profile_path(profile_key_or_path: str) -> str:
                # 1. User override in ~/.pytestlab/profiles
                user_profile = os.path.expanduser(
                    os.path.join("~/.pytestlab/profiles", profile_key_or_path + ".yaml")
                )
                if os.path.exists(user_profile):
                    return user_profile
                # 2. User sim_profiles (legacy)
                user_sim_profile = os.path.expanduser(
                    os.path.join("~/.pytestlab/sim_profiles", profile_key_or_path + ".yaml")
                )
                if os.path.exists(user_sim_profile):
                    return user_sim_profile
                # 3. Package profile
                import pytestlab as ptl

                pkg_profile = os.path.join(
                    os.path.dirname(ptl.__file__), "profiles", profile_key_or_path + ".yaml"
                )
                if os.path.exists(pkg_profile):
                    return pkg_profile
                # 4. Direct path
                if os.path.exists(profile_key_or_path):
                    return profile_key_or_path
                raise FileNotFoundError(
                    f"Simulation profile not found for '{profile_key_or_path}'"
                )

            device_model_str = getattr(config_model, "model", "GenericSimulatedModel")
            if isinstance(config_source, str):
                sim_profile_path = os.path.abspath(resolve_sim_profile_path(config_source))
                if debug_mode:
                    print(f"Resolved sim profile path: {sim_profile_path}")
            else:
                # Write dict config to a temp file
                with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as tf:
                    yaml.dump(config_data, tf)
                    sim_profile_path = os.path.abspath(tf.name)
                if debug_mode:
                    print(f"Wrote temp sim profile: {sim_profile_path}")
            backend_instance = SimBackend(
                profile_path=sim_profile_path,
                model=device_model_str,
                timeout_ms=actual_timeout,
            )
            if debug_mode:
                print(
                    f"Using SimBackend for {device_model_str} with timeout {actual_timeout}ms. Profile: {sim_profile_path}"
                )
        else:
            # For live hardware, determine the backend type (VISA or Lamb)
            if backend_type_hint:
                # Explicit hint overrides any inference
                chosen_backend_type = backend_type_hint.lower()
                if debug_mode:
                    print(f"Backend type hint provided: '{chosen_backend_type}'.")
            elif actual_address and "LAMB::" in actual_address.upper():
                # Infer 'lamb' backend from the address format
                chosen_backend_type = "lamb"
                if debug_mode:
                    print(f"Inferred backend type: 'lamb' from address '{actual_address}'.")
            elif actual_address:
                # Infer 'visa' for any other address type
                chosen_backend_type = "visa"
                if debug_mode:
                    print(f"Inferred backend type: 'visa' from address '{actual_address}'.")
            else:
                # Default to 'lamb' if no address is provided (e.g., for remote discovery)
                chosen_backend_type = "lamb"
                if debug_mode:
                    print("Defaulting backend type to 'lamb' (no address present).")

            if chosen_backend_type == "visa":
                if actual_address is None:
                    raise InstrumentConfigurationError(
                        config_source, "Missing address/resource_name for VISA backend."
                    )
                backend_instance = VisaBackend(
                    address=actual_address, timeout_ms=actual_timeout
                )
                if debug_mode:
                    print(
                        f"Using VisaBackend for '{actual_address}' with timeout {actual_timeout}ms."
                    )
            elif chosen_backend_type == "lamb":
                lamb_server_url = getattr(config_model, "lamb_url", "http://lamb-server:8000")
                if actual_address:
                    backend_instance = LambBackend(
                        address=actual_address, url=lamb_server_url, timeout_ms=actual_timeout
                    )
                elif hasattr(config_model, "model") and hasattr(config_model, "serial_number"):
                    backend_instance = LambBackend(
                        address=None,
                        url=lamb_server_url,
                        timeout_ms=actual_timeout,
                        model_name=config_model.model,
                        serial_number=config_model.serial_number,
                    )
                else:
                    raise InstrumentConfigurationError(
                        config_source,
                        "Lamb backend requires either an address or both model and serial_number in the config.",
                    )
                if debug_mode:
                    print(
                        f"Using LambBackend for model='{getattr(config_model, 'model', None)}', serial='{getattr(config_model, 'serial_number', None)}' via '{lamb_server_url}' with timeout {actual_timeout}ms."
                    )
            else:
                raise InstrumentConfigurationError(
                    config_source, f"Unsupported backend_type '{chosen_backend_type}'."
                )

    # Step 5: Instantiate the final instrument driver class
    device_type_str: str = config_model.device_type
    instrument_class_to_init = cls._instrument_mapping.get(device_type_str.lower())

    if instrument_class_to_init is None:
        raise InstrumentConfigurationError(
            config_source,
            f"Unknown device_type: '{device_type_str}'. No registered instrument class.",
        )

    # The instrument's constructor receives the parsed configuration model and the
    # instantiated backend.
    instrument = instrument_class_to_init(config=config_model, backend=backend_instance)

    if debug_mode:
        print(
            f"Instantiated {instrument_class_to_init.__name__} with {type(backend_instance).__name__}."
        )
    if debug_mode:
        print(
            "Note: Backend connection is not established by __init__. Call 'instrument.connect_backend()' explicitly."
        )

    return instrument
from_type(instrument_type, *args, **kwargs) classmethod

Initializes a specific instrument driver based on its type string.

This factory method uses a mapping to find the appropriate instrument class for a given instrument_type string (e.g., 'oscilloscope') and passes any additional arguments to its constructor.

PARAMETER DESCRIPTION
instrument_type

The type of the instrument to initialize.

TYPE: str

*args

Positional arguments to pass to the instrument's constructor.

TYPE: Any DEFAULT: ()

**kwargs

Keyword arguments to pass to the instrument's constructor.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Instrument

An instance of a specific Instrument subclass.

RAISES DESCRIPTION
InstrumentConfigurationError

If the instrument_type is not recognized.

Source code in pytestlab/instruments/AutoInstrument.py
@classmethod
def from_type(
    cls: type[AutoInstrument], instrument_type: str, *args: Any, **kwargs: Any
) -> Instrument:
    """Initializes a specific instrument driver based on its type string.

    This factory method uses a mapping to find the appropriate instrument class
    for a given `instrument_type` string (e.g., 'oscilloscope') and passes
    any additional arguments to its constructor.

    Args:
        instrument_type: The type of the instrument to initialize.
        *args: Positional arguments to pass to the instrument's constructor.
        **kwargs: Keyword arguments to pass to the instrument's constructor.

    Returns:
        An instance of a specific Instrument subclass.

    Raises:
        InstrumentConfigurationError: If the instrument_type is not recognized.
    """
    instrument_class = cls._instrument_mapping.get(instrument_type.lower())
    if instrument_class:
        return instrument_class(*args, **kwargs)
    else:
        raise InstrumentConfigurationError(
            instrument_type, f"Unknown instrument type: {instrument_type}"
        )
get_config_from_cdn(identifier) classmethod

Fetches an instrument configuration from a CDN with local caching.

This method attempts to retrieve a configuration file from a predefined CDN URL. For efficiency, it caches the configuration locally. If a cached version is available, it's used directly. Otherwise, the file is downloaded, cached for future use, and then returned.

PARAMETER DESCRIPTION
identifier

The unique identifier for the configuration, which is used to construct the CDN URL (e.g., 'keysight/dsox1204g').

TYPE: str

RETURNS DESCRIPTION
dict[str, Any]

The loaded configuration data as a dictionary.

RAISES DESCRIPTION
FileNotFoundError

If the configuration is not found on the CDN.

InstrumentConfigurationError

If the downloaded configuration is invalid.

Source code in pytestlab/instruments/AutoInstrument.py
@classmethod
def get_config_from_cdn(cls: type[AutoInstrument], identifier: str) -> dict[str, Any]:
    """Fetches an instrument configuration from a CDN with local caching.

    This method attempts to retrieve a configuration file from a predefined
    CDN URL. For efficiency, it caches the configuration locally. If a cached
    version is available, it's used directly. Otherwise, the file is
    downloaded, cached for future use, and then returned.

    Args:
        identifier: The unique identifier for the configuration, which is
                    used to construct the CDN URL (e.g., 'keysight/dsox1204g').

    Returns:
        The loaded configuration data as a dictionary.

    Raises:
        FileNotFoundError: If the configuration is not found on the CDN.
        InstrumentConfigurationError: If the downloaded configuration is invalid.
    """
    import pytestlab as ptl

    cache_dir = os.path.join(os.path.dirname(ptl.__file__), "cache", "configs")
    os.makedirs(cache_dir, exist_ok=True)

    cache_file = os.path.join(cache_dir, f"{identifier}.yaml")

    # Check for a cached version of the configuration first
    if os.path.exists(cache_file):
        try:
            with open(cache_file) as f:
                content = f.read()
                loaded_config = yaml.safe_load(content)
                # Validate the cached content; if corrupt, proceed to download
                if not isinstance(loaded_config, dict):
                    os.remove(cache_file)
                    raise InstrumentConfigurationError(
                        identifier, "Cached config is not a valid dictionary."
                    )
                return loaded_config
        except Exception as e:
            # If reading the cache fails, remove the broken file and fetch from CDN
            print(f"Cache read failed for {identifier}: {e}. Fetching from CDN.")
            if os.path.exists(cache_file):
                try:
                    os.remove(cache_file)
                except OSError:
                    pass

    # If not cached, fetch from the official CDN
    url = f"https://cdn.pytestlab.org/config/{identifier}.yaml"
    with httpx.Client() as client:
        try:
            response = client.get(url, timeout=10)
            response.raise_for_status()  # Raise an exception for bad status codes

            config_text = response.text
            loaded_config = yaml.safe_load(config_text)
            if not isinstance(loaded_config, dict):
                raise InstrumentConfigurationError(
                    identifier,
                    f"CDN config for {identifier} is not a valid dictionary.",
                )

            # Cache the newly downloaded configuration
            with open(cache_file, "w") as f:
                f.write(config_text)

            return loaded_config
        except httpx.HTTPStatusError as http_err:
            # Handle HTTP errors, specifically 404 for not found
            if http_err.response.status_code == 404:
                raise FileNotFoundError(
                    f"Configuration file not found at {url} (HTTP 404)."
                ) from http_err
            else:
                raise FileNotFoundError(
                    f"Failed to fetch configuration from CDN ({url}): HTTP {http_err.response.status_code}"
                ) from http_err
        except httpx.RequestError as e:
            # Handle network-related errors
            raise FileNotFoundError(
                f"Failed to fetch configuration from CDN ({url}): {str(e)}"
            ) from e
        except yaml.YAMLError as ye:
            # Handle errors in parsing the YAML content
            raise InstrumentConfigurationError(
                identifier, f"Error parsing YAML from CDN for {identifier}: {ye}"
            ) from ye
get_config_from_local(identifier, normalized_identifier=None) classmethod

Loads an instrument configuration from the local filesystem.

This method searches for a configuration file in two primary locations: 1. A built-in 'profiles' directory within the PyTestLab package. 2. A direct file path provided by the user.

PARAMETER DESCRIPTION
identifier

The identifier for the profile (e.g., 'keysight/dsox1204g') or a direct path to a .yaml or .json file.

TYPE: str

normalized_identifier

A pre-normalized version of the identifier.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
dict[str, Any]

The loaded configuration data as a dictionary.

RAISES DESCRIPTION
FileNotFoundError

If no configuration file can be found at any of the searched locations.

InstrumentConfigurationError

If the file is found but is not a valid YAML/JSON dictionary.

Source code in pytestlab/instruments/AutoInstrument.py
@classmethod
def get_config_from_local(
    cls: type[AutoInstrument], identifier: str, normalized_identifier: str | None = None
) -> dict[str, Any]:
    """Loads an instrument configuration from the local filesystem.

    This method searches for a configuration file in two primary locations:
    1. A built-in 'profiles' directory within the PyTestLab package.
    2. A direct file path provided by the user.

    Args:
        identifier: The identifier for the profile (e.g., 'keysight/dsox1204g')
                    or a direct path to a .yaml or .json file.
        normalized_identifier: A pre-normalized version of the identifier.

    Returns:
        The loaded configuration data as a dictionary.

    Raises:
        FileNotFoundError: If no configuration file can be found at any of the
                           searched locations.
        InstrumentConfigurationError: If the file is found but is not a valid
                                      YAML/JSON dictionary.
    """
    import pytestlab as ptl

    norm_id = (
        normalized_identifier
        if normalized_identifier is not None
        else os.path.normpath(identifier)
    )

    current_file_directory = os.path.dirname(ptl.__file__)
    preset_path = os.path.join(current_file_directory, "profiles", norm_id + ".yaml")

    # Determine the correct file path to load from
    path_to_try: str | None = None
    if os.path.exists(preset_path):
        # First, check for a built-in profile matching the identifier
        path_to_try = preset_path
    elif os.path.exists(identifier) and (
        identifier.endswith(".yaml") or identifier.endswith(".json")
    ):
        # Next, check if the identifier is a direct path to an existing file
        path_to_try = identifier

    if path_to_try:
        try:
            with open(path_to_try) as file:
                content = file.read()
                loaded_config = yaml.safe_load(content)
                if not isinstance(loaded_config, dict):
                    raise InstrumentConfigurationError(
                        identifier,
                        f"Local config file '{path_to_try}' did not load as a dictionary.",
                    )
                return loaded_config
        except yaml.YAMLError as ye:
            raise InstrumentConfigurationError(
                identifier,
                f"Error parsing YAML from local file '{path_to_try}': {ye}",
            ) from ye
        except Exception as e:
            raise FileNotFoundError(
                f"Error reading local config file '{path_to_try}': {e}"
            ) from e

    raise FileNotFoundError(
        f"No configuration found for identifier '{identifier}' in local paths."
    )
register_instrument(instrument_type, instrument_class) classmethod

Dynamically registers a new custom instrument class.

This allows users to extend PyTestLab with their own instrument drivers. Once registered, the new instrument type can be used with the factory methods like from_config and from_type.

PARAMETER DESCRIPTION
instrument_type

The string identifier for the new instrument type (e.g., 'my_custom_scope'). This is case-insensitive.

TYPE: str

instrument_class

The class object that implements the instrument driver. It must be a subclass of pytestlab.Instrument.

TYPE: type[Instrument[Any]]

RAISES DESCRIPTION
InstrumentConfigurationError

If the instrument type name is already in use or if the provided class is not a valid subclass of Instrument.

Source code in pytestlab/instruments/AutoInstrument.py
@classmethod
def register_instrument(
    cls: type[AutoInstrument], instrument_type: str, instrument_class: type[Instrument[Any]]
) -> None:
    """Dynamically registers a new custom instrument class.

    This allows users to extend PyTestLab with their own instrument drivers.
    Once registered, the new instrument type can be used with the factory
    methods like `from_config` and `from_type`.

    Args:
        instrument_type: The string identifier for the new instrument type
                         (e.g., 'my_custom_scope'). This is case-insensitive.
        instrument_class: The class object that implements the instrument driver.
                          It must be a subclass of `pytestlab.Instrument`.

    Raises:
        InstrumentConfigurationError: If the instrument type name is already
                                      in use or if the provided class is not a
                                      valid subclass of `Instrument`.
    """
    type_key = instrument_type.lower()
    if type_key in cls._instrument_mapping:
        raise InstrumentConfigurationError(
            instrument_type,
            f"Instrument type '{instrument_type}' already registered with class {cls._instrument_mapping[type_key].__name__}",
        )
    if not issubclass(instrument_class, Instrument):
        raise InstrumentConfigurationError(
            instrument_type,
            f"Cannot register class {instrument_class.__name__}. It must be a subclass of Instrument.",
        )
    cls._instrument_mapping[type_key] = instrument_class
    # Consider using a logger if available, instead of print
    print(
        f"Instrument type '{instrument_type}' registered with class {instrument_class.__name__}."
    )

Functions

pytestlab.instruments.Instrument(config, backend, **kwargs)

Bases: Generic[ConfigType]

Base class for all instrument drivers.

This class provides the core functionality for interacting with an instrument through a standardized interface. It handles command sending, querying, error checking, and logging. It is designed to be subclassed for specific instrument types (e.g., Oscilloscope, PowerSupply).

The Instrument class is generic and typed with ConfigType, which allows each subclass to specify its own Pydantic configuration model.

ATTRIBUTE DESCRIPTION
config

The Pydantic configuration model instance for this instrument.

TYPE: ConfigType

_backend

The communication backend used to interact with the hardware or simulation.

TYPE: InstrumentIO

_command_log

A log of all commands sent and responses received.

TYPE: List[Dict[str, Any]]

_logger

The logger instance for this instrument.

TYPE: Any

Initialize the Instrument class.

PARAMETER DESCRIPTION
config

Configuration for the instrument.

TYPE: ConfigType

backend

The communication backend instance.

TYPE: InstrumentIO

**kwargs

Additional keyword arguments.

TYPE: Any DEFAULT: {}

Source code in pytestlab/instruments/instrument.py
def __init__(self, config: ConfigType, backend: InstrumentIO, **kwargs: Any) -> None:
    """
    Initialize the Instrument class.

    Args:
        config (ConfigType): Configuration for the instrument.
        backend (InstrumentIO): The communication backend instance.
        **kwargs: Additional keyword arguments.
    """
    if not isinstance(config, InstrumentConfig):  # Check against the bound base
        raise InstrumentConfigurationError(
            self.__class__.__name__,
            f"A valid InstrumentConfig-compatible object must be provided, but got {type(config).__name__}.",
        )

    self.config = config
    self._backend = backend
    self._command_log = []

    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    self._logger = get_logger(logger_name)

    self._logger.info(
        f"Instrument '{logger_name}': Initializing with backend '{type(backend).__name__}'."
    )
    # Get SCPI data and convert to compatible format
    if hasattr(self.config, "scpi") and self.config.scpi is not None:
        if hasattr(self.config.scpi, "model_dump"):
            scpi_section = self.config.scpi.model_dump()
        else:
            scpi_section = {}
    else:
        scpi_section = {}
    self.scpi_engine = SCPIEngine(scpi_section)

Attributes

MAX_ERRORS_TO_READ = 50 class-attribute instance-attribute

config = config instance-attribute

scpi_engine = SCPIEngine(scpi_section) instance-attribute

Functions

attempt_error_recovery()

Attempts to recover from instrument error states.

This method tries to clear errors and reset the instrument if it's unresponsive. It's useful when the instrument is stuck in an error state that prevents normal communication.

RETURNS DESCRIPTION
bool

True if recovery was successful, False otherwise.

Source code in pytestlab/instruments/instrument.py
def attempt_error_recovery(self) -> bool:
    """Attempts to recover from instrument error states.

    This method tries to clear errors and reset the instrument if it's
    unresponsive. It's useful when the instrument is stuck in an error
    state that prevents normal communication.

    Returns:
        True if recovery was successful, False otherwise.
    """
    self._logger.info("Attempting to recover from instrument error state...")

    try:
        # First try to clear status and errors
        self._logger.debug("Attempting to clear status and errors...")
        self.clear_status()
        errors = self.get_all_errors()
        if errors:
            self._logger.info(f"Cleared {len(errors)} errors: {errors}")

        # Try to get instrument ID to verify it's responsive
        try:
            idn = self.id()
            self._logger.info(f"Instrument recovered, ID: {idn}")
            return True
        except Exception as e:
            self._logger.warning(f"Still unresponsive after clearing errors: {e}")

        # If still unresponsive, try a hard reset
        self._logger.debug("Attempting instrument reset...")
        try:
            self._send_command("*RST", skip_check=True)
            # Wait a moment for reset to complete
            import time

            time.sleep(2.0)

            # Try ID again
            idn = self.id()
            self._logger.info(f"Instrument recovered after reset, ID: {idn}")
            return True
        except Exception as e:
            self._logger.error(f"Reset failed: {e}")

    except Exception as e:
        self._logger.error(f"Error recovery attempt failed: {e}")

    self._logger.error("Failed to recover instrument from error state")
    return False

clear_status()

Clears the instrument's status registers and error queue (*CLS).

Source code in pytestlab/instruments/instrument.py
def clear_status(self) -> None:
    """
    Clears the instrument's status registers and error queue (*CLS).
    """
    try:
        cmds = self.scpi_engine.build("clear")
    except Exception:
        cmds = ["*CLS"]
    for c in cmds:
        self._send_command(c, skip_check=True)
    self._logger.debug("Status registers and error queue cleared (*CLS).")

close()

Close the connection to the instrument via the backend.

Source code in pytestlab/instruments/instrument.py
def close(self) -> None:
    """Close the connection to the instrument via the backend."""
    try:
        model_name_for_logger = (
            self.config.model if hasattr(self.config, "model") else self.__class__.__name__
        )
        self._logger.info(f"Instrument '{model_name_for_logger}': Closing connection.")
        self._backend.close()  # Changed to use close
        self._logger.info(f"Instrument '{model_name_for_logger}': Connection closed.")
    except Exception as e:
        model_name_for_logger = (
            self.config.model if hasattr(self.config, "model") else self.__class__.__name__
        )
        self._logger.error(
            f"Instrument '{model_name_for_logger}': Error during backend close: {e}"
        )

connect_backend()

Establishes the connection to the instrument via the backend.

This method must be called after the instrument is instantiated to open the communication channel. It delegates the connection logic to the underlying backend.

RAISES DESCRIPTION
InstrumentConnectionError

If the backend fails to connect.

Source code in pytestlab/instruments/instrument.py
def connect_backend(self) -> None:
    """Establishes the connection to the instrument via the backend.

    This method must be called after the instrument is instantiated to open
    the communication channel. It delegates the connection logic to the
    underlying backend.

    Raises:
        InstrumentConnectionError: If the backend fails to connect.
    """
    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    try:
        self._backend.connect()
        self._logger.info(f"Instrument '{logger_name}': Backend connected.")
    except Exception as e:
        self._logger.error(f"Instrument '{logger_name}': Failed to connect backend: {e}")
        if hasattr(self._backend, "disconnect"):  # Check if disconnect is available
            try:
                self._backend.disconnect()
            except Exception as disc_e:
                self._logger.error(
                    f"Instrument '{logger_name}': Error disconnecting backend during failed connect: {disc_e}"
                )
        raise InstrumentConnectionError(
            instrument=logger_name, message=f"Failed to connect backend: {e}"
        ) from e

from_config(config, debug_mode=False) classmethod

Source code in pytestlab/instruments/instrument.py
@classmethod
def from_config(
    cls: type[Instrument], config: InstrumentConfig, debug_mode: bool = False
) -> Instrument:
    if not isinstance(config, InstrumentConfig):
        raise InstrumentConfigurationError(
            cls.__name__, "from_config expects an InstrumentConfig object."
        )
    # The backend instantiation is missing here and is crucial.
    # This will be handled by AutoInstrument.from_config later.
    raise NotImplementedError("from_config needs to be updated for backend instantiation.")

get_all_errors()

Reads and clears all errors currently present in the instrument's error queue.

Source code in pytestlab/instruments/instrument.py
def get_all_errors(self) -> list[tuple[int, str]]:
    """
    Reads and clears all errors currently present in the instrument's error queue.
    """
    errors: list[tuple[int, str]] = []
    for i in range(self.MAX_ERRORS_TO_READ):
        try:
            code, message = self.get_error()
        except InstrumentCommunicationError as e:
            self._logger.debug(
                f"Communication error while reading error queue (iteration {i + 1}): {e}"
            )
            if errors:
                self._logger.debug(
                    f"Returning errors read before communication failure: {errors}"
                )
            return errors

        if code == 0:
            break
        errors.append((code, message))
        if code == -350:
            self._logger.debug("Error queue overflow (-350) detected. Stopping read.")
            break
    else:
        self._logger.debug(
            f"Warning: Read {self.MAX_ERRORS_TO_READ} errors without reaching 'No error'. "
            "Error queue might still contain errors or be in an unexpected state."
        )

    if not errors:
        self._logger.debug("No errors found in instrument queue.")
    else:
        self._logger.debug(f"Retrieved {len(errors)} error(s) from queue: {errors}")
    return errors

get_communication_timeout()

Gets the communication timeout from the backend.

Source code in pytestlab/instruments/instrument.py
def get_communication_timeout(self) -> int:
    """Gets the communication timeout from the backend."""
    timeout = self._backend.get_timeout()
    self._logger.debug(f"Communication timeout retrieved from backend: {timeout} ms.")
    return timeout

get_error()

Reads and clears the oldest error from the instrument's error queue.

Source code in pytestlab/instruments/instrument.py
def get_error(self) -> tuple[int, str]:
    """
    Reads and clears the oldest error from the instrument's error queue.
    """
    try:
        q = self.scpi_engine.build("get_error")[0]
    except Exception:
        q = "SYSTem:ERRor?"
    response = (self._query(q, skip_check=True)).strip()
    try:
        code_str, msg_part = response.split(",", 1)
        code = int(code_str)
        message = msg_part.strip().strip('"')
    except (ValueError, IndexError) as e:
        self._logger.debug(
            f"Warning: Unexpected error response format: '{response}'. Raising error."
        )
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=q,
            message=f"Could not parse error response: '{response}'",
        ) from e

    if code != 0:
        self._logger.debug(f"Instrument Error Query: Code={code}, Message='{message}'")
    return code, message

get_scpi_version()

Queries the version of the SCPI standard the instrument complies with.

Source code in pytestlab/instruments/instrument.py
def get_scpi_version(self) -> str:
    """
    Queries the version of the SCPI standard the instrument complies with.
    """
    try:
        q = self.scpi_engine.build("scpi_version")[0]
    except Exception:
        q = "SYSTem:VERSion?"
    response = (self._query(q)).strip()
    self._logger.debug(f"SCPI Version reported: {response}")
    return response

health_check()

Performs a basic health check of the instrument.

Source code in pytestlab/instruments/instrument.py
def health_check(self) -> HealthReport:
    """Performs a basic health check of the instrument."""
    # Base implementation could try IDN and error queue check
    report = HealthReport()
    try:
        report.instrument_idn = self.id()
        instrument_errors = self.get_all_errors()
        if instrument_errors:
            report.warnings.extend(
                [f"Stored Error: {code} - {msg}" for code, msg in instrument_errors]
            )

        if not report.errors and not report.warnings:
            report.status = HealthStatus.OK
        elif report.warnings and not report.errors:
            report.status = HealthStatus.WARNING
        else:  # if errors are present
            report.status = HealthStatus.ERROR

    except Exception as e:
        report.status = HealthStatus.ERROR
        report.errors.append(f"Health check failed during IDN/Error Query: {str(e)}")
    return report

id()

Query the instrument for its identification string (*IDN?).

Source code in pytestlab/instruments/instrument.py
def id(self) -> str:
    """
    Query the instrument for its identification string (*IDN?).
    """
    q = "*IDN?"
    try:
        candidate = self.scpi_engine.build("identify")[0]
        if isinstance(candidate, str) and "IDN" in candidate.upper():
            q = candidate
    except Exception:
        pass
    name = self._query(q)
    self._logger.debug(f"Connected to {name}")
    return name

lock_panel(lock=True)

Locks or unlocks the front panel of the instrument.

Source code in pytestlab/instruments/instrument.py
def lock_panel(self, lock: bool = True) -> None:
    """
    Locks or unlocks the front panel of the instrument.
    """
    if lock:
        try:
            cmds = self.scpi_engine.build("panel_lock")
        except Exception:
            cmds = [":SYSTem:LOCK"]
    else:
        try:
            cmds = self.scpi_engine.build("panel_local")
        except Exception:
            cmds = [":SYSTem:LOCal"]
    for c in cmds:
        self._send_command(c)
    self._logger.debug(f"Panel {'locked' if lock else 'unlocked (local control enabled)'}.")

requires(requirement) classmethod

Decorator to specify method requirements based on instrument configuration.

Source code in pytestlab/instruments/instrument.py
@classmethod
def requires(cls, requirement: str) -> Callable:
    """
    Decorator to specify method requirements based on instrument configuration.
    """

    def decorator(func: Callable) -> Callable:
        def wrapped_func(self: Instrument, *args: Any, **kwargs: Any) -> Any:
            if not hasattr(self.config, "requires") or not callable(self.config.requires):
                raise InstrumentConfigurationError(
                    self.config.model,
                    "Config object missing 'requires' method for decorator.",
                )

            if self.config.requires(requirement):
                return func(self, *args, **kwargs)
            else:
                raise InstrumentConfigurationError(
                    self.config.model,
                    f"Method '{func.__name__}' requires '{requirement}', which is not available for this instrument model/configuration.",
                )

        return wrapped_func

    return decorator

reset()

Reset the instrument to its default settings (*RST).

Source code in pytestlab/instruments/instrument.py
def reset(self) -> None:
    """Reset the instrument to its default settings (*RST)."""
    try:
        cmds = self.scpi_engine.build("reset")
    except Exception:
        cmds = ["*RST"]
    for c in cmds:
        self._send_command(c)
    self._logger.debug("Instrument reset to default settings (*RST).")

run_self_test(full_test=True)

Executes the instrument's internal self-test routine (*TST?) and reports result.

Source code in pytestlab/instruments/instrument.py
def run_self_test(self, full_test: bool = True) -> str:
    """
    Executes the instrument's internal self-test routine (*TST?) and reports result.
    """
    if not full_test:
        self._logger.debug(
            "Note: `full_test=False` currently ignored, running standard *TST? self-test."
        )

    self._logger.debug("Running self-test (*TST?)...")
    try:
        q = self.scpi_engine.build("self_test")[0]
    except Exception:
        q = "*TST?"
    result_str = ""
    try:
        result_str = self._query(q)
        code = int(result_str.strip())
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=q,
            message=f"Unexpected non-integer response: '{result_str}'",
        ) from e
    except InstrumentCommunicationError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=q,
            message="Failed to execute query.",
        ) from e

    if code == 0:
        self._logger.debug("Self-test query (*TST?) returned 0 (Passed).")
        errors_after_test = self.get_all_errors()
        if errors_after_test:
            details = "; ".join([f"{c}: {m}" for c, m in errors_after_test])
            warn_msg = (
                f"Self-test query passed, but errors found in queue afterwards: {details}"
            )
            self._logger.debug(warn_msg)
        return "Passed"
    else:
        self._logger.debug(
            f"Self-test query (*TST?) returned non-zero code: {code} (Failed). Reading error queue..."
        )
        errors = self.get_all_errors()
        details = (
            "; ".join([f"{c}: {m}" for c, m in errors])
            if errors
            else "No specific errors reported in queue"
        )
        fail_msg = f"Failed: Code {code}. Errors: {details}"
        self._logger.debug(fail_msg)
        return fail_msg

set_communication_timeout(timeout_ms)

Sets the communication timeout on the backend.

Source code in pytestlab/instruments/instrument.py
def set_communication_timeout(self, timeout_ms: int) -> None:
    """Sets the communication timeout on the backend."""
    self._backend.set_timeout(timeout_ms)
    self._logger.debug(f"Communication timeout set to {timeout_ms} ms on backend.")

wait_for_operation_complete(query_instrument=True, timeout=10.0)

Waits for the instrument to finish all pending overlapping commands. The 'timeout' parameter's effect depends on the backend's query timeout settings.

Source code in pytestlab/instruments/instrument.py
def wait_for_operation_complete(
    self, query_instrument: bool = True, timeout: float = 10.0
) -> str | None:
    """
    Waits for the instrument to finish all pending overlapping commands.
    The 'timeout' parameter's effect depends on the backend's query timeout settings.
    """
    if query_instrument:
        self._logger.debug(
            f"Waiting for operation complete (*OPC?). Effective timeout depends on backend (method timeout hint: {timeout}s)."
        )
        q = "*OPC?"
        try:
            try:
                q = self.scpi_engine.build("opc_query")[0]
            except Exception:
                q = "*OPC?"
            response = self._query(q)
            self._logger.debug("Operation complete query (*OPC?) returned.")
            if response.strip() != "1":
                self._logger.debug(
                    f"Warning: *OPC? returned '{response}' instead of expected '1'."
                )
            return response.strip()
        except InstrumentCommunicationError as e:
            err_msg = f"*OPC? query failed. This may be due to backend communication timeout (related to method's timeout param: {timeout}s)."
            self._logger.debug(err_msg)
            raise InstrumentCommunicationError(
                instrument=self.config.model, command=q, message=err_msg
            ) from e
    else:
        try:
            cmds = self.scpi_engine.build("opc")
        except Exception:
            cmds = ["*OPC"]
        for c in cmds:
            self._send_command(c)
        self._logger.debug(
            "Operation complete command (*OPC) sent (non-blocking). Status polling required."
        )
        return None

Supported Instrument Types

Oscilloscope

pytestlab.instruments.Oscilloscope

Classes

ChannelReadingResult(values, instrument, units, measurement_type, timestamp=None, envelope=None, sampling_rate=None, **kwargs)

ChannelReadingResult(
    values: float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)
ChannelReadingResult(
    values: np.ndarray
    | pl.DataFrame
    | np.float64
    | list[Any]
    | UFloat,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)

Bases: MeasurementResult

A result class for oscilloscope channel readings (time, voltage, etc).

Convenience features: - results[1] → returns a new ChannelReadingResult containing only CH1 and time - results.for_channel(2) → same as above for channel 2 - results.channels → list of available channel numbers - results.time → numpy array of the time axis - results.to_dataframe() → underlying DataFrame

Source code in pytestlab/experiments/results.py
def __init__(
    self,
    values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat | float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = None,  # Allow optional timestamp override
    envelope: dict[str, Any] | None = None,  # Add envelope as an explicit argument
    sampling_rate: float | None = None,  # Add sampling_rate for FFT
    **kwargs: Any,
) -> None:  # Added **kwargs and type hint
    if isinstance(values, float) and not isinstance(values, np.floating):
        # Normalize plain Python float to numpy float64 for internal consistency
        values = np.float64(values)
    self.values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat = cast(
        np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat, values
    )
    self.units: str = units
    self.instrument: str = instrument
    self.measurement_type: str = measurement_type
    self.timestamp: float = timestamp if timestamp is not None else time.time()
    # Envelope logic: always provide an envelope attribute
    if envelope is not None:
        self.envelope = envelope
    else:
        # Default: minimal valid envelope (empty dict, or customize as needed)
        self.envelope = {}

    # Store sampling rate for FFT calculations
    self.sampling_rate = sampling_rate

    # Store any additional kwargs as attributes
    for key, value in kwargs.items():
        setattr(self, key, value)
Attributes
channels property
time property
Functions
__getitem__(key)
Source code in pytestlab/instruments/Oscilloscope.py
def __getitem__(self, key):
    # Integer channel indexing: results[1] → CH1 + time
    if isinstance(key, int):
        return self.for_channel(key)
    # Named access for time
    if key in ("time", "t"):
        return self.time
    # Fall back to default behavior (may raise)
    return super().__getitem__(key)
for_channel(channel)
Source code in pytestlab/instruments/Oscilloscope.py
def for_channel(self, channel: int) -> ChannelReadingResult:
    df = self._ensure_dataframe()
    col = self._column_for_channel(channel)
    if col not in df.columns:
        raise KeyError(f"Channel column not present: {col}")
    sub_df = pl.DataFrame(
        {
            "Time (s)": df["Time (s)"],
            col: df[col],
        }
    )
    return ChannelReadingResult(
        values=sub_df,
        instrument=self.instrument,
        units=self.units,
        measurement_type=self.measurement_type,
        timestamp=self.timestamp,
        sampling_rate=getattr(self, "sampling_rate", None),
    )
to_dataframe()
Source code in pytestlab/instruments/Oscilloscope.py
def to_dataframe(self) -> pl.DataFrame:
    return self._ensure_dataframe()

FFTResult(values, instrument, units, measurement_type, timestamp=None, envelope=None, sampling_rate=None, **kwargs)

FFTResult(
    values: float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)
FFTResult(
    values: np.ndarray
    | pl.DataFrame
    | np.float64
    | list[Any]
    | UFloat,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)

Bases: MeasurementResult

A result class for FFT data from the oscilloscope.

Source code in pytestlab/experiments/results.py
def __init__(
    self,
    values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat | float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = None,  # Allow optional timestamp override
    envelope: dict[str, Any] | None = None,  # Add envelope as an explicit argument
    sampling_rate: float | None = None,  # Add sampling_rate for FFT
    **kwargs: Any,
) -> None:  # Added **kwargs and type hint
    if isinstance(values, float) and not isinstance(values, np.floating):
        # Normalize plain Python float to numpy float64 for internal consistency
        values = np.float64(values)
    self.values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat = cast(
        np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat, values
    )
    self.units: str = units
    self.instrument: str = instrument
    self.measurement_type: str = measurement_type
    self.timestamp: float = timestamp if timestamp is not None else time.time()
    # Envelope logic: always provide an envelope attribute
    if envelope is not None:
        self.envelope = envelope
    else:
        # Default: minimal valid envelope (empty dict, or customize as needed)
        self.envelope = {}

    # Store sampling rate for FFT calculations
    self.sampling_rate = sampling_rate

    # Store any additional kwargs as attributes
    for key, value in kwargs.items():
        setattr(self, key, value)

FRanalysisResult(values, instrument, units, measurement_type, timestamp=None, envelope=None, sampling_rate=None, **kwargs)

FRanalysisResult(
    values: float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)
FRanalysisResult(
    values: np.ndarray
    | pl.DataFrame
    | np.float64
    | list[Any]
    | UFloat,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = ...,
    envelope: dict[str, Any] | None = ...,
    sampling_rate: float | None = ...,
    **kwargs: Any,
)

Bases: MeasurementResult

A result class for frequency response analysis data.

Source code in pytestlab/experiments/results.py
def __init__(
    self,
    values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat | float,
    instrument: str,
    units: str,
    measurement_type: str,
    timestamp: float | None = None,  # Allow optional timestamp override
    envelope: dict[str, Any] | None = None,  # Add envelope as an explicit argument
    sampling_rate: float | None = None,  # Add sampling_rate for FFT
    **kwargs: Any,
) -> None:  # Added **kwargs and type hint
    if isinstance(values, float) and not isinstance(values, np.floating):
        # Normalize plain Python float to numpy float64 for internal consistency
        values = np.float64(values)
    self.values: np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat = cast(
        np.ndarray | pl.DataFrame | np.float64 | list[Any] | UFloat, values
    )
    self.units: str = units
    self.instrument: str = instrument
    self.measurement_type: str = measurement_type
    self.timestamp: float = timestamp if timestamp is not None else time.time()
    # Envelope logic: always provide an envelope attribute
    if envelope is not None:
        self.envelope = envelope
    else:
        # Default: minimal valid envelope (empty dict, or customize as needed)
        self.envelope = {}

    # Store sampling rate for FFT calculations
    self.sampling_rate = sampling_rate

    # Store any additional kwargs as attributes
    for key, value in kwargs.items():
        setattr(self, key, value)

Oscilloscope(config, debug_mode=False, simulate=False, **kwargs)

Bases: Instrument[OscilloscopeConfig]

Drives a digital oscilloscope for waveform acquisition and measurement.

This class provides a comprehensive, high-level interface for controlling an oscilloscope. It builds upon the base Instrument class and adds extensive functionality specific to oscilloscopes.

Key features include: - Facade-based interfaces for channels, trigger, and acquisition for cleaner code. - Methods for reading waveforms, performing automated measurements (e.g., Vpp, Vrms). - Support for advanced features like FFT and Frequency Response Analysis (FRA). - Built-in waveform generator control if the hardware supports it. - Screenshot capability.

ATTRIBUTE DESCRIPTION
config

The Pydantic configuration object (OscilloscopeConfig) containing settings specific to this oscilloscope.

TYPE: OscilloscopeConfig

trigger

A ScopeTriggerFacade for configuring trigger settings.

acquisition

A ScopeAcquisitionFacade for acquisition system settings.

Initialize the Oscilloscope class with the given VISA resource and profile information.

Args: config (OscilloscopeConfig): Configuration object for the oscilloscope. debug_mode (bool): Enable debug mode. (Handled by base or backend) simulate (bool): Enable simulation mode. (Handled by base or backend)

Source code in pytestlab/instruments/Oscilloscope.py
def __init__(
    self,
    config: OscilloscopeConfig,
    debug_mode: bool = False,
    simulate: bool = False,
    **kwargs: Any,
) -> None:  # config is now non-optional
    """
    Initialize the Oscilloscope class with the given VISA resource and profile information.

    Args:
    config (OscilloscopeConfig): Configuration object for the oscilloscope.
    debug_mode (bool): Enable debug mode. (Handled by base or backend)
    simulate (bool): Enable simulation mode. (Handled by base or backend)
    """
    # The config is already validated by the loader to be OscilloscopeConfig V2
    super().__init__(
        config=config, debug_mode=debug_mode, simulate=simulate, **kwargs
    )  # Pass kwargs
    # Initialize facades
    self.trigger = ScopeTriggerFacade(self)
    self.acquisition = ScopeAcquisitionFacade(self)
Attributes
acquisition = ScopeAcquisitionFacade(self) instance-attribute
config instance-attribute
trigger = ScopeTriggerFacade(self) instance-attribute
Functions
auto_scale()

Auto scale the oscilloscope display.

This method sends an SCPI command to the oscilloscope to auto scale the display.

Example:

auto_scale()

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def auto_scale(self) -> None:
    """
    Auto scale the oscilloscope display.

    This method sends an SCPI command to the oscilloscope to auto scale the display.

    Example:
    >>> auto_scale()
    """
    for cmd in self.scpi_engine.build("auto_scale"):
        self._send_command(cmd)
channel(ch_num)

Returns a facade for interacting with a specific channel.

This method provides a convenient, chainable interface for controlling a single oscilloscope channel.

PARAMETER DESCRIPTION
ch_num

The channel number (1-based).

TYPE: int

RETURNS DESCRIPTION
ScopeChannelFacade

A ScopeChannelFacade object for the specified channel.

RAISES DESCRIPTION
InstrumentParameterError

If the channel number is invalid.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def channel(self, ch_num: int) -> ScopeChannelFacade:
    """Returns a facade for interacting with a specific channel.

    This method provides a convenient, chainable interface for controlling a
    single oscilloscope channel.

    Args:
        ch_num: The channel number (1-based).

    Returns:
        A `ScopeChannelFacade` object for the specified channel.

    Raises:
        InstrumentParameterError: If the channel number is invalid.
    """
    if not self.config.channels or not (1 <= ch_num <= len(self.config.channels)):
        num_conf_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            parameter="ch_num",
            value=ch_num,
            valid_range=(1, num_conf_ch),
            message="Channel number is out of range.",
        )
    return ScopeChannelFacade(self, ch_num)
configure_fft(source_channel, scale=None, offset=None, span=None, window_type='HANNing', units='DECibel', display=True)

Configure the oscilloscope to perform an FFT on the specified channel.

:param source_channel: The channel number to perform FFT on. :param scale: The vertical scale of the FFT display. Instrument specific. :param offset: The vertical offset of the FFT display. Instrument specific. :param span: The frequency span for the FFT. Instrument specific. :param window_type: The windowing function. Case-insensitive. From config.fft.window_types. :param units: The unit for FFT magnitude. Case-insensitive. From config.fft.units. :param display: True to turn FFT display ON, False for OFF.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("fft")
def configure_fft(
    self,
    source_channel: int,
    scale: float | None = None,
    offset: float | None = None,
    span: float | None = None,
    window_type: str = "HANNing",
    units: str = "DECibel",
    display: bool = True,
) -> None:
    """
    Configure the oscilloscope to perform an FFT on the specified channel.

    :param source_channel: The channel number to perform FFT on.
    :param scale: The vertical scale of the FFT display. Instrument specific.
    :param offset: The vertical offset of the FFT display. Instrument specific.
    :param span: The frequency span for the FFT. Instrument specific.
    :param window_type: The windowing function. Case-insensitive. From config.fft.window_types.
    :param units: The unit for FFT magnitude. Case-insensitive. From config.fft.units.
    :param display: True to turn FFT display ON, False for OFF.
    """
    if self.config.fft is None:
        raise InstrumentConfigurationError(
            self.config.model, "FFT not configured for this instrument."
        )
    if not (1 <= source_channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="source_channel",
            value=source_channel,
            valid_range=(1, len(self.config.channels)),
            message="Source channel number is out of range.",
        )

    # Validate window_type against config.fft.window_types (List[str])
    # Assuming window_type parameter is the SCPI string itself
    if window_type.upper() not in [wt.upper() for wt in self.config.fft.window_types]:
        raise InstrumentParameterError(
            parameter="window_type",
            value=window_type,
            valid_range=self.config.fft.window_types,
            message="Unsupported FFT window type.",
        )
    scpi_window = window_type

    # Validate units against config.fft.units (List[str])
    if units.upper() not in [u.upper() for u in self.config.fft.units]:
        raise InstrumentParameterError(
            parameter="units",
            value=units,
            valid_range=self.config.fft.units,
            message="Unsupported FFT units.",
        )
    scpi_units = units

    if self.config.fft is None:
        raise InstrumentConfigurationError(
            self.config.model, "FFT not configured for this instrument."
        )
    for cmd in self.scpi_engine.build("fft_source", channel=source_channel):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fft_window", window=scpi_window):
        self._send_command(cmd)

    if span is not None:
        for cmd in self.scpi_engine.build("fft_span", span=span):
            self._send_command(cmd)

    for cmd in self.scpi_engine.build("fft_vtype", units=scpi_units):
        self._send_command(cmd)

    if scale is not None:
        for cmd in self.scpi_engine.build("fft_scale", scale=scale):
            self._send_command(cmd)

    if offset is not None:
        for cmd in self.scpi_engine.build("fft_offset", offset=offset):
            self._send_command(cmd)

    scpi_display_state = SCPIOnOff.ON.value if display else SCPIOnOff.OFF.value
    for cmd in self.scpi_engine.build("fft_display", state=scpi_display_state):
        self._send_command(cmd)

    self._logger.debug(f"FFT configured for channel {source_channel}.")
configure_trigger(channel, level, source=None, trigger_type='HIGH', slope=TriggerSlope.POSITIVE, mode='EDGE')

Sets the trigger for the oscilloscope.

:param channel: The channel to set the trigger for (used if source is None or a channel itself) :param level: The trigger level in volts :param source: The source of the trigger. Default behaviour is to use the channel. Valid options CHANnel | EXTernal | LINE | WGEN :param trigger_type: The type of trigger. Default is 'HIGH' (Note: this param seems unused in current logic for level setting) :param slope: The slope of the trigger. Default is TriggerSlope.POSITIVE :param mode: The trigger mode. Default is 'EDGE'

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def configure_trigger(
    self,
    channel: int,
    level: float,
    source: str | None = None,
    trigger_type: str = "HIGH",
    slope: TriggerSlope = TriggerSlope.POSITIVE,
    mode: str = "EDGE",
) -> None:
    """
    Sets the trigger for the oscilloscope.

    :param channel: The channel to set the trigger for (used if source is None or a channel itself)
    :param level: The trigger level in volts
    :param source: The source of the trigger. Default behaviour is to use the channel. Valid options CHANnel<n> | EXTernal | LINE | WGEN
    :param trigger_type: The type of trigger. Default is 'HIGH' (Note: this param seems unused in current logic for level setting)
    :param slope: The slope of the trigger. Default is TriggerSlope.POSITIVE
    :param mode: The trigger mode. Default is 'EDGE'
    """

    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Primary channel number is out of range.",
        )

    actual_source: str
    if source is None:
        actual_source = f"CHANnel{channel}"
    else:
        actual_source = source.upper()
        # Check if source is a channel (handle CH1, CHAN1, CHANNEL1 formats)
        if actual_source.startswith("CH"):
            try:
                num_str = "".join(filter(str.isdigit, actual_source))
                if not num_str:
                    raise ValueError("No digits found in channel source string")
                source_channel_to_validate = int(num_str)
                if not (1 <= source_channel_to_validate <= len(self.config.channels)):
                    raise InstrumentParameterError(
                        parameter="source",
                        value=source,
                        valid_range=(1, len(self.config.channels)),
                        message="Source channel number is out of range.",
                    )
                # Normalize the channel source to CHANNEL format for SCPI command
                actual_source = f"CHANnel{source_channel_to_validate}"
            except (ValueError, IndexError) as e:
                raise InstrumentParameterError(
                    parameter="source",
                    value=source,
                    message="Invalid channel format in source.",
                ) from e
        elif actual_source not in ["EXTERNAL", "LINE", "WGEN"]:
            raise InstrumentParameterError(
                parameter="source",
                value=source,
                valid_range=["EXTernal", "LINE", "WGEN"],
                message="Invalid source.",
            )

    if slope.value not in self.config.trigger.slopes:
        raise InstrumentParameterError(
            parameter="slope",
            value=slope.value,
            valid_range=self.config.trigger.slopes,
            message="Unsupported trigger slope.",
        )
    scpi_slope = slope.value

    if mode.upper() not in [
        m.upper() for m in self.config.trigger.modes
    ]:  # Case-insensitive check
        self._logger.warning(
            f"Trigger mode '{mode}' not in configured supported modes: {self.config.trigger.modes}. Passing directly to instrument."
        )
    scpi_mode = mode

    for cmd in self.scpi_engine.build(
        "configure_trigger",
        source=actual_source,
        level=level,
        channel=channel,
        slope=scpi_slope,
        mode=scpi_mode,
    ):
        self._send_command(cmd)
    self._wait()
    self._logger.debug(
        f"Trigger set → source={actual_source}, level={level}, slope={scpi_slope}, mode={scpi_mode}"
    )
    return
display_channel(channels, state=True)

Display or hide the specified channel(s) on the oscilloscope.

Args: channels (Union[int, List[int]]): A single channel number or a list of channel numbers. state (bool): True to display (ON), False to hide (OFF). Default is True.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def display_channel(self, channels: int | list[int], state: bool = True) -> None:
    """
    Display or hide the specified channel(s) on the oscilloscope.

    Args:
    channels (Union[int, List[int]]): A single channel number or a list of channel numbers.
    state (bool): True to display (ON), False to hide (OFF). Default is True.
    """
    ch_list: list[int]
    if isinstance(channels, int):
        ch_list = [channels]
    elif isinstance(channels, list) and all(isinstance(ch, int) for ch in channels):
        ch_list = channels
    else:
        # validate_call should catch this if type hints are precise enough
        raise InstrumentParameterError(message="channels must be an int or a list of ints")

    for ch_num in ch_list:
        if not (1 <= ch_num <= len(self.config.channels)):
            raise InstrumentParameterError(
                parameter="channels",
                value=ch_num,
                valid_range=(1, len(self.config.channels)),
                message="Channel number is out of range.",
            )
        for cmd in self.scpi_engine.build("display_channel", channel=ch_num, state=state):
            self._send_command(cmd)
fft_display(state=True)

Switches on or off the FFT display.

:param state: True to enable FFT display, False to disable.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("fft")
def fft_display(self, state: bool = True) -> None:
    """
    Switches on or off the FFT display.

    :param state: True to enable FFT display, False to disable.
    """
    if self.config.fft is None:
        raise InstrumentConfigurationError(
            self.config.model, "FFT not configured for this instrument."
        )
    scpi_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    for cmd in self.scpi_engine.build("fft_display", state=scpi_state):
        self._send_command(cmd)
    self._logger.debug(f"FFT display {'enabled' if state else 'disabled'}.")
franalysis_sweep(input_channel, output_channel, start_freq, stop_freq, amplitude, points=10, trace='none', load='onemeg', disable_on_complete=True)

Perform a frequency response analysis sweep.

RETURNS DESCRIPTION
FRanalysisResult

Containing the frequency response analysis data.

TYPE: FRanalysisResult

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("franalysis")
# @ConfigRequires("function_generator")
def franalysis_sweep(
    self,
    input_channel: int,
    output_channel: int,
    start_freq: float,
    stop_freq: float,
    amplitude: float,
    points: int = 10,
    trace: str = "none",
    load: str = "onemeg",
    disable_on_complete: bool = True,
) -> FRanalysisResult:
    """
    Perform a frequency response analysis sweep.

    Returns:
        FRanalysisResult: Containing the frequency response analysis data.
    """
    if self.config.function_generator is None or self.config.franalysis is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator or FRANalysis not configured."
        )

    if not (1 <= input_channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="input_channel",
            value=input_channel,
            valid_range=(1, len(self.config.channels)),
            message="Input channel is out of range.",
        )
    if not (1 <= output_channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="output_channel",
            value=output_channel,
            valid_range=(1, len(self.config.channels)),
            message="Output channel is out of range.",
        )

    # Ensure points is at least 2 for a valid sweep
    if points < 2:
        raise InstrumentParameterError(
            parameter="points",
            value=points,
            valid_range=(2, "inf"),
            message="Points for sweep must be at least 2.",
        )

    # SCPI commands for frequency response analysis sweep via SCPIEngine only
    if self.config.franalysis is None or self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "FRAnalysis or function generator not configured."
        )
    for cmd in self.scpi_engine.build("fran_enable"):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_start", value=start_freq):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_stop", value=stop_freq):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_amplitude", value=amplitude):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_points", value=points):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_trace", value=trace):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("fran_load", value=load):
        self._send_command(cmd)
    if disable_on_complete:
        for cmd in self.scpi_engine.build("fran_disable"):
            self._send_command(cmd)
    self._wait()
    result_data = self._query(self.scpi_engine.build("fran_fetch")[0])

    # Parse the result data into a structured format if needed
    # For now, let's assume it's a simple comma-separated value string
    parsed_results = [float(val) for val in result_data.split(",")]

    # Create a DataFrame or structured result object
    # Assuming two columns: Frequency and Magnitude
    freq_values = parsed_results[0::2]  # Extracting frequency values
    mag_values = parsed_results[1::2]  # Extracting magnitude values

    return FRanalysisResult(
        instrument=self.config.model,
        units="",
        measurement_type="FrequencyResponse",
        values=pl.DataFrame({"Frequency (Hz)": freq_values, "Magnitude": mag_values}),
    )
from_config(config, debug_mode=False) classmethod
Source code in pytestlab/instruments/Oscilloscope.py
@classmethod
def from_config(
    cls: type[Oscilloscope], config: InstrumentConfig, debug_mode: bool = False
) -> Oscilloscope:
    # This method aligns with the new __init__ signature.
    if not isinstance(config, OscilloscopeConfig):
        raise InstrumentConfigurationError(
            cls.__name__, "from_config expects an OscilloscopeConfig object."
        )
    return cls(config=config, debug_mode=debug_mode)
function_display(state=True)

Switches on or off the function display (e.g. Math or WGEN waveform).

:param state: True to enable display, False to disable.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def function_display(self, state: bool = True) -> None:
    """
    Switches on or off the function display (e.g. Math or WGEN waveform).

    :param state: True to enable display, False to disable.
    """
    scpi_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    for cmd in self.scpi_engine.build("function_display", state=scpi_state):
        self._send_command(cmd)
    self._logger.debug(f"Function display {'enabled' if state else 'disabled'}.")
get_channel_axis(channel)

Gets the channel axis of the oscilloscope. (y-axis)

:param channel: The channel to get the axis for :return: A list containing the channel axis scale and offset

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_channel_axis(self, channel: int) -> list[float]:
    """
    Gets the channel axis of the oscilloscope. (y-axis)

    :param channel: The channel to get the axis for
    :return: A list containing the channel axis scale and offset
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    s = self.scpi_engine.parse(
        "get_channel_scale",
        self._query(self.scpi_engine.build("get_channel_scale", channel=channel)[0]),
    )
    o = self.scpi_engine.parse(
        "get_channel_offset",
        self._query(self.scpi_engine.build("get_channel_offset", channel=channel)[0]),
    )
    return [np.float64(s), np.float64(o)]
get_probe_attenuation(channel)

Gets the probe attenuation for a given channel.

PARAMETER DESCRIPTION
channel

The oscilloscope channel to get the probe attenuation for.

TYPE: int

RETURNS DESCRIPTION
str

The probe attenuation value (e.g., '10:1', '1:1').

TYPE: str

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_probe_attenuation(self, channel: int) -> str:  # Returns string like "10:1"
    """
    Gets the probe attenuation for a given channel.

    Parameters:
        channel (int): The oscilloscope channel to get the probe attenuation for.

    Returns:
        str: The probe attenuation value (e.g., '10:1', '1:1').
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )
    response_str: str = (
        self._query(self.scpi_engine.build("probe_get", channel=channel)[0])
    ).strip()
    try:
        num_factor = float(response_str)
        if num_factor.is_integer():
            return f"{int(num_factor)}:1"
        return f"{num_factor}:1"
    except ValueError:
        self._logger.warning(
            f"Could not parse probe attenuation factor '{response_str}' as number. Returning raw."
        )
        return response_str  # Or raise error
get_sampling_rate()

Get the current sampling rate of the oscilloscope. Returns: float: The sampling rate in Hz.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_sampling_rate(self) -> float:
    """
    Get the current sampling rate of the oscilloscope.
    Returns:
        float: The sampling rate in Hz.
    """
    v = self.scpi_engine.parse(
        "acquire_sample_rate", self._query(self.scpi_engine.build("acquire_sample_rate")[0])
    )
    return np.float64(v)
get_time_axis()

Gets the time axis of the oscilloscope. (x-axis)

:return: A list containing the time axis scale and position

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_time_axis(self) -> list[float]:
    """
    Gets the time axis of the oscilloscope. (x-axis)

    :return: A list containing the time axis scale and position
    """
    s = self.scpi_engine.parse(
        "get_timebase_scale", self._query(self.scpi_engine.build("get_timebase_scale")[0])
    )
    p = self.scpi_engine.parse(
        "get_timebase_position", self._query(self.scpi_engine.build("get_timebase_position")[0])
    )
    return [np.float64(s), np.float64(p)]
health_check()

Performs a basic health check of the oscilloscope instrument.

RETURNS DESCRIPTION
HealthReport

A report containing the instrument's health status, errors, warnings, and supported features.

TYPE: HealthReport

Source code in pytestlab/instruments/Oscilloscope.py
def health_check(self) -> HealthReport:
    """
    Performs a basic health check of the oscilloscope instrument.

    Returns:
        HealthReport: A report containing the instrument's health status,
                      errors, warnings, and supported features.
    """
    report = HealthReport()

    try:
        # Get instrument identification
        report.instrument_idn = self.id()

        # Check for stored errors
        instrument_errors = self.get_all_errors()
        if instrument_errors:
            report.warnings.extend(
                [f"Stored Error: {code} - {msg}" for code, msg in instrument_errors]
            )

        # Set initial status based on errors
        if not report.errors and not report.warnings:
            report.status = HealthStatus.OK
        elif report.warnings and not report.errors:
            report.status = HealthStatus.WARNING
        else:
            report.status = HealthStatus.ERROR

    except Exception as e:
        report.status = HealthStatus.ERROR
        report.errors.append(f"Health check failed during IDN/Error Query: {str(e)}")

    try:
        # Test basic oscilloscope functionality
        _ = self.get_time_axis()

        # Check supported features based on configuration
        if hasattr(self.config, "fft") and self.config.fft:
            report.supported_features["fft"] = True
        else:
            report.supported_features["fft"] = False

        if hasattr(self.config, "franalysis") and self.config.franalysis:
            report.supported_features["franalysis"] = True
        else:
            report.supported_features["franalysis"] = False

        if hasattr(self.config, "function_generator") and self.config.function_generator:
            report.supported_features["function_generator"] = True
        else:
            report.supported_features["function_generator"] = False

    except Exception as e:
        report.errors.append(f"Oscilloscope-specific check failed: {str(e)}")

    # Determine backend status
    if hasattr(self, "_backend") and hasattr(self._backend, "__class__"):
        backend_name = self._backend.__class__.__name__
        if "SimBackend" in backend_name:
            report.backend_status = "Simulated"
        elif "VisaBackend" in backend_name:
            report.backend_status = "VISA Connection"
        elif "LambInstrument" in backend_name or "LambBackend" in backend_name:
            report.backend_status = "Lamb Connection"
        else:
            report.backend_status = f"Unknown backend: {backend_name}"
    else:
        report.backend_status = "Backend information unavailable"

    # Final status evaluation
    if report.errors and report.status != HealthStatus.ERROR:
        report.status = HealthStatus.ERROR
    elif report.warnings and report.status == HealthStatus.OK:
        report.status = HealthStatus.WARNING

    # If no errors or warnings after all checks, and status is still UNKNOWN, set to OK
    if report.status == HealthStatus.UNKNOWN and not report.errors and not report.warnings:
        report.status = HealthStatus.OK

    return report
lock_panel(lock=True)

Locks the panel of the instrument

PARAMETER DESCRIPTION
lock

True to lock the panel, False to unlock it

TYPE: bool DEFAULT: True

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def lock_panel(self, lock: bool = True) -> None:
    """
    Locks the panel of the instrument

    Args:
        lock (bool): True to lock the panel, False to unlock it
    """
    scpi_state = SCPIOnOff.ON.value if lock else SCPIOnOff.OFF.value
    for cmd in self.scpi_engine.build("system_lock", state=scpi_state):
        self._send_command(cmd)
measure_rms_voltage(channel)

Measure the root-mean-square (RMS) voltage for a specified channel.

Args: channel (int): The channel identifier.

Returns: MeasurementResult: An object containing the RMS voltage measurement.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def measure_rms_voltage(self, channel: int) -> MeasurementResult:
    """
    Measure the root-mean-square (RMS) voltage for a specified channel.

    Args:
    channel (int): The channel identifier.

    Returns:
    MeasurementResult: An object containing the RMS voltage measurement.
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    q = self.scpi_engine.build("measure_vrms", channel=channel)[0]
    reading = float(self.scpi_engine.parse("measure_vrms", self._query(q)))

    value_to_return: float | UFloat = reading

    if self.config.measurement_accuracy:
        mode_key = f"vrms_ch{channel}"
        self._logger.debug(
            f"Attempting to find accuracy spec for Vrms on channel {channel} with key: '{mode_key}'"
        )
        spec = self.config.measurement_accuracy.get(mode_key)
        if spec:
            sigma = spec.calculate_std_dev(reading, range_value=None)
            if sigma > 0:
                value_to_return = ufloat(reading, sigma)
                self._logger.debug(
                    f"Applied accuracy spec '{mode_key}', value: {value_to_return}"
                )
            else:
                self._logger.debug(
                    f"Accuracy spec '{mode_key}' resulted in sigma=0. Returning float."
                )
        else:
            self._logger.debug(
                f"No accuracy spec found for Vrms on channel {channel} with key '{mode_key}'. Returning float."
            )
    else:
        self._logger.debug(
            f"No measurement_accuracy configuration in instrument for Vrms on channel {channel}. Returning float."
        )

    self._logger.debug(f"RMS Voltage (Channel {channel}): {value_to_return}")

    measurement_result = MeasurementResult(
        values=value_to_return,
        instrument=self.config.model,
        units="V",
        measurement_type="rms_voltage",
    )
    return measurement_result
measure_voltage_peak_to_peak(channel)

Measure the peak-to-peak voltage for a specified channel.

Args: channel (int): The channel identifier.

Returns: MeasurementResult: An object containing the peak-to-peak voltage measurement.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def measure_voltage_peak_to_peak(self, channel: int) -> MeasurementResult:
    """
    Measure the peak-to-peak voltage for a specified channel.

    Args:
    channel (int): The channel identifier.

    Returns:
    MeasurementResult: An object containing the peak-to-peak voltage measurement.
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    q = self.scpi_engine.build("measure_vpp", channel=channel)[0]
    reading = float(self.scpi_engine.parse("measure_vpp", self._query(q)))

    value_to_return: float | UFloat = reading

    if self.config.measurement_accuracy:
        mode_key = f"vpp_ch{channel}"
        self._logger.debug(
            f"Attempting to find accuracy spec for Vpp on channel {channel} with key: '{mode_key}'"
        )
        spec = self.config.measurement_accuracy.get(mode_key)
        if spec:
            sigma = spec.calculate_std_dev(reading, range_value=None)
            if sigma > 0:
                value_to_return = ufloat(reading, sigma)
                self._logger.debug(
                    f"Applied accuracy spec '{mode_key}', value: {value_to_return}"
                )
            else:
                self._logger.debug(
                    f"Accuracy spec '{mode_key}' resulted in sigma=0. Returning float."
                )
        else:
            self._logger.debug(
                f"No accuracy spec found for Vpp on channel {channel} with key '{mode_key}'. Returning float."
            )
    else:
        self._logger.debug(
            f"No measurement_accuracy configuration in instrument for Vpp on channel {channel}. Returning float."
        )

    measurement_result = MeasurementResult(
        values=value_to_return, units="V", instrument=self.config.model, measurement_type="P2PV"
    )

    self._logger.debug(f"Peak to Peak Voltage (Channel {channel}): {value_to_return}")

    return measurement_result
plot_channels(*channels, points=None, run_after=True, timebase=None, spec=None, **kwargs)

Convenience: acquire one or more channels and plot the result.

PARAMETER DESCRIPTION
channels

Channel numbers to read (e.g., 1 or (1,2)).

TYPE: int | list[int] | tuple[int, ...] DEFAULT: ()

points

Optional point count (instrument-specific behavior).

TYPE: int | None DEFAULT: None

run_after

Whether to run a fresh acquisition prior to readback.

TYPE: bool DEFAULT: True

timebase

Optional time-base scale in seconds.

TYPE: float | None DEFAULT: None

spec

Optional PlotSpec. If omitted, constructed from kwargs.

TYPE: PlotSpec | None DEFAULT: None

**kwargs

PlotSpec fields (kind, x, y, title, xlabel, ylabel, legend, grid).

DEFAULT: {}

RETURNS DESCRIPTION

A matplotlib Figure.

Source code in pytestlab/instruments/Oscilloscope.py
def plot_channels(
    self,
    *channels: int | list[int] | tuple[int, ...],
    points: int | None = None,
    run_after: bool = True,
    timebase: float | None = None,
    spec: PlotSpec | None = None,
    **kwargs,
):
    """
    Convenience: acquire one or more channels and plot the result.

    Args:
        channels: Channel numbers to read (e.g., 1 or (1,2)).
        points: Optional point count (instrument-specific behavior).
        run_after: Whether to run a fresh acquisition prior to readback.
        timebase: Optional time-base scale in seconds.
        spec: Optional PlotSpec. If omitted, constructed from kwargs.
        **kwargs: PlotSpec fields (kind, x, y, title, xlabel, ylabel, legend, grid).

    Returns:
        A matplotlib Figure.
    """
    from ..plotting import PlotSpec  # local import keeps plotting optional

    result = self.read_channels(
        *channels, points=points, run_after=run_after, timebase=timebase
    )
    plot_spec = spec or (PlotSpec(**kwargs) if kwargs else PlotSpec())
    return result.plot(plot_spec)
read_channels(*channels, run_after=True, timebase=None, **kwargs)

Acquire one or more channels and return a ChannelReadingResult with a correct per-channel Y scaling.

This implementation queries a fresh waveform preamble for every channel so that Y-axis scaling (yinc/yorg/yref) is applied correctly even when the channels have different vertical settings.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
@validate_call
def read_channels(
    self,
    *channels: int | list[int] | tuple[int, ...],
    run_after: bool = True,
    timebase: float | None = None,
    **kwargs,
) -> ChannelReadingResult:
    """
    Acquire one or more channels and return a ChannelReadingResult with a correct
    per-channel Y scaling.

    This implementation queries a fresh waveform preamble **for every channel**
    so that Y-axis scaling (yinc/yorg/yref) is applied correctly even when the
    channels have different vertical settings.
    """
    # ---------------------- argument normalisation (unchanged) ----------------------
    if "runAfter" in kwargs:
        warnings.warn(
            "'runAfter' is deprecated, use 'run_after' instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _ = kwargs["runAfter"]

    if not channels:
        raise InstrumentParameterError(message="No channels specified.")

    processed_channels: list[int] = []
    if len(channels) == 1 and isinstance(channels[0], list | tuple):
        seq = cast(tuple[int, ...] | list[int], channels[0])
        for c in seq:
            if not isinstance(c, int):
                raise InstrumentParameterError(message="Channel numbers must be integers.")
            processed_channels.append(c)
    else:
        for item in channels:
            if isinstance(item, list | tuple):
                seq2 = cast(tuple[int, ...] | list[int], item)
                for x in seq2:
                    if not isinstance(x, int):
                        raise InstrumentParameterError(
                            message="Channel numbers must be integers."
                        )
                    processed_channels.append(x)
            else:
                if not isinstance(item, int):
                    raise InstrumentParameterError(message="Channel numbers must be integers.")
                processed_channels.append(item)

    for ch in processed_channels:
        if not (1 <= ch <= len(self.config.channels)):
            raise InstrumentParameterError(
                parameter="channels",
                value=ch,
                valid_range=(1, len(self.config.channels)),
                message="Channel number is out of range.",
            )

    # -------------------- optional time-base tweak (unchanged) ---------------------
    if timebase is not None:
        cur_scale, cur_pos = self.get_time_axis()
        self.set_time_axis(scale=timebase, position=cur_pos)

    # ----------------------------- acquire waveform --------------------------------
    chan_list_str = ", ".join(f"CHANnel{ch}" for ch in processed_channels)
    for cmd in self.scpi_engine.build("digitize", sources=chan_list_str):
        self._send_command(cmd)

    sampling_rate = float(self.get_sampling_rate())

    time_array: np.ndarray | None = None
    columns: dict[str, np.ndarray] = {}

    for _idx, ch in enumerate(processed_channels, start=1):
        # Select channel as waveform source and fetch its preamble
        for cmd in self.scpi_engine.build("set_wave_source", source=f"CHANnel{ch}"):
            self._send_command(cmd)
        pre = self._read_preamble()

        raw = self._read_wave_data(f"CHANnel{ch}")

        # Convert Y-axis using **this channel's** preamble
        volts = (raw - pre.yref) * pre.yinc + pre.yorg
        columns[f"Channel {ch} (V)"] = volts

        # Only need to compute the common time axis once
        if time_array is None:
            n_pts = len(volts)
            time_array = (np.arange(n_pts) - pre.xref) * pre.xinc + pre.xorg

    if time_array is None:
        raise InstrumentDataError(self.config.model, "Time axis generation failed.")

    return ChannelReadingResult(
        instrument=self.config.model,
        units="V",
        measurement_type="ChannelVoltageTime",
        sampling_rate=sampling_rate,
        values=pl.DataFrame({"Time (s)": time_array, **columns}),
    )
read_fft_data(channel, window='hann')

Acquires time-domain data for the specified channel and computes the FFT using the analysis submodule.

PARAMETER DESCRIPTION
channel

The channel number to perform FFT on.

TYPE: int

window

The windowing function to apply before FFT (e.g., 'hann', 'hamming', None).

TYPE: Optional[str] DEFAULT: 'hann'

RETURNS DESCRIPTION
FFTResult

An object containing the computed FFT data (frequency and linear magnitude).

TYPE: FFTResult

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def read_fft_data(self, channel: int, window: str | None = "hann") -> FFTResult:
    """
    Acquires time-domain data for the specified channel and computes the FFT using
    the analysis submodule.

    Args:
        channel (int): The channel number to perform FFT on.
        window (Optional[str]): The windowing function to apply before FFT
                                 (e.g., 'hann', 'hamming', None).

    Returns:
        FFTResult: An object containing the computed FFT data (frequency and linear magnitude).
    """
    self._logger.debug(
        f"Initiating FFT computation for channel {channel} using analysis module."
    )

    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    # 1. Acquire raw time-domain waveform data
    waveform_data: ChannelReadingResult = self.read_channels(channel)

    if not isinstance(waveform_data.values, pl.DataFrame) or waveform_data.values.is_empty():
        self._logger.warning(
            f"No waveform data acquired for channel {channel}. Cannot compute FFT."
        )
        # Return an empty FFTResult or raise an error
        return FFTResult(
            instrument=self.config.model,
            units="Linear",
            measurement_type="FFT_computed_python",
            values=pl.DataFrame(
                {"Frequency (Hz)": np.array([]), "Magnitude (Linear)": np.array([])}
            ),
        )

    time_array = waveform_data.values["Time (s)"].to_numpy()
    voltage_column_name = f"Channel {channel} (V)"
    if voltage_column_name not in waveform_data.values.columns:
        raise InstrumentDataError(
            self.config.model,
            f"Could not find voltage data for channel {channel} in waveform results.",
        )
    voltage_array = waveform_data.values[voltage_column_name].to_numpy()

    # 2. Call the appropriate function from pytestlab.analysis.fft
    frequency_array, magnitude_array = analysis_fft.compute_fft(
        time_array=time_array, voltage_array=voltage_array, window=window
    )

    # 3. Return or further process the results
    return FFTResult(
        instrument=self.config.model,
        units="Linear",  # compute_fft returns linear magnitude
        measurement_type="FFT_computed_python",
        values=pl.DataFrame(
            {"Frequency (Hz)": frequency_array, "Magnitude (Linear)": magnitude_array}
        ),
    )
screenshot()

Capture a screenshot of the oscilloscope display.

:return Image: A PIL Image object containing the screenshot.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def screenshot(self) -> Image.Image:
    """
    Capture a screenshot of the oscilloscope display.

    :return Image: A PIL Image object containing the screenshot.
    """
    binary_data_response: bytes = self._query_raw(self.scpi_engine.build("screenshot")[0])

    if not binary_data_response.startswith(b"#"):
        raise InstrumentDataError(
            self.config.model, "Invalid screenshot data format: does not start with #"
        )

    length_of_length_field: int = int(chr(binary_data_response[1]))
    png_data_length_str: str = binary_data_response[2 : 2 + length_of_length_field].decode(
        "ascii"
    )
    png_data_length: int = int(png_data_length_str)
    png_data_start_index: int = 2 + length_of_length_field
    image_data_bytes: bytes = binary_data_response[
        png_data_start_index : png_data_start_index + png_data_length
    ]

    return Image.open(BytesIO(image_data_bytes))
set_acquisition_time(time)

Set the total acquisition time for the oscilloscope.

PARAMETER DESCRIPTION
time

The total acquisition time in seconds.

TYPE: float

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_acquisition_time(self, time: float) -> None:
    """
    Set the total acquisition time for the oscilloscope.

    Args:
        time (float): The total acquisition time in seconds.
    """
    for cmd in self.scpi_engine.build("timebase_main_range", time=time):
        self._send_command(cmd)
set_bandwidth_limit(channel, bandwidth)

Sets the bandwidth limit for a specified channel. Args: channel (int): The channel number. bandwidth (Union[str, float]): The bandwidth limit (e.g., "20M", 20e6, or "FULL").

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_bandwidth_limit(self, channel: int, bandwidth: str | float) -> None:
    """
    Sets the bandwidth limit for a specified channel.
    Args:
        channel (int): The channel number.
        bandwidth (Union[str, float]): The bandwidth limit (e.g., "20M", 20e6, or "FULL").
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )
    for cmd in self.scpi_engine.build(
        "channel_bandwidth", channel=channel, bandwidth=bandwidth
    ):
        self._send_command(cmd)
set_channel_axis(channel, scale, offset)

Sets the channel axis of the oscilloscope. (y-axis)

:param channel: The channel to set :param scale: The scale of the channel axis in volts :param offset: The offset of the channel in volts

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_channel_axis(self, channel: int, scale: float, offset: float) -> None:
    """
    Sets the channel axis of the oscilloscope. (y-axis)

    :param channel: The channel to set
    :param scale: The scale of the channel axis in volts
    :param offset: The offset of the channel in volts
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    for cmd in self.scpi_engine.build(
        "set_channel_axis", channel=channel, scale=scale, offset=offset
    ):
        self._send_command(cmd)
    self._wait()
set_probe_attenuation(channel, scale)

Sets the probe scale for a given channel.

PARAMETER DESCRIPTION
channel

The oscilloscope channel to set the scale for.

TYPE: int

scale

The probe scale value (e.g., 10 for 10:1, 1 for 1:1).

TYPE: int

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_probe_attenuation(self, channel: int, scale: int) -> None:
    """
    Sets the probe scale for a given channel.

    Parameters:
        channel (int): The oscilloscope channel to set the scale for.
        scale (int): The probe scale value (e.g., 10 for 10:1, 1 for 1:1).
    """
    if not (1 <= channel <= len(self.config.channels)):
        raise InstrumentParameterError(
            parameter="channel",
            value=channel,
            valid_range=(1, len(self.config.channels)),
            message="Channel number is out of range.",
        )

    channel_model_config = self.config.channels[channel - 1]
    if scale not in channel_model_config.probe_attenuation:  # probe_attenuation is List[int]
        raise InstrumentParameterError(
            parameter="scale",
            value=scale,
            valid_range=channel_model_config.probe_attenuation,
            message=f"Scale not in supported probe_attenuation list for channel {channel}.",
        )

    for cmd in self.scpi_engine.build("probe_set", channel=channel, scale=scale):
        self._send_command(cmd)
    self._logger.debug(f"Set probe scale to {scale}:1 for channel {channel}.")
set_sample_rate(rate)

Sets the sample rate for the oscilloscope.

Args: rate (str): The desired sample rate. Valid values are 'MAX' and 'AUTO'. Case-insensitive.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_sample_rate(self, rate: str) -> None:
    """
    Sets the sample rate for the oscilloscope.

    Args:
    rate (str): The desired sample rate. Valid values are 'MAX' and 'AUTO'. Case-insensitive.
    """
    rate_upper: str = rate.upper()
    valid_values: list[str] = ["MAX", "AUTO"]  # These are common SCPI values
    if rate_upper not in valid_values:
        raise InstrumentParameterError(
            parameter="rate",
            value=rate,
            valid_range=valid_values,
            message="Invalid rate.",
        )
    for cmd in self.scpi_engine.build("acquire_set_rate", rate=rate_upper):
        self._send_command(cmd)
set_time_axis(scale, position)

Sets the time axis of the Oscilloscope. (x-axis)

:param scale: scale The scale of the axis in seconds :param position: The position of the time axis from the trigger in seconds

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_time_axis(self, scale: float, position: float) -> None:
    """
    Sets the time axis of the Oscilloscope. (x-axis)

    :param scale: scale The scale of the axis in seconds
    :param position: The position of the time axis from the trigger in seconds
    """
    timebase_range = None
    # Prefer explicit timebase settings, fall back to first channel metadata.
    timebase_settings = getattr(self.config, "timebase_settings", None)
    if timebase_settings is not None:
        tb_range = getattr(timebase_settings, "range", None)
        if tb_range is not None:
            timebase_range = tb_range
    elif self.config.channels:
        first_channel = self.config.channels[0]
        channel_tb = getattr(first_channel, "timebase", None)
        timebase_range = getattr(channel_tb, "range", None)

    if not math.isfinite(scale):
        valid_range = None
        if timebase_range is not None:
            valid_range = (timebase_range.min_val, timebase_range.max_val)
        raise InstrumentParameterError(
            parameter="scale",
            value=scale,
            valid_range=valid_range,
            message="Scale must be a finite value.",
        )
    if timebase_range is not None:
        timebase_range.assert_in_range(scale, "scale")
    elif scale <= 0:
        raise InstrumentParameterError(
            parameter="scale",
            value=scale,
            valid_range=(0.0, math.inf),
            message="Scale must be greater than zero.",
        )

    if not math.isfinite(position):
        raise InstrumentParameterError(
            parameter="position",
            value=position,
            message="Position must be a finite value.",
        )

    for cmd in self.scpi_engine.build("set_time_axis", scale=scale, position=position):
        self._send_command(cmd)
    self._wait()
set_wave_gen_amp(amp)

Set the amplitude for the waveform generator.

Args: amp (float): The desired amplitude for the waveform generator in volts.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wave_gen_amp(self, amp: float) -> None:
    """
    Set the amplitude for the waveform generator.

    Args:
    amp (float): The desired amplitude for the waveform generator in volts.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.config.function_generator.amplitude.assert_in_range(
        amp, name="Waveform generator amplitude"
    )
    cmds = self.scpi_engine.build("wgen_set_volt", amplitude=amp)
    for cmd in cmds:
        self._send_command(cmd)
set_wave_gen_freq(freq)

Set the frequency for the waveform generator.

Args: freq (float): The desired frequency for the waveform generator in Hz.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wave_gen_freq(self, freq: float) -> None:
    """
    Set the frequency for the waveform generator.

    Args:
    freq (float): The desired frequency for the waveform generator in Hz.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    # Assuming RangeMixin's assert_in_range is preferred for validation
    self.config.function_generator.frequency.assert_in_range(
        freq, name="Waveform generator frequency"
    )
    cmds = self.scpi_engine.build("wgen_set_freq", frequency=freq)
    for cmd in cmds:
        self._send_command(cmd)
set_wave_gen_func(func_type)

Set the waveform function for the oscilloscope's waveform generator.

Args: func_type (WaveformType): The desired function enum member.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wave_gen_func(self, func_type: WaveformType) -> None:
    """
    Set the waveform function for the oscilloscope's waveform generator.

    Args:
    func_type (WaveformType): The desired function enum member.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )

    # Check if the SCPI value of the enum is in the list of supported waveform types from config
    if func_type.value not in self.config.function_generator.waveform_types:
        raise InstrumentParameterError(
            parameter="func_type",
            value=func_type.value,
            valid_range=self.config.function_generator.waveform_types,
            message="Unsupported waveform type.",
        )

    cmds = self.scpi_engine.build("wgen_set_func", func=func_type.value)
    for cmd in cmds:
        self._send_command(cmd)
set_wave_gen_offset(offset)

Set the voltage offset for the waveform generator.

Args: offset (float): The desired voltage offset for the waveform generator in volts.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wave_gen_offset(self, offset: float) -> None:
    """
    Set the voltage offset for the waveform generator.

    Args:
    offset (float): The desired voltage offset for the waveform generator in volts.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.config.function_generator.offset.assert_in_range(
        offset, name="Waveform generator offset"
    )
    cmds = self.scpi_engine.build("wgen_set_offset", offset=offset)
    for cmd in cmds:
        self._send_command(cmd)
set_wgen_dc(offset)

Sets the waveform generator to a DC wave.

:param offset: The offset of the DC wave in volts

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_dc(self, offset: float) -> None:
    """Sets the waveform generator to a DC wave.

    :param offset: The offset of the DC wave in volts
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.set_wave_gen_func(WaveformType.DC)
    self.set_wave_gen_offset(offset)
set_wgen_noise(v0, v1, offset)

Sets the waveform generator to a noise wave.

:param v0: The 'low' amplitude component or similar parameter for noise. :param v1: The 'high' amplitude component or similar parameter for noise. :param offset: The offset of the noise wave in volts.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_noise(self, v0: float, v1: float, offset: float) -> None:
    """Sets the waveform generator to a noise wave.

    :param v0: The 'low' amplitude component or similar parameter for noise.
    :param v1: The 'high' amplitude component or similar parameter for noise.
    :param offset: The offset of the noise wave in volts.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.set_wave_gen_func(WaveformType.NOISE)
    for cmd in self.scpi_engine.build("wgen_set_low", value=v0):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_high", value=v1):
        self._send_command(cmd)
    self.set_wave_gen_offset(offset)
set_wgen_pulse(v0, v1, period, pulse_width=None, **kwargs)

Sets the waveform generator to a pulse wave.

:param v0: The voltage of the low state in volts :param v1: The voltage of the high state in volts :param period: The period of the pulse wave in seconds. :param pulse_width: The pulse width in seconds.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_pulse(
    self, v0: float, v1: float, period: float, pulse_width: float | None = None, **kwargs
) -> None:
    """Sets the waveform generator to a pulse wave.

    :param v0: The voltage of the low state in volts
    :param v1: The voltage of the high state in volts
    :param period: The period of the pulse wave in seconds.
    :param pulse_width: The pulse width in seconds.
    """
    if "pulseWidth" in kwargs:
        warnings.warn(
            "'pulseWidth' is deprecated, use 'pulse_width' instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        pulse_width = kwargs["pulseWidth"]

    if pulse_width is None:
        raise InstrumentParameterError(message="pulse_width is required.")

    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.set_wave_gen_func(WaveformType.PULSE)

    for cmd in self.scpi_engine.build("wgen_set_low", value=v0):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_high", value=v1):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_period", period=period):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_pulse_width", width=pulse_width):
        self._send_command(cmd)
set_wgen_ramp(v0, v1, freq, symmetry)

Sets the waveform generator to a ramp wave.

:param v0: The voltage of the low state in volts :param v1: The voltage of the high state in volts :param freq: The frequency of the ramp wave in Hz. :param symmetry: Symmetry (0% to 100%).

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_ramp(self, v0: float, v1: float, freq: float, symmetry: int) -> None:
    """Sets the waveform generator to a ramp wave.

    :param v0: The voltage of the low state in volts
    :param v1: The voltage of the high state in volts
    :param freq: The frequency of the ramp wave in Hz.
    :param symmetry: Symmetry (0% to 100%).
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.set_wave_gen_func(WaveformType.RAMP)

    def clamp_symmetry(number: int) -> int:
        return max(0, min(number, 100))

    for cmd in self.scpi_engine.build("wgen_set_low", value=v0):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_high", value=v1):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_freq", frequency=freq):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build(
        "wgen_set_ramp_symmetry", symmetry=clamp_symmetry(symmetry)
    ):
        self._send_command(cmd)
set_wgen_sin(amp, offset, freq)

Sets the waveform generator to a sine wave.

:param amp: The amplitude of the sine wave in volts :param offset: The offset of the sine wave in volts :param freq: The frequency of the sine wave in Hz.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_sin(self, amp: float, offset: float, freq: float) -> None:
    """Sets the waveform generator to a sine wave.

    :param amp: The amplitude of the sine wave in volts
    :param offset: The offset of the sine wave in volts
    :param freq: The frequency of the sine wave in Hz.
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    self.set_wave_gen_func(WaveformType.SINE)
    self.set_wave_gen_amp(amp)
    self.set_wave_gen_offset(offset)
    self.set_wave_gen_freq(freq)
set_wgen_square(v0, v1, freq, duty_cycle=None, **kwargs)

Sets the waveform generator to a square wave.

:param v0: The voltage of the low state in volts :param v1: The voltage of the high state in volts :param freq: The frequency of the square wave in Hz. :param duty_cycle: The duty cycle (1% to 99%).

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def set_wgen_square(
    self, v0: float, v1: float, freq: float, duty_cycle: int | None = None, **kwargs
) -> None:
    """Sets the waveform generator to a square wave.

    :param v0: The voltage of the low state in volts
    :param v1: The voltage of the high state in volts
    :param freq: The frequency of the square wave in Hz.
    :param duty_cycle: The duty cycle (1% to 99%).
    """
    if "dutyCycle" in kwargs:
        warnings.warn(
            "'dutyCycle' is deprecated, use 'duty_cycle' instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        duty_cycle = kwargs["dutyCycle"]

    if duty_cycle is None:
        duty_cycle = 50

    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )

    self.set_wave_gen_func(WaveformType.SQUARE)

    def clamp_duty(number: int) -> int:
        return max(1, min(number, 99))

    for cmd in self.scpi_engine.build("wgen_set_low", value=v0):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_high", value=v1):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_freq", frequency=freq):
        self._send_command(cmd)
    for cmd in self.scpi_engine.build("wgen_set_square_duty", duty=clamp_duty(duty_cycle)):
        self._send_command(cmd)
wave_gen(state)

Enable or disable the waveform generator of the oscilloscope.

Args: state (bool): True to enable ('ON'), False to disable ('OFF').

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
# @ConfigRequires("function_generator")
def wave_gen(self, state: bool) -> None:
    """
    Enable or disable the waveform generator of the oscilloscope.

    Args:
    state (bool): True to enable ('ON'), False to disable ('OFF').
    """
    if self.config.function_generator is None:
        raise InstrumentConfigurationError(
            self.config.model, "Function generator not configured."
        )
    scpi_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    cmds = self.scpi_engine.build("wgen_output", state=scpi_state)
    for cmd in cmds:
        self._send_command(cmd)

Preamble(format, type, points, xinc, xorg, xref, yinc, yorg, yref) dataclass

Holds the waveform preamble data from the oscilloscope.

The preamble contains all the necessary metadata to convert the raw, digitized ADC values from the oscilloscope into meaningful time and voltage arrays. It describes the scaling and offset factors for both the X (time) and Y (voltage) axes.

ATTRIBUTE DESCRIPTION
format

Data format (e.g., 'BYTE', 'WORD').

TYPE: str

type

Acquisition type (e.g., 'NORMal', 'AVERage').

TYPE: str

points

The number of data points in the waveform.

TYPE: int

xinc

The time difference between adjacent data points (sampling interval).

TYPE: float

xorg

The time value of the first data point.

TYPE: float

xref

The reference time point (usually the trigger point).

TYPE: float

yinc

The voltage difference for each ADC level (voltage resolution).

TYPE: float

yorg

The voltage value at the vertical center of the screen.

TYPE: float

yref

The ADC level corresponding to the vertical center.

TYPE: float

Attributes
format instance-attribute
points instance-attribute
type instance-attribute
xinc instance-attribute
xorg instance-attribute
xref instance-attribute
yinc instance-attribute
yorg instance-attribute
yref instance-attribute
Functions

ScopeAcquisitionFacade(scope)

Provides a simplified interface for the oscilloscope's acquisition system.

This facade manages settings related to how the oscilloscope digitizes signals, including acquisition type (e.g., Normal, Averaging), memory mode (Real-time vs. Segmented), and sample rates.

ATTRIBUTE DESCRIPTION
_scope

The parent Oscilloscope instance.

Source code in pytestlab/instruments/Oscilloscope.py
def __init__(self, scope: Oscilloscope):
    self._scope = scope
Functions
analyze_all_segments()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def analyze_all_segments(self) -> Self:
    if self.get_acquisition_mode() != "SEGMENTED":
        raise InstrumentParameterError(
            parameter="count", message="Segment analysis requires SEGMENTED mode."
        )
    for cmd in self._scope.scpi_engine.build("seg_analyze"):
        self._scope._send_command(cmd)
    self._scope._wait()
    return self
get_acquire_points()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquire_points(self) -> int:
    return int(self._scope._query(self._scope.scpi_engine.build("acquire_points")[0]))
get_acquire_setup()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquire_setup(self) -> dict[str, str]:
    raw_str: str = self._scope._query(self._scope.scpi_engine.build("acquire_setup")[0]).strip()
    parts: list[str] = [p.strip() for p in raw_str.split(";")]
    setup_dict: dict[str, str] = {}
    for part in parts:
        kv = part.split(maxsplit=1)
        if len(kv) == 2:
            setup_dict[kv[0]] = kv[1]
    return setup_dict
get_acquisition_average_count()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquisition_average_count(self) -> int:
    return int(self._scope._query(self._scope.scpi_engine.build("acq_get_count")[0]))
get_acquisition_mode()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquisition_mode(self) -> str:
    resp_str_raw: str = self._scope._query(
        self._scope.scpi_engine.build("acq_get_mode")[0]
    ).strip()
    for friendly_name, scpi_command_str in _ACQ_MODE_MAP.items():
        if resp_str_raw.upper().startswith(scpi_command_str.upper()[:4]):
            return friendly_name
    self._scope._logger.warning(
        f"Could not map SCPI response '{resp_str_raw}' to a known AcquisitionMode. Returning raw response."
    )
    return resp_str_raw
get_acquisition_sample_rate()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquisition_sample_rate(self) -> float:
    return float(self._scope._query(self._scope.scpi_engine.build("acquire_sample_rate")[0]))
get_acquisition_type()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_acquisition_type(self) -> str:
    resp_str_raw: str = self._scope._query(
        self._scope.scpi_engine.build("acq_get_type")[0]
    ).strip()
    for enum_member, scpi_command_str in _ACQ_TYPE_MAP.items():
        if resp_str_raw.upper().startswith(scpi_command_str.upper()[:4]):
            return enum_member.name
    self._scope._logger.warning(
        f"Could not map SCPI response '{resp_str_raw}' to a known AcquisitionType. Returning raw response."
    )
    return resp_str_raw
get_segment_index()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_segment_index(self) -> int:
    return int(self._scope._query(self._scope.scpi_engine.build("seg_get_index")[0]))
get_segmented_count()
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def get_segmented_count(self) -> int:
    return int(self._scope._query(self._scope.scpi_engine.build("seg_get_count")[0]))
set_acquisition_average_count(count)
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_acquisition_average_count(self, count: int) -> Self:
    _validate_range(count, 2, 65_536, "Average count")
    current_acq_type_str = self.get_acquisition_type()
    if current_acq_type_str != AcquisitionType.AVERAGE.name:
        raise InstrumentParameterError(
            parameter="count",
            message=f"Average count can only be set when acquisition type is AVERAGE, not {current_acq_type_str}.",
        )
    for cmd in self._scope.scpi_engine.build("acq_set_count", count=count):
        self._scope._send_command(cmd)
    self._scope._wait()
    self._scope._logger.debug(f"AVERAGE count set → {count}")
    return self
set_acquisition_mode(mode)
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_acquisition_mode(self, mode: str) -> Self:
    mode_upper: str = mode.upper()
    scpi_mode_val = _ACQ_MODE_MAP.get(mode_upper)
    if not scpi_mode_val:
        raise InstrumentParameterError(
            parameter="mode",
            value=mode,
            valid_range=list(_ACQ_MODE_MAP.keys()),
            message="Unknown acquisition mode.",
        )
    for cmd in self._scope.scpi_engine.build("acq_set_mode", value=scpi_mode_val):
        self._scope._send_command(cmd)
    self._scope._wait()
    self._scope._logger.debug(f"Acquisition MODE set → {mode_upper}")
    return self
set_acquisition_type(acq_type)
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_acquisition_type(self, acq_type: AcquisitionType) -> Self:
    scpi_val = _ACQ_TYPE_MAP.get(acq_type)
    if not scpi_val:
        raise InstrumentParameterError(
            parameter="acq_type",
            value=acq_type,
            message="Unsupported acquisition type enum member.",
        )
    for cmd in self._scope.scpi_engine.build("acq_set_type", value=scpi_val):
        self._scope._send_command(cmd)
    self._scope._wait()
    self._scope._logger.debug(f"Acquisition TYPE set → {acq_type.name}")
    return self
set_segment_index(index)
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_segment_index(self, index: int) -> Self:
    total_segments: int = self.get_segmented_count()
    _validate_range(index, 1, total_segments, "Segment index")
    for cmd in self._scope.scpi_engine.build("seg_set_index", index=index):
        self._scope._send_command(cmd)
    self._scope._wait()
    self._scope._logger.debug(f"Segment INDEX set → {index}")
    return self
set_segmented_count(count)
Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def set_segmented_count(self, count: int) -> Self:
    if self.get_acquisition_mode() != "SEGMENTED":
        raise InstrumentParameterError(
            parameter="count",
            message="Segmented count can only be set while in SEGMENTED acquisition mode.",
        )
    _validate_range(count, 2, 500, "Segmented count")
    for cmd in self._scope.scpi_engine.build("seg_set_count", count=count):
        self._scope._send_command(cmd)
    self._scope._wait()
    self._scope._logger.debug(f"Segmented COUNT set → {count}")
    return self

ScopeChannelFacade(scope, channel_num)

Provides a simplified, chainable interface for a single oscilloscope channel.

This facade abstracts the underlying SCPI commands for common channel operations, allowing for more readable and fluent test scripts. For example: scope.channel(1).setup(scale=0.5, offset=0).enable()

ATTRIBUTE DESCRIPTION
_scope

The parent Oscilloscope instance.

_channel

The channel number this facade controls.

Source code in pytestlab/instruments/Oscilloscope.py
def __init__(self, scope: Oscilloscope, channel_num: int):
    self._scope = scope
    self._channel = channel_num
Functions
disable()

Disables the channel display.

Source code in pytestlab/instruments/Oscilloscope.py
def disable(self) -> Self:
    """Disables the channel display."""
    self._scope.display_channel(self._channel, False)
    return self
enable()

Enables the channel display.

Source code in pytestlab/instruments/Oscilloscope.py
def enable(self) -> Self:
    """Enables the channel display."""
    self._scope.display_channel(self._channel, True)
    return self
measure_peak_to_peak()

Performs a peak-to-peak voltage measurement on this channel.

Source code in pytestlab/instruments/Oscilloscope.py
def measure_peak_to_peak(self) -> MeasurementResult:
    """Performs a peak-to-peak voltage measurement on this channel."""
    return self._scope.measure_voltage_peak_to_peak(self._channel)
measure_rms()

Performs an RMS voltage measurement on this channel.

Source code in pytestlab/instruments/Oscilloscope.py
def measure_rms(self) -> MeasurementResult:
    """Performs an RMS voltage measurement on this channel."""
    return self._scope.measure_rms_voltage(self._channel)
setup(scale=None, position=None, offset=None, coupling=None, probe_attenuation=None, bandwidth_limit=None)

Configures multiple settings for the channel in a single call.

This method allows setting the vertical scale, position/offset, coupling, probe attenuation, and bandwidth limit. Any parameter left as None will not be changed.

PARAMETER DESCRIPTION
scale

The vertical scale in volts per division.

TYPE: float | None DEFAULT: None

position

The vertical position in divisions from the center.

TYPE: float | None DEFAULT: None

offset

The vertical offset in volts. 'offset' is often preferred over 'position' as it's independent of the scale.

TYPE: float | None DEFAULT: None

coupling

The input coupling ("AC" or "DC").

TYPE: str | None DEFAULT: None

probe_attenuation

The attenuation factor of the probe (e.g., 10 for 10:1).

TYPE: int | None DEFAULT: None

bandwidth_limit

The bandwidth limit to apply (e.g., "20M" or 20e6).

TYPE: str | float | None DEFAULT: None

RETURNS DESCRIPTION
Self

The ScopeChannelFacade instance for method chaining.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def setup(
    self,
    scale: float | None = None,
    position: float | None = None,
    offset: float | None = None,
    coupling: str | None = None,
    probe_attenuation: int | None = None,
    bandwidth_limit: str | float | None = None,
) -> Self:
    """Configures multiple settings for the channel in a single call.

    This method allows setting the vertical scale, position/offset, coupling,
    probe attenuation, and bandwidth limit. Any parameter left as `None` will
    not be changed.

    Args:
        scale: The vertical scale in volts per division.
        position: The vertical position in divisions from the center.
        offset: The vertical offset in volts. 'offset' is often preferred
                over 'position' as it's independent of the scale.
        coupling: The input coupling ("AC" or "DC").
        probe_attenuation: The attenuation factor of the probe (e.g., 10 for 10:1).
        bandwidth_limit: The bandwidth limit to apply (e.g., "20M" or 20e6).

    Returns:
        The `ScopeChannelFacade` instance for method chaining.
    """
    if scale is not None:
        current_offset_val = (
            self._scope.get_channel_axis(self._channel)[1]
            if offset is None and position is None
            else (offset or position or 0.0)
        )
        # Ensure current_offset_val is not None before converting to float
        if current_offset_val is not None:
            self._scope.set_channel_axis(self._channel, scale, float(current_offset_val))
        else:
            self._scope.set_channel_axis(self._channel, scale, 0.0)
    if offset is not None or position is not None:
        val_to_set = position if position is not None else offset
        current_scale_val = self._scope.get_channel_axis(self._channel)[0]
        # Ensure val_to_set is not None before converting to float
        if val_to_set is not None:
            self._scope.set_channel_axis(self._channel, current_scale_val, float(val_to_set))
        else:
            self._scope.set_channel_axis(self._channel, current_scale_val, 0.0)
    if coupling is not None:
        for cmd in self._scope.scpi_engine.build(
            "set_channel_coupling", channel=self._channel, coupling=coupling.upper()
        ):
            self._scope._send_command(cmd)
        self._scope._logger.debug(f"Channel {self._channel} coupling set to {coupling.upper()}")
    if probe_attenuation is not None:
        self._scope.set_probe_attenuation(self._channel, probe_attenuation)
    if bandwidth_limit is not None:
        self._scope.set_bandwidth_limit(self._channel, bandwidth_limit)
    return self

ScopeTriggerFacade(scope)

Provides a simplified, chainable interface for the oscilloscope's trigger system.

This facade abstracts the underlying SCPI commands for trigger operations, focusing on common use cases like setting up an edge trigger.

ATTRIBUTE DESCRIPTION
_scope

The parent Oscilloscope instance.

Source code in pytestlab/instruments/Oscilloscope.py
def __init__(self, scope: Oscilloscope):
    self._scope = scope
Functions
setup_edge(source, level, slope=TriggerSlope.POSITIVE, coupling=None, mode='EDGE')

Configures a standard edge trigger.

PARAMETER DESCRIPTION
source

The trigger source (e.g., "CH1", "CH2", "EXT", "LINE").

TYPE: str

level

The trigger level in volts.

TYPE: float

slope

The trigger slope (TriggerSlope.POSITIVE, NEGATIVE, or EITHER).

TYPE: TriggerSlope DEFAULT: POSITIVE

coupling

The trigger coupling (e.g., "AC", "DC"). Can be instrument-specific.

TYPE: str | None DEFAULT: None

mode

The trigger mode, defaults to "EDGE".

TYPE: str DEFAULT: 'EDGE'

RETURNS DESCRIPTION
Self

The ScopeTriggerFacade instance for method chaining.

Source code in pytestlab/instruments/Oscilloscope.py
@validate_call
def setup_edge(
    self,
    source: str,
    level: float,
    slope: TriggerSlope = TriggerSlope.POSITIVE,
    coupling: str | None = None,
    mode: str = "EDGE",
) -> Self:
    """Configures a standard edge trigger.

    Args:
        source: The trigger source (e.g., "CH1", "CH2", "EXT", "LINE").
        level: The trigger level in volts.
        slope: The trigger slope (`TriggerSlope.POSITIVE`, `NEGATIVE`, or `EITHER`).
        coupling: The trigger coupling (e.g., "AC", "DC"). Can be instrument-specific.
        mode: The trigger mode, defaults to "EDGE".

    Returns:
        The `ScopeTriggerFacade` instance for method chaining.
    """
    trigger_channel_for_level = 1
    if source.upper().startswith("CHAN"):
        try:
            trigger_channel_for_level = int(source[len("CHAN") :])
        except ValueError as e:
            raise InstrumentParameterError(
                parameter="source",
                value=source,
                message="Invalid trigger source format for channel.",
            ) from e
    elif source.upper().startswith("CH"):
        try:
            trigger_channel_for_level = int(source[len("CH") :])
        except ValueError as e:
            raise InstrumentParameterError(
                parameter="source",
                value=source,
                message="Invalid trigger source format for channel.",
            ) from e

    self._scope.configure_trigger(
        channel=trigger_channel_for_level, level=level, source=source, slope=slope, mode=mode
    )
    if coupling is not None:
        for cmd in self._scope.scpi_engine.build(
            "set_trigger_coupling", mode=mode.upper(), coupling=coupling.upper()
        ):
            self._scope._send_command(cmd)
    return self

Power Supply

pytestlab.instruments.PowerSupply

Classes

PSUChannelConfig(voltage, current, state)

A data class to hold the measured configuration of a single PSU channel.

This class is used to structure the data returned by get_configuration, providing a snapshot of a channel's state. It is not a Pydantic model for loading configurations from files.

ATTRIBUTE DESCRIPTION
voltage

The measured voltage of the channel.

TYPE: float | UFloat

current

The measured current of the channel.

TYPE: float | UFloat

state

The output state of the channel ("ON" or "OFF").

TYPE: str

Initializes the PSUChannelConfig.

PARAMETER DESCRIPTION
voltage

The voltage value for the channel.

TYPE: float | UFloat

current

The current value for the channel.

TYPE: float | UFloat

state

The state of the channel (e.g., 0, 1, "ON", "OFF").

TYPE: int | str

Source code in pytestlab/instruments/PowerSupply.py
def __init__(self, voltage: float | UFloat, current: float | UFloat, state: int | str) -> None:
    """Initializes the PSUChannelConfig.

    Args:
        voltage: The voltage value for the channel.
        current: The current value for the channel.
        state: The state of the channel (e.g., 0, 1, "ON", "OFF").
    """
    # Allow UFloat or float for channel telemetry
    self.voltage: float | UFloat = cast(float | UFloat, voltage)
    self.current: float | UFloat = cast(float | UFloat, current)
    self.state: str  # Store state as string "ON" or "OFF" for consistency
    if isinstance(state, str):
        # Normalize state from various string inputs like "1", "0", "ON", "OFF"
        state_upper = state.upper().strip()
        if state_upper == SCPIOnOff.ON.value or state_upper == "1":
            self.state = SCPIOnOff.ON.value
        elif state_upper == SCPIOnOff.OFF.value or state_upper == "0":
            self.state = SCPIOnOff.OFF.value
        else:
            raise ValueError(f"Invalid string state value: {state}")
    elif isinstance(state, int | float):  # float for query results that might be like 1.0
        self.state = SCPIOnOff.ON.value if int(state) == 1 else SCPIOnOff.OFF.value
    else:
        raise ValueError(f"Invalid state value type: {type(state)}, value: {state}")
Attributes
current = cast(float | UFloat, current) instance-attribute
state instance-attribute
voltage = cast(float | UFloat, voltage) instance-attribute
Functions
__repr__()
Source code in pytestlab/instruments/PowerSupply.py
def __repr__(self) -> str:
    return f"PSUChannelConfig(voltage={self.voltage!r}, current={self.current!r}, state='{self.state}')"

PSUChannelFacade(psu, channel_num)

Provides a simplified, chainable interface for a single PSU channel.

This facade abstracts the underlying SCPI commands for common channel operations, allowing for more readable and fluent test scripts. For example: psu.channel(1).set(voltage=5.0, current_limit=0.1).on()

ATTRIBUTE DESCRIPTION
_psu

The parent PowerSupply instance.

_channel

The channel number (1-based) this facade controls.

Source code in pytestlab/instruments/PowerSupply.py
def __init__(self, psu: PowerSupply, channel_num: int):
    self._psu = psu
    self._channel = channel_num
Functions
get_current()

Reads the measured current from this channel.

Source code in pytestlab/instruments/PowerSupply.py
def get_current(self) -> float | UFloat:
    """Reads the measured current from this channel."""
    return self._psu.read_current(self._channel)
get_output_state()

Checks if the channel output is enabled (ON).

RETURNS DESCRIPTION
bool

True if the output is on, False otherwise.

RAISES DESCRIPTION
InstrumentParameterError

If the instrument returns an unexpected state.

Source code in pytestlab/instruments/PowerSupply.py
def get_output_state(self) -> bool:
    """Checks if the channel output is enabled (ON).

    Returns:
        True if the output is on, False otherwise.

    Raises:
        InstrumentParameterError: If the instrument returns an unexpected state.
    """
    cmd = self._psu._eng().build("get_output_state", channel=self._channel)[0]
    state_str = self._psu._eng().parse("get_output_state", self._psu._query(cmd))
    s = str(state_str).strip().upper()
    if s in {"1", "ON", "TRUE"}:
        return True
    if s in {"0", "OFF", "FALSE"}:
        return False
    raise InstrumentParameterError(
        f"Unexpected output state '{state_str}' for channel {self._channel}"
    )
get_voltage()

Reads the measured voltage from this channel.

Source code in pytestlab/instruments/PowerSupply.py
def get_voltage(self) -> float | UFloat:
    """Reads the measured voltage from this channel."""
    return self._psu.read_voltage(self._channel)
off()

Disables the output of this channel.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def off(self) -> Self:
    """Disables the output of this channel."""
    self._psu.output(self._channel, False)
    return self
on()

Enables the output of this channel.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def on(self) -> Self:
    """Enables the output of this channel."""
    self._psu.output(self._channel, True)
    return self
set(voltage=None, current_limit=None)

Sets the voltage and/or current limit for this channel.

PARAMETER DESCRIPTION
voltage

The target voltage in Volts.

TYPE: float | None DEFAULT: None

current_limit

The current limit in Amperes.

TYPE: float | None DEFAULT: None

RETURNS DESCRIPTION
Self

The PSUChannelFacade instance for method chaining.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def set(self, voltage: float | None = None, current_limit: float | None = None) -> Self:
    """Sets the voltage and/or current limit for this channel.

    Args:
        voltage: The target voltage in Volts.
        current_limit: The current limit in Amperes.

    Returns:
        The `PSUChannelFacade` instance for method chaining.
    """
    if voltage is not None:
        self._psu.set_voltage(self._channel, voltage)
    if current_limit is not None:
        self._psu.set_current(self._channel, current_limit)
    return self
slew(duration_s=None, enabled=True)

Configures the slew rate (ramp time) for this channel.

PARAMETER DESCRIPTION
duration_s

The time in seconds for the voltage to ramp to its set value. If None, the duration is not changed.

TYPE: float | None DEFAULT: None

enabled

True to enable slew, False to disable.

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
Self

The PSUChannelFacade instance for method chaining.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def slew(self, duration_s: float | None = None, enabled: bool = True) -> Self:
    """Configures the slew rate (ramp time) for this channel.

    Args:
        duration_s: The time in seconds for the voltage to ramp to its
                    set value. If None, the duration is not changed.
        enabled:    True to enable slew, False to disable.

    Returns:
        The `PSUChannelFacade` instance for method chaining.
    """
    if duration_s is not None:
        self._psu.set_slew_rate(self._channel, duration_s)
    self._psu.enable_slew_rate(self._channel, enabled)
    return self

PowerSupply(config, **kwargs)

Bases: Instrument[PowerSupplyConfig]

Drives a multi-channel Power Supply Unit (PSU).

This class provides a high-level interface for controlling a programmable power supply. It builds upon the base Instrument class and adds methods for setting and reading voltage and current on a per-channel basis. It also supports incorporating measurement uncertainty if configured.

A key feature is the channel() method, which returns a PSUChannelFacade for a simplified, chainable programming experience.

ATTRIBUTE DESCRIPTION
config

The Pydantic configuration object (PowerSupplyConfig) containing settings specific to this PSU.

TYPE: PowerSupplyConfig

scpi_engine

The SCPI engine for building and parsing commands.

Source code in pytestlab/instruments/PowerSupply.py
def __init__(self, config: PowerSupplyConfig, **kwargs: Any):
    super().__init__(config=config, **kwargs)
    # Initialize SCPI engine from the config if available (reusing base if present)
    if getattr(self, "scpi_engine", None) is None:
        scpi_data = config.scpi.model_dump() if config.scpi else {}
        self.scpi_engine = SCPIEngine(scpi_data, variant=config.scpi_variant)

    # Initialize safety limit properties
    self._voltage_limit = None
    self._current_limit = None
    self._voltage_value = 0.0
    self._current_value = 0.0
Attributes
config instance-attribute
current property writable

Get the current value.

current_limit property writable

Get the current safety limit.

model_config = {'arbitrary_types_allowed': True} class-attribute instance-attribute
scpi_engine = SCPIEngine(scpi_data, variant=(config.scpi_variant)) instance-attribute
voltage property writable

Get the current voltage value.

voltage_limit property writable

Get the current voltage safety limit.

Functions
channel(ch_num)

Returns a facade for interacting with a specific channel.

PARAMETER DESCRIPTION
ch_num

The channel number (1-based).

TYPE: int

RETURNS DESCRIPTION
PSUChannelFacade

A facade object for the specified channel.

TYPE: PSUChannelFacade

RAISES DESCRIPTION
InstrumentParameterError

If channel number is invalid.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def channel(self, ch_num: int) -> PSUChannelFacade:
    """
    Returns a facade for interacting with a specific channel.

    Args:
        ch_num (int): The channel number (1-based).

    Returns:
        PSUChannelFacade: A facade object for the specified channel.

    Raises:
        InstrumentParameterError: If channel number is invalid.
    """
    if not self.config.channels or not (1 <= ch_num <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(f"Channel number {ch_num} is out of range (1-{num_ch}).")
    return PSUChannelFacade(self, ch_num)
display(state)

Enables or disables the instrument's front panel display.

PARAMETER DESCRIPTION
state

True to turn the display on, False to turn it off.

TYPE: bool

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def display(self, state: bool) -> None:
    """Enables or disables the instrument's front panel display.

    Args:
        state: True to turn the display on, False to turn it off.
    """
    cmd = self._eng().build("set_display", state=state)[0]
    self._send_command(cmd)
enable_slew_rate(channel, state)

Enables or disables the slew rate feature for a specific channel.

PARAMETER DESCRIPTION
channel

The channel number (1-based).

TYPE: int

state

True to enable slew, False to disable.

TYPE: bool

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def enable_slew_rate(self, channel: int, state: bool) -> None:
    """Enables or disables the slew rate feature for a specific channel.

    Args:
        channel: The channel number (1-based).
        state: True to enable slew, False to disable.
    """
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )

    command_name = "enable_slew_rate" if state else "disable_slew_rate"
    cmd = self._eng().build(command_name, channel=channel)[0]
    self._send_command(cmd)
get_configuration()

Reads the live state of all configured PSU channels.

This method iterates through all channels defined in the configuration, queries their current voltage, current, and output state, and returns the collected data.

RETURNS DESCRIPTION
dict[int, PSUChannelConfig]

A dictionary where keys are channel numbers (1-based) and values are

dict[int, PSUChannelConfig]

PSUChannelConfig objects representing the state of each channel.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def get_configuration(self) -> dict[int, PSUChannelConfig]:
    """Reads the live state of all configured PSU channels.

    This method iterates through all channels defined in the configuration,
    queries their current voltage, current, and output state, and returns
    the collected data.

    Returns:
        A dictionary where keys are channel numbers (1-based) and values are
        `PSUChannelConfig` objects representing the state of each channel.
    """
    results: dict[int, PSUChannelConfig] = {}
    if not self.config.channels:
        self._logger.warning(
            "No channels defined in the PowerSupplyConfig. Cannot get configuration."
        )
        return results

    num_channels = len(self.config.channels)

    for channel_num in range(1, num_channels + 1):  # Iterate 1-indexed channel numbers

        def _nominal(x: Any) -> float:
            return x.nominal_value if hasattr(x, "nominal_value") else float(x)

        voltage_val = _nominal(self.read_voltage(channel_num))
        current_val = _nominal(self.read_current(channel_num))
        # Query output state using SCPI engine
        cmd = self._eng().build("get_output_state", channel=channel_num)[0]
        state_str: str = self._eng().parse("get_output_state", self._query(cmd))

        results[channel_num] = PSUChannelConfig(
            voltage=voltage_val, current=current_val, state=state_str.strip()
        )
    return results
id()

Queries the instrument identification string.

RETURNS DESCRIPTION
str

The instrument identification string.

TYPE: str

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def id(self) -> str:
    """
    Queries the instrument identification string.

    Returns:
        str: The instrument identification string.
    """
    cmd = self._eng().build("identify")[0]
    return self._eng().parse("identify", self._query(cmd))
output(channel, state=True)

Enables or disables the output for one or more channels.

PARAMETER DESCRIPTION
channel

A single channel number (1-based) or a list of channel numbers.

TYPE: int | list[int]

state

True to enable the output (ON), False to disable (OFF).

TYPE: bool DEFAULT: True

RAISES DESCRIPTION
InstrumentParameterError

If any channel number is invalid.

ValueError

If the channel argument is not an int or a list of ints.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def output(self, channel: int | list[int], state: bool = True) -> None:
    """Enables or disables the output for one or more channels.

    Args:
        channel: A single channel number (1-based) or a list of channel numbers.
        state: True to enable the output (ON), False to disable (OFF).

    Raises:
        InstrumentParameterError: If any channel number is invalid.
        ValueError: If the `channel` argument is not an int or a list of ints.
    """
    channels_to_process: list[int]
    if isinstance(channel, int):
        channels_to_process = [channel]
    elif isinstance(channel, list):
        # Ensure all elements in the list are integers
        if not all(isinstance(ch, int) for ch in channel):
            raise ValueError("All elements in channel list must be integers.")
        channels_to_process = channel
    else:
        # This case should ideally be caught by validate_call if type hints are precise enough,
        # but an explicit check remains good practice.
        raise ValueError(f"Invalid channel type: {type(channel)}. Expected int or List[int].")

    num_configured_channels = len(self.config.channels) if self.config.channels else 0
    for ch_num in channels_to_process:
        if not (1 <= ch_num <= num_configured_channels):
            raise InstrumentParameterError(
                f"Channel number {ch_num} is out of range (1-{num_configured_channels})."
            )

    # Send command for each channel individually
    for ch_num in channels_to_process:
        cmd = self._eng().build("set_output", channel=ch_num, state=state)[0]
        self._send_command(cmd)
read_current(channel)

Reads the measured output current from a specific channel.

PARAMETER DESCRIPTION
channel

The channel number to measure (1-based).

TYPE: int

RETURNS DESCRIPTION
float | UFloat

The measured current as a float.

RAISES DESCRIPTION
InstrumentParameterError

If the channel number is invalid.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def read_current(self, channel: int) -> float | UFloat:
    """Reads the measured output current from a specific channel.

    Args:
        channel: The channel number to measure (1-based).

    Returns:
        The measured current as a float.

    Raises:
        InstrumentParameterError: If the channel number is invalid.
    """
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )
    cmd = self._eng().build("measure_current", channel=channel)[0]
    reading = float(self._eng().parse("measure_current", self._query(cmd)))

    value_to_return: Any = reading

    if self.config.measurement_accuracy:
        mode_key = f"read_current_ch{channel}"
        self._logger.debug(
            f"Attempting to find accuracy spec for read_current on channel {channel} with key: '{mode_key}'"
        )
        spec = self.config.measurement_accuracy.get(mode_key)

        if spec:
            sigma = spec.calculate_std_dev(reading, range_value=None)
            if sigma > 0:
                try:
                    value_to_return = ufloat(reading, sigma)
                except Exception:
                    value_to_return = reading
                self._logger.debug(
                    f"Applied accuracy spec '{mode_key}', value: {value_to_return}"
                )
            else:
                self._logger.debug(
                    f"Accuracy spec '{mode_key}' resulted in sigma=0. Returning float."
                )
        else:
            self._logger.debug(
                f"No accuracy spec found for read_current on channel {channel} with key '{mode_key}'. Returning float."
            )
    else:
        self._logger.debug(
            f"No measurement_accuracy configuration in instrument for read_current on channel {channel}. Returning float."
        )

    return value_to_return
read_voltage(channel)

Reads the measured output voltage from a specific channel.

PARAMETER DESCRIPTION
channel

The channel number to measure (1-based).

TYPE: int

RETURNS DESCRIPTION
float | UFloat

The measured voltage as a float.

RAISES DESCRIPTION
InstrumentParameterError

If the channel number is invalid.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def read_voltage(self, channel: int) -> float | UFloat:
    """Reads the measured output voltage from a specific channel.

    Args:
        channel: The channel number to measure (1-based).

    Returns:
        The measured voltage as a float.

    Raises:
        InstrumentParameterError: If the channel number is invalid.
    """
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )
    cmd = self._eng().build("measure_voltage", channel=channel)[0]
    reading = float(self._eng().parse("measure_voltage", self._query(cmd)))

    value_to_return: Any = reading

    if self.config.measurement_accuracy:
        mode_key = f"read_voltage_ch{channel}"
        self._logger.debug(
            f"Attempting to find accuracy spec for read_voltage on channel {channel} with key: '{mode_key}'"
        )
        spec = self.config.measurement_accuracy.get(mode_key)

        if spec:
            sigma = spec.calculate_std_dev(reading, range_value=None)
            if sigma > 0:
                try:
                    value_to_return = ufloat(reading, sigma)
                except Exception:
                    value_to_return = reading
                self._logger.debug(
                    f"Applied accuracy spec '{mode_key}', value: {value_to_return}"
                )
            else:
                self._logger.debug(
                    f"Accuracy spec '{mode_key}' resulted in sigma=0. Returning float."
                )
        else:
            self._logger.debug(
                f"No accuracy spec found for read_voltage on channel {channel} with key '{mode_key}'. Returning float."
            )
    else:
        self._logger.debug(
            f"No measurement_accuracy configuration in instrument for read_voltage on channel {channel}. Returning float."
        )

    return value_to_return
reset()

Resets the instrument to its factory default settings.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def reset(self) -> None:
    """
    Resets the instrument to its factory default settings.
    """
    cmd = self._eng().build("reset")[0]
    self._send_command(cmd)
set_current(channel, current)

Sets the current limit for a specific channel.

PARAMETER DESCRIPTION
channel

The channel number (1-based).

TYPE: int

current

The current limit in Amperes.

TYPE: float

RAISES DESCRIPTION
InstrumentParameterError

If the channel number is invalid or the current is outside the configured range for that channel.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def set_current(self, channel: int, current: float) -> None:
    """Sets the current limit for a specific channel.

    Args:
        channel: The channel number (1-based).
        current: The current limit in Amperes.

    Raises:
        InstrumentParameterError: If the channel number is invalid or the
                                  current is outside the configured range for
                                  that channel.
    """
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )

    channel_config = self.config.channels[channel - 1]  # channel is 1-based
    channel_config.current_limit_range.assert_in_range(
        current, name=f"Current for channel {channel}"
    )

    from ..bench import SafetyLimitError

    if self._current_limit is not None and current > self._current_limit:
        raise SafetyLimitError(f"{current}A exceeds safety limit {self._current_limit}A")

    cmd = self._eng().build("set_current", channel=channel, current=current)[0]
    self._send_command(cmd)
    if channel == 1:
        self._current_value = current
set_slew_rate(channel, duration_s)

Sets the slew rate (ramp duration) for a specific channel.

PARAMETER DESCRIPTION
channel

The channel number (1-based).

TYPE: int

duration_s

The time in seconds for the voltage to ramp to the set value.

TYPE: float

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def set_slew_rate(self, channel: int, duration_s: float) -> None:
    """Sets the slew rate (ramp duration) for a specific channel.

    Args:
        channel: The channel number (1-based).
        duration_s: The time in seconds for the voltage to ramp to the set value.
    """
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )

    duration_ms = int(duration_s * 1000)
    cmd = self._eng().build("set_slew_rate", channel=channel, duration_ms=duration_ms)[0]
    self._send_command(cmd)
set_voltage(channel, voltage)

Sets the output voltage for a specific channel.

PARAMETER DESCRIPTION
channel

The channel number (1-based).

TYPE: int

voltage

The target voltage in Volts.

TYPE: float

RAISES DESCRIPTION
InstrumentParameterError

If the channel number is invalid or the voltage is outside the configured range for that channel.

Source code in pytestlab/instruments/PowerSupply.py
@validate_call
def set_voltage(self, channel: int, voltage: float) -> None:
    """Sets the output voltage for a specific channel.

    Args:
        channel: The channel number (1-based).
        voltage: The target voltage in Volts.

    Raises:
        InstrumentParameterError: If the channel number is invalid or the
                                  voltage is outside the configured range for
                                  that channel.
    """
    # Validate that the channel number is within the configured range
    if not self.config.channels or not (1 <= channel <= len(self.config.channels)):
        num_ch = len(self.config.channels) if self.config.channels else 0
        raise InstrumentParameterError(
            f"Channel number {channel} is out of range (1-{num_ch})."
        )

    # Validate the voltage against the limits defined in the configuration
    channel_config = self.config.channels[channel - 1]
    channel_config.voltage_range.assert_in_range(voltage, name=f"Voltage for channel {channel}")

    # Enforce safety limit, if any
    from ..bench import SafetyLimitError

    if self._voltage_limit is not None and voltage > self._voltage_limit:
        raise SafetyLimitError(f"{voltage}V exceeds safety limit {self._voltage_limit}V")

    # Build and send the SCPI command
    cmd = self._eng().build("set_voltage", channel=channel, voltage=voltage)[0]
    self._send_command(cmd)
    # Cache recent set value for safety checks
    if channel == 1:
        self._voltage_value = voltage

Waveform Generator

pytestlab.instruments.WaveformGenerator

Module providing a high-level interface for Keysight EDU33210 Series Trueform Arbitrary Waveform Generators.

Attributes

WAVEFORM_PARAM_COMMANDS = {WaveformType.PULSE: {'duty_cycle': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:DCYCle {v_float}', 'period': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:PERiod {v_float}', 'width': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:WIDTh {v_float}', 'transition_both': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:TRANsition:BOTH {v_float}', 'transition_leading': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:TRANsition:LEADing {v_float}', 'transition_trailing': lambda ch, v_float: f'SOUR{ch}:FUNC:PULS:TRANsition:TRAiling {v_float}', 'hold_mode': lambda ch, v_str_hold: f'SOUR{ch}:FUNC:PULS:HOLD {v_str_hold.upper()}'}, WaveformType.SQUARE: {'duty_cycle': lambda ch, v_float: f'SOUR{ch}:FUNC:SQUare:DCYCle {v_float}', 'period': lambda ch, v_float: f'SOUR{ch}:FUNC:SQUare:PERiod {v_float}'}, WaveformType.RAMP: {'symmetry': lambda ch, v_float: f'SOUR{ch}:FUNC:RAMP:SYMMetry {v_float}'}, WaveformType.SINE: {}, WaveformType.NOISE: {'bandwidth': lambda ch, v_float: f'SOUR{ch}:FUNC:NOISe:BANDwidth {v_float}'}, WaveformType.ARB: {'sample_rate': lambda ch, v_float: f'SOUR{ch}:FUNC:ARB:SRATe {v_float}', 'filter': lambda ch, arb_filter_enum_val: f'SOUR{ch}:FUNC:ARB:FILTer {arb_filter_enum_val}', 'advance_mode': lambda ch, arb_adv_enum_val: f'SOUR{ch}:FUNC:ARB:ADVance {arb_adv_enum_val}', 'frequency': lambda ch, v_float: f'SOUR{ch}:FUNC:ARB:FREQ {v_float}', 'period': lambda ch, v_float: f'SOUR{ch}:FUNC:ARB:PER {v_float}', 'ptpeak_voltage': lambda ch, v_float: f'SOUR{ch}:FUNC:ARB:PTP {v_float}'}, WaveformType.DC: {}} module-attribute

Classes

FileSystemInfo(bytes_used, bytes_free, files=list()) dataclass

Data class representing the results of a directory listing query (list_directory).

Contains information about memory usage and the files/folders found in the queried path.

ATTRIBUTE DESCRIPTION
bytes_used

Total bytes used on the specified memory volume (INT or USB).

TYPE: int

bytes_free

Total bytes free on the specified memory volume.

TYPE: int

files

A list of dictionaries, each representing a file or folder. Example entry: {'name': 'f.txt', 'type': 'FILE', 'size': 1024}. Type might be 'FILE', 'FOLDER', 'ARB', 'STAT', etc., depending on the file extension and instrument response. Size is in bytes.

TYPE: List[Dict[str, Any]]

Attributes
bytes_free instance-attribute
bytes_used instance-attribute
files = field(default_factory=list) class-attribute instance-attribute
Functions

WGChannelFacade(wg, channel_num)

Source code in pytestlab/instruments/WaveformGenerator.py
def __init__(self, wg: WaveformGenerator, channel_num: int):
    self._wg = wg
    self._channel = channel_num
Functions
disable()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def disable(self) -> Self:
    self._wg.set_output_state(self._channel, SCPIOnOff.OFF)
    return self
enable()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def enable(self) -> Self:
    self._wg.set_output_state(self._channel, SCPIOnOff.ON)
    return self
set_load_impedance(impedance)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_load_impedance(self, impedance: float | OutputLoadImpedance | str) -> Self:
    self._wg.set_output_load_impedance(self._channel, impedance)
    return self
set_voltage_unit(unit)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_unit(self, unit: VoltageUnit) -> Self:
    self._wg.set_voltage_unit(self._channel, unit)
    return self
setup_arbitrary(arb_name, sample_rate, amplitude, offset=0.0, phase=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_arbitrary(
    self,
    arb_name: str,
    sample_rate: float,
    amplitude: float,
    offset: float = 0.0,
    phase: float | None = None,
) -> Self:
    self._wg.set_function(self._channel, WaveformType.ARB)
    self._wg.select_arbitrary_waveform(self._channel, arb_name)
    self._wg.set_arbitrary_waveform_sample_rate(self._channel, sample_rate)
    self._wg.set_amplitude(self._channel, amplitude)
    self._wg.set_offset(self._channel, offset)
    if phase is not None:
        self._wg.set_phase(self._channel, phase)
    return self
setup_dc(offset)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_dc(self, offset: float) -> Self:
    self._wg.set_function(self._channel, WaveformType.DC)
    self._wg.set_offset(self._channel, offset)
    return self
setup_pulse(frequency, amplitude, offset=0.0, width=None, duty_cycle=None, transition_both=None, phase=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_pulse(
    self,
    frequency: float,
    amplitude: float,
    offset: float = 0.0,
    width: float | None = None,
    duty_cycle: float | None = None,
    transition_both: float | None = None,
    phase: float | None = None,
) -> Self:
    if frequency <= 0:
        raise InstrumentParameterError(
            parameter="frequency", value=frequency, message="Must be > 0 for pulse setup."
        )
    period = 1.0 / frequency

    pulse_params = {"period": period}
    if width is not None:
        pulse_params["width"] = width
    elif duty_cycle is not None:
        pulse_params["duty_cycle"] = duty_cycle
    else:
        pulse_params["duty_cycle"] = 50.0

    if transition_both is not None:
        pulse_params["transition_both"] = transition_both

    self._wg.set_function(self._channel, WaveformType.PULSE, **pulse_params)
    self._wg.set_amplitude(self._channel, amplitude)
    self._wg.set_offset(self._channel, offset)
    if phase is not None:
        self._wg.set_phase(self._channel, phase)
    return self
setup_ramp(frequency, amplitude, offset=0.0, symmetry=50.0, phase=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_ramp(
    self,
    frequency: float,
    amplitude: float,
    offset: float = 0.0,
    symmetry: float = 50.0,
    phase: float | None = None,
) -> Self:
    self._wg.set_function(self._channel, WaveformType.RAMP, symmetry=symmetry)
    self._wg.set_frequency(self._channel, frequency)
    self._wg.set_amplitude(self._channel, amplitude)
    self._wg.set_offset(self._channel, offset)
    if phase is not None:
        self._wg.set_phase(self._channel, phase)
    return self
setup_sine(frequency, amplitude, offset=0.0, phase=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_sine(
    self, frequency: float, amplitude: float, offset: float = 0.0, phase: float | None = None
) -> Self:
    self._wg.set_function(self._channel, WaveformType.SINE)
    self._wg.set_frequency(self._channel, frequency)
    self._wg.set_amplitude(self._channel, amplitude)
    self._wg.set_offset(self._channel, offset)
    if phase is not None:
        self._wg.set_phase(self._channel, phase)
    return self
setup_square(frequency, amplitude, offset=0.0, duty_cycle=50.0, phase=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def setup_square(
    self,
    frequency: float,
    amplitude: float,
    offset: float = 0.0,
    duty_cycle: float = 50.0,
    phase: float | None = None,
) -> Self:
    self._wg.set_function(self._channel, WaveformType.SQUARE, duty_cycle=duty_cycle)
    self._wg.set_frequency(self._channel, frequency)
    self._wg.set_amplitude(self._channel, amplitude)
    self._wg.set_offset(self._channel, offset)
    if phase is not None:
        self._wg.set_phase(self._channel, phase)
    return self

WaveformConfigResult(channel, function, frequency, amplitude, offset, phase=None, symmetry=None, duty_cycle=None, output_state=None, load_impedance=None, voltage_unit=None) dataclass

Data class storing the retrieved waveform configuration of a channel.

Provides a structured way to access key parameters of the channel's current state, obtained by querying multiple SCPI commands.

ATTRIBUTE DESCRIPTION
channel

The channel number (1 or 2).

TYPE: int

function

The short SCPI name of the active waveform function (e.g., "SIN", "RAMP").

TYPE: str

frequency

The current frequency in Hz (or sample rate in Sa/s for ARB).

TYPE: float

amplitude

The current amplitude in the configured voltage units.

TYPE: float

offset

The current DC offset voltage in Volts.

TYPE: float

phase

The current phase offset in the configured angle units (None if not applicable).

TYPE: Optional[float]

symmetry

The current symmetry percentage for RAMP/TRIANGLE (None otherwise).

TYPE: Optional[float]

duty_cycle

The current duty cycle percentage for SQUARE/PULSE (None otherwise).

TYPE: Optional[float]

output_state

The current state of the main output (True=ON, False=OFF).

TYPE: Optional[bool]

load_impedance

The configured load impedance (Ohms or "INFinity").

TYPE: Optional[Union[float, str]]

voltage_unit

The currently configured voltage unit ("VPP", "VRMS", "DBM").

TYPE: Optional[str]

Note

Consider adding fields for active modulation/sweep/burst state if needed.

Attributes
amplitude instance-attribute
channel instance-attribute
duty_cycle = None class-attribute instance-attribute
frequency instance-attribute
function instance-attribute
load_impedance = None class-attribute instance-attribute
offset instance-attribute
output_state = None class-attribute instance-attribute
phase = None class-attribute instance-attribute
symmetry = None class-attribute instance-attribute
voltage_unit = None class-attribute instance-attribute
Functions

WaveformGenerator(config, debug_mode=False, **kwargs)

Bases: Instrument[WaveformGeneratorConfig]

Provides a high-level Python interface for controlling Keysight EDU33210 Series Trueform Arbitrary Waveform Generators via SCPI commands.

Initializes the WaveformGenerator instance.

Source code in pytestlab/instruments/WaveformGenerator.py
def __init__(
    self, config: WaveformGeneratorConfig, debug_mode: bool = False, **kwargs: Any
) -> None:
    """
    Initializes the WaveformGenerator instance.
    """
    super().__init__(config=config, debug_mode=debug_mode, **kwargs)  # Pass kwargs to base
    # self.config is already set by base Instrument's __init__ due to Generic type

    # Determine channel count from the length of the channels list in the config
    if hasattr(self.config, "channels") and isinstance(self.config.channels, list):
        self._channel_count = len(self.config.channels)
    else:
        # This case should ideally be caught by Pydantic validation of WaveformGeneratorConfig
        self._logger.warning("config.channels is not a list. Defaulting channel count to 0.")
        self._channel_count = 0

    if self._channel_count <= 0:
        self._logger.warning(
            f"Channel count determined as {self._channel_count}. Check instrument configuration."
        )
        # Consider if raising an error is more appropriate if channel_count is essential and expected to be > 0
        # For now, logging a warning to allow flexibility if some AWGs might be configured with 0 channels initially.

    self._logger.debug(f"Detected {self._channel_count} channels from configuration.")
Attributes
channel_count property

Returns the number of output channels supported by this instrument, based on configuration.

config instance-attribute
model_config = {'arbitrary_types_allowed': True} class-attribute instance-attribute
Functions
apply_waveform_settings(channel, function_type, frequency=OutputLoadImpedance.DEFAULT, amplitude=OutputLoadImpedance.DEFAULT, offset=OutputLoadImpedance.DEFAULT)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def apply_waveform_settings(
    self,
    channel: int | str,
    function_type: WaveformType | str,
    frequency: float | OutputLoadImpedance | str = OutputLoadImpedance.DEFAULT,
    amplitude: float | OutputLoadImpedance | str = OutputLoadImpedance.DEFAULT,
    offset: float | OutputLoadImpedance | str = OutputLoadImpedance.DEFAULT,
) -> None:
    ch = self._validate_channel(channel)
    scpi_short_name = self._get_scpi_function_name(function_type)
    apply_suffix_map: dict[str, str] = {
        WaveformType.SINE.value: "SINusoid",
        WaveformType.SQUARE.value: "SQUare",
        WaveformType.RAMP.value: "RAMP",
        WaveformType.PULSE.value: "PULSe",
        WaveformType.NOISE.value: "NOISe",
        WaveformType.ARB.value: "ARBitrary",
        WaveformType.DC.value: "DC",
    }
    if scpi_short_name == "TRI" and "TRI" not in apply_suffix_map:
        apply_suffix_map["TRI"] = "TRIangle"
    apply_suffix = apply_suffix_map.get(scpi_short_name)
    if not apply_suffix:
        if scpi_short_name in apply_suffix_map:
            apply_suffix = apply_suffix_map[scpi_short_name]
        else:
            raise InstrumentParameterError(
                parameter="function_type",
                value=function_type,
                message=f"Waveform function (SCPI: {scpi_short_name}) not supported by APPLy.",
            )
    params: list[str] = [
        self._format_value_min_max_def(frequency),
        self._format_value_min_max_def(amplitude),
        self._format_value_min_max_def(offset),
    ]
    param_str = ",".join(params)
    cmd = f"SOUR{ch}:APPLy:{apply_suffix} {param_str}"
    self._send_command(cmd)
    self._logger.debug(
        f"Channel {ch}: Applied {apply_suffix} with params: Freq/SR={frequency}, Ampl={amplitude}, Offs={offset}"
    )
    self._error_check()
channel(ch_num)

Returns a facade for interacting with a specific channel.

PARAMETER DESCRIPTION
ch_num

The channel number (1-based) or string identifier (e.g. "CH1").

TYPE: Union[int, str]

RETURNS DESCRIPTION
WGChannelFacade

A facade object for the specified channel.

TYPE: WGChannelFacade

RAISES DESCRIPTION
InstrumentParameterError

If channel number is invalid.

Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def channel(self, ch_num: int | str) -> WGChannelFacade:
    """
    Returns a facade for interacting with a specific channel.

    Args:
        ch_num (Union[int,str]): The channel number (1-based) or string identifier (e.g. "CH1").

    Returns:
        WGChannelFacade: A facade object for the specified channel.

    Raises:
        InstrumentParameterError: If channel number is invalid.
    """
    validated_ch_num = self._validate_channel(ch_num)  # _validate_channel returns int
    return WGChannelFacade(self, validated_ch_num)
clear_volatile_arbitrary_waveforms(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def clear_volatile_arbitrary_waveforms(self, channel: int | str) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:DATA:VOLatile:CLEar")
    self._logger.debug(f"Channel {ch}: Cleared volatile arbitrary waveform memory.")
    self._error_check()
delete_file_or_folder(path)
Source code in pytestlab/instruments/WaveformGenerator.py
def delete_file_or_folder(self, path: str) -> None:
    if not path:
        raise InstrumentParameterError(
            parameter="path", message="Path cannot be empty for deletion."
        )
    path_scpi = f'"{path}"'
    cmd = f"MMEMory:DELete {path_scpi}"
    try:
        self._send_command(cmd)
        self._logger.debug(f"Attempted to delete file/folder: '{path}' using MMEM:DELete")
        self._error_check()
    except InstrumentCommunicationError as e:
        code, msg = self.get_error()
        if code != 0:
            if "Directory not empty" in msg or "folder" in msg.lower():
                raise InstrumentCommunicationError(
                    instrument=self.config.model,
                    command=cmd,
                    message=f"Failed to delete '{path}'. Non-empty folder? Inst Err {code}: {msg}",
                ) from e
            else:
                raise InstrumentCommunicationError(
                    instrument=self.config.model,
                    command=cmd,
                    message=f"Failed to delete '{path}'. Inst Err {code}: {msg}",
                ) from e
        else:
            raise e
download_arbitrary_waveform_data(channel, arb_name, data_points, data_type='DAC', use_binary=True, is_dual_channel_data=False, dual_data_format=None)
Source code in pytestlab/instruments/WaveformGenerator.py
def download_arbitrary_waveform_data(
    self,
    channel: int | str,
    arb_name: str,
    data_points: list[int] | list[float] | np.ndarray,
    data_type: str = "DAC",
    use_binary: bool = True,
    is_dual_channel_data: bool = False,
    dual_data_format: str | None = None,
) -> None:
    if use_binary:
        self.download_arbitrary_waveform_data_binary(
            channel,
            arb_name,
            data_points,
            data_type,
            is_dual_channel_data=is_dual_channel_data,
            dual_data_format=dual_data_format,
        )
    else:
        self.download_arbitrary_waveform_data_csv(channel, arb_name, data_points, data_type)
download_arbitrary_waveform_data_binary(channel, arb_name, data_points, data_type='DAC', is_dual_channel_data=False, dual_data_format=None)
Source code in pytestlab/instruments/WaveformGenerator.py
def download_arbitrary_waveform_data_binary(
    self,
    channel: int | str,
    arb_name: str,
    data_points: list[int] | list[float] | np.ndarray,
    data_type: str = "DAC",
    is_dual_channel_data: bool = False,
    dual_data_format: str | None = None,
) -> None:
    ch = self._validate_channel(channel)
    if not re.match(r"^[a-zA-Z0-9_]{1,12}$", arb_name):
        raise InstrumentParameterError(
            parameter="arb_name",
            value=arb_name,
            message="Arbitrary waveform name is invalid.",
        )
    data_type_upper = data_type.upper().strip()
    if data_type_upper not in ["DAC", "NORM"]:
        raise InstrumentParameterError(
            parameter="data_type",
            value=data_type,
            valid_range=["DAC", "NORM"],
            message="Invalid data_type.",
        )
    np_data = np.asarray(data_points)
    if np_data.ndim != 1 or np_data.size == 0:
        raise InstrumentParameterError(
            parameter="data_points", message="data_points must be a non-empty 1D sequence."
        )
    num_points_total = np_data.size
    num_points_per_channel = num_points_total
    arb_cmd_node = "ARBitrary"
    if is_dual_channel_data:
        if self.channel_count < 2:
            raise InstrumentConfigurationError(
                self.config.model,
                "Dual channel download requires 2-channel instrument.",
            )
        arb_cmd_node = "ARBitrary2"
        if num_points_total % 2 != 0:
            raise InstrumentParameterError(
                parameter="data_points",
                message="Total data_points must be even for dual channel.",
            )
        num_points_per_channel = num_points_total // 2
        if dual_data_format:
            fmt_upper = dual_data_format.upper().strip()
            if fmt_upper not in ["AABB", "ABAB"]:
                raise InstrumentParameterError(
                    parameter="dual_data_format",
                    value=dual_data_format,
                    valid_range=["AABB", "ABAB"],
                    message="Invalid dual_data_format.",
                )
            self._send_command(f"SOUR{ch}:DATA:{arb_cmd_node}:FORMat {fmt_upper}")
            self._error_check()
            self._logger.debug(f"Channel {ch}: Dual arb data format set to {fmt_upper}")
    binary_data: bytes
    scpi_suffix: str
    transfer_type_log_msg: str = "Binary Block"
    if data_type_upper == "DAC":
        scpi_suffix = ":DAC"
        if not np.issubdtype(np_data.dtype, np.integer):
            self._logger.warning("Warning: DAC data not integer, converting to int16.")
            try:
                np_data = np_data.astype(np.int16)
            except ValueError as e:
                raise InstrumentParameterError(
                    parameter="data_points",
                    message="Cannot convert DAC data to int16.",
                ) from e
        dac_min, dac_max = (-32768, 32767)
        if np.any(np_data < dac_min) or np.any(np_data > dac_max):
            raise InstrumentParameterError(
                parameter="data_points",
                message=f"DAC data out of range [{dac_min}, {dac_max}].",
            )
        binary_data = np_data.astype("<h").tobytes()
    else:  # NORM
        scpi_suffix = ""
        if not np.issubdtype(np_data.dtype, np.floating):
            self._logger.warning("Warning: Normalized data not float, converting to float32.")
            try:
                np_data = np_data.astype(np.float32)
            except ValueError as e:
                raise InstrumentParameterError(
                    parameter="data_points",
                    message="Cannot convert Normalized data to float32.",
                ) from e
        norm_min, norm_max = -1.0, 1.0
        tolerance = 1e-6
        if np.any(np_data < norm_min - tolerance) or np.any(np_data > norm_max + tolerance):
            raise InstrumentParameterError(
                parameter="data_points",
                message=f"Normalized data out of range [{norm_min}, {norm_max}].",
            )
        np_data = np.clip(np_data, norm_min, norm_max)
        binary_data = np_data.astype("<f").tobytes()
    cmd_prefix = f"SOUR{ch}:DATA:{arb_cmd_node}{scpi_suffix} {arb_name},"
    try:
        self._write_binary(cmd_prefix, binary_data)
        transfer_type_log_msg = "IEEE 488.2 Binary Block via _write_binary"
        self._logger.debug(
            f"Channel {ch}: Downloaded arb '{arb_name}' via {transfer_type_log_msg} ({num_points_per_channel} pts/ch, {len(binary_data)} bytes, type: {data_type_upper})"
        )
        self._error_check()
    except InstrumentCommunicationError as e:
        self._logger.error(
            f"Error during {transfer_type_log_msg} arb download for '{arb_name}'."
        )
        code, msg = self.get_error()
        if code == 786:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd_prefix,
                message=f"Arb Name Conflict (786) for '{arb_name}'.",
            ) from e
        elif code == 781:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd_prefix,
                message=f"Out of Memory (781) for '{arb_name}'.",
            ) from e
        elif code == -113:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd_prefix,
                message=f"SCPI Syntax Error (-113) for '{arb_name}'.",
            ) from e
        elif code != 0:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd_prefix,
                message=f"Arb download for '{arb_name}' failed. Inst Err {code}: {msg}",
            ) from e
        else:
            raise e
    except Exception as e:
        self._logger.error(f"Unexpected error during binary arb download for '{arb_name}': {e}")
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd_prefix,
            message=f"Unexpected failure downloading arb '{arb_name}'",
        ) from e
download_arbitrary_waveform_data_csv(channel, arb_name, data_points, data_type='DAC')
Source code in pytestlab/instruments/WaveformGenerator.py
def download_arbitrary_waveform_data_csv(
    self,
    channel: int | str,
    arb_name: str,
    data_points: list[int] | list[float] | np.ndarray,
    data_type: str = "DAC",
) -> None:
    ch = self._validate_channel(channel)
    if not re.match(r"^[a-zA-Z0-9_]{1,12}$", arb_name):
        raise InstrumentParameterError(
            parameter="arb_name",
            value=arb_name,
            message="Arbitrary waveform name is invalid.",
        )
    data_type_upper = data_type.upper().strip()
    if data_type_upper not in ["DAC", "NORM"]:
        raise InstrumentParameterError(
            parameter="data_type",
            value=data_type,
            valid_range=["DAC", "NORM"],
            message="Invalid data_type.",
        )
    np_data = np.asarray(data_points)
    if np_data.ndim != 1 or np_data.size == 0:
        raise InstrumentParameterError(
            parameter="data_points", message="data_points must be a non-empty 1D sequence."
        )
    max_len = getattr(
        getattr(getattr(self.config, "waveforms", None), "arbitrary", None), "max_length", None
    )
    if isinstance(max_len, int | float) and np_data.size > max_len:
        self._logger.warning(
            f"Number of data points ({np_data.size}) exceeds configured max_length ({max_len})."
        )
    formatted_data: str
    scpi_suffix: str
    if data_type_upper == "DAC":
        if not np.issubdtype(np_data.dtype, np.integer):
            self._logger.warning("DAC data not integer, converting to int16.")
            try:
                np_data = np_data.astype(np.int16)
            except ValueError as e:
                raise InstrumentParameterError(
                    parameter="data_points",
                    message="Cannot convert DAC data to int16.",
                ) from e
        dac_min, dac_max = (-32768, 32767)
        if np.any(np_data < dac_min) or np.any(np_data > dac_max):
            raise InstrumentParameterError(
                parameter="data_points",
                message=f"DAC data out of range [{dac_min}, {dac_max}].",
            )
        formatted_data = ",".join(map(str, np_data))
        scpi_suffix = ":DAC"
    else:  # NORM
        if not np.issubdtype(np_data.dtype, np.floating):
            self._logger.warning("Normalized data not float, converting to float32.")
            try:
                np_data = np_data.astype(np.float32)
            except ValueError as e:
                raise InstrumentParameterError(
                    parameter="data_points",
                    message="Cannot convert Normalized data to floats.",
                ) from e
        norm_min, norm_max = -1.0, 1.0
        tolerance = 1e-9
        if np.any(np_data < norm_min - tolerance) or np.any(np_data > norm_max + tolerance):
            raise InstrumentParameterError(
                parameter="data_points",
                message=f"Normalized data out of range [{norm_min}, {norm_max}].",
            )
        np_data = np.clip(np_data, norm_min, norm_max)
        formatted_data = ",".join(map(lambda x: f"{x:.8G}", np_data))
        scpi_suffix = ""
    cmd = f"SOUR{ch}:DATA:ARBitrary{scpi_suffix} {arb_name},{formatted_data}"
    max_cmd_len = getattr(self.config, "max_scpi_command_length", 10000)
    if len(cmd) > max_cmd_len:
        self._logger.warning(
            f"SCPI command length ({len(cmd)}) large. Consider binary transfer."
        )
    try:
        self._send_command(cmd)
        self._logger.debug(
            f"Channel {ch}: Downloaded arb '{arb_name}' via CSV ({np_data.size} points, type: {data_type_upper})"
        )
        self._error_check()
    except InstrumentCommunicationError as e:
        self._logger.error(f"Error during CSV arb download for '{arb_name}'.")
        code, msg = self.get_error()
        if code == -113:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"SCPI Syntax Error (-113) for '{arb_name}'.",
            ) from e
        elif code == 786:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"Arb Name Conflict (786) for '{arb_name}'.",
            ) from e
        elif code == 781:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"Out of Memory (781) for '{arb_name}'.",
            ) from e
        elif code == -102:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"SCPI Syntax Error (-102) for '{arb_name}'.",
            ) from e
        elif code != 0:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"Arb download for '{arb_name}' failed. Inst Err {code}: {msg}",
            ) from e
        else:
            raise e
enable_burst(channel, state)
Source code in pytestlab/instruments/WaveformGenerator.py
def enable_burst(self, channel: int | str, state: bool) -> None:
    ch = self._validate_channel(channel)
    cmd_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    self._send_command(f"SOUR{ch}:BURSt:STATe {cmd_state}")
    self._logger.debug(f"Channel {ch}: Burst state set to {cmd_state}")
    self._error_check()
enable_modulation(channel, mod_type, state)
Source code in pytestlab/instruments/WaveformGenerator.py
def enable_modulation(self, channel: int | str, mod_type: str, state: bool) -> None:
    ch = self._validate_channel(channel)
    mod_upper = mod_type.upper().strip()
    valid_mods = {"AM", "FM", "PM", "PWM", "FSK", "BPSK", "SUM"}
    if mod_upper not in valid_mods:
        raise InstrumentParameterError(
            parameter="mod_type",
            value=mod_type,
            valid_range=valid_mods,
            message="Invalid modulation type.",
        )
    cmd_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    self._send_command(f"SOUR{ch}:{mod_upper}:STATe {cmd_state}")
    self._logger.debug(f"Channel {ch}: {mod_upper} modulation state set to {cmd_state}")
    self._error_check()
enable_sweep(channel, state)
Source code in pytestlab/instruments/WaveformGenerator.py
def enable_sweep(self, channel: int | str, state: bool) -> None:
    ch = self._validate_channel(channel)
    cmd_state = SCPIOnOff.ON.value if state else SCPIOnOff.OFF.value
    self._send_command(f"SOUR{ch}:SWEep:STATe {cmd_state}")
    self._logger.debug(f"Channel {ch}: Sweep state set to {cmd_state}")
    self._error_check()
from_config(config, debug_mode=False, **kwargs) classmethod
Source code in pytestlab/instruments/WaveformGenerator.py
@classmethod
def from_config(
    cls: type[Self],
    config: InstrumentConfig,
    debug_mode: bool = False,
    **kwargs: Any,
) -> Self:
    if not isinstance(config, WaveformGeneratorConfig):
        raise InstrumentConfigurationError(
            cls.__name__, "from_config expects a WaveformGeneratorConfig object."
        )
    return cls(config=config, debug_mode=debug_mode, **kwargs)
get_amplitude(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_amplitude(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:VOLTage?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    try:
        q = self.scpi_engine.build("get_amplitude", channel=ch)[0]
        if query_type:
            q += f" {query_type.value}"
        response = (self._query(q)).strip()
    except Exception:
        response = (self._query(cmd)).strip()
    try:
        amp = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse amplitude float from response: '{response}'",
        ) from e
    unit = self.get_voltage_unit(ch)
    self._logger.debug(f"Channel {ch}: Amplitude{type_str} is {amp} {unit.value}")
    return amp
get_angle_unit()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_angle_unit(self) -> str:
    response = (self._query("UNIT:ANGLe?")).strip().upper()
    if response not in ["DEG", "RAD", "SEC"]:
        self._logger.warning(f"Warning: Unexpected angle unit response '{response}'.")
    self._logger.debug(f"Current global angle unit is {response}")
    return response
get_arbitrary_waveform_points(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_arbitrary_waveform_points(self, channel: int | str) -> int:
    ch = self._validate_channel(channel)
    try:
        response = (self._query(f"SOUR{ch}:FUNC:ARB:POINts?")).strip()
        points = int(response)
        self._logger.debug(
            f"Channel {ch}: Currently selected arbitrary waveform has {points} points"
        )
        return points
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"SOUR{ch}:FUNC:ARB:POINts?",
            message=f"Failed to parse integer points from response: '{response}'",
        ) from e
    except InstrumentCommunicationError as e:
        code, msg = self.get_error()
        if code != 0:
            self._logger.warning(
                f"Query SOUR{ch}:FUNC:ARB:POINts? failed. Inst Err {code}: {msg}. Returning 0."
            )
            return 0
        else:
            raise e
get_arbitrary_waveform_sample_rate(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_arbitrary_waveform_sample_rate(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:FUNC:ARB:SRATe?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    response = (self._query(cmd)).strip()
    try:
        sr = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse sample rate float from response: '{response}'",
        ) from e
    self._logger.debug(f"Channel {ch}: Arbitrary waveform sample rate{type_str} is {sr} Sa/s")
    return sr
get_channel_configuration_summary(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_channel_configuration_summary(self, channel: int | str) -> str:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:APPLy?")).strip()
    self._logger.debug(f"Channel {ch}: Configuration summary (APPLy?) returned: {response}")
    if response.startswith('"') and response.endswith('"') and response.count('"') == 2:
        return response[1:-1]
    return response
get_complete_config(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_complete_config(self, channel: int | str) -> WaveformConfigResult:
    ch_num = self._validate_channel(channel)
    self._logger.debug(f"Getting complete configuration snapshot for channel {ch_num}...")
    func_scpi_str = self.get_function(ch_num)
    freq = self.get_frequency(ch_num)
    ampl = self.get_amplitude(ch_num)
    offs = self.get_offset(ch_num)
    output_state_enum = self.get_output_state(ch_num)
    output_state_bool = True if output_state_enum == SCPIOnOff.ON else False
    load_impedance_val = self.get_output_load_impedance(ch_num)
    load_impedance_str: str | float
    if (
        isinstance(load_impedance_val, OutputLoadImpedance)
        and load_impedance_val == OutputLoadImpedance.INFINITY
    ):
        load_impedance_str = "INFinity"
    else:
        load_impedance_str = float(load_impedance_val)
    voltage_unit_enum = self.get_voltage_unit(ch_num)
    voltage_unit_str = voltage_unit_enum.value
    phase: float | None = None
    if func_scpi_str not in [WaveformType.DC.value, WaveformType.NOISE.value]:
        try:
            phase = self.get_phase(ch_num)
        except InstrumentCommunicationError as e:
            self._log(
                f"Note: Phase query failed for CH{ch_num} (function: {func_scpi_str}): {e}",
                level="info",
            )
    symmetry: float | None = None
    duty_cycle: float | None = None
    try:
        if func_scpi_str == WaveformType.RAMP.value:
            symmetry = self.get_ramp_symmetry(ch_num)
        elif func_scpi_str == WaveformType.SQUARE.value:
            duty_cycle = self.get_square_duty_cycle(ch_num)
        elif func_scpi_str == WaveformType.PULSE.value:
            duty_cycle = self.get_pulse_duty_cycle(ch_num)
    except InstrumentCommunicationError as e:
        self._log(
            f"Note: Query failed for function-specific parameter for CH{ch_num} func {func_scpi_str}: {e}",
            level="info",
        )
    return WaveformConfigResult(
        channel=ch_num,
        function=func_scpi_str,
        frequency=freq,
        amplitude=ampl,
        offset=offs,
        phase=phase,
        symmetry=symmetry,
        duty_cycle=duty_cycle,
        output_state=output_state_bool,
        load_impedance=load_impedance_str,
        voltage_unit=voltage_unit_str,
    )
get_free_volatile_arbitrary_memory(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_free_volatile_arbitrary_memory(self, channel: int | str) -> int:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:DATA:VOLatile:FREE?")).strip()
    try:
        free_points = int(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"SOUR{ch}:DATA:VOLatile:FREE?",
            message=f"Unexpected non-integer response: {response}",
        ) from e
    self._logger.debug(f"Channel {ch}: Free volatile arbitrary memory: {free_points} points")
    return free_points
get_frequency(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_frequency(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:FREQ?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    try:
        q = self.scpi_engine.build("get_frequency", channel=ch)[0]
        if query_type:
            q += f" {query_type.value}"
        response = (self._query(q)).strip()
    except Exception:
        response = (self._query(cmd)).strip()
    try:
        freq = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse frequency float from response: '{response}'",
        ) from e
    self._logger.debug(f"Channel {ch}: Frequency{type_str} is {freq} Hz")
    return freq
get_function(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
def get_function(self, channel: int | str) -> str:
    ch = self._validate_channel(channel)
    scpi_func = (self._query(f"SOUR{ch}:FUNC?")).strip()
    self._logger.debug(f"Channel {ch}: Current function is {scpi_func}")
    return scpi_func
get_offset(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_offset(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:VOLTage:OFFSet?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    try:
        q = self.scpi_engine.build("get_offset", channel=ch)[0]
        if query_type:
            q += f" {query_type.value}"
        response = (self._query(q)).strip()
    except Exception:
        response = (self._query(cmd)).strip()
    try:
        offs = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse offset float from response: '{response}'",
        ) from e
    self._logger.debug(f"Channel {ch}: Offset{type_str} is {offs} V")
    return offs
get_output_load_impedance(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_output_load_impedance(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float | OutputLoadImpedance:
    ch = self._validate_channel(channel)
    cmd = f"OUTPut{ch}:LOAD?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    response = (self._query(cmd)).strip()
    self._logger.debug(f"Channel {ch}: Raw impedance response{type_str} is '{response}'")
    try:
        numeric_response = float(response)
        if abs(numeric_response - 9.9e37) < 1e30:
            return OutputLoadImpedance.INFINITY
        else:
            return numeric_response
    except ValueError as e:
        if response.upper() == OutputLoadImpedance.INFINITY.value.upper():
            return OutputLoadImpedance.INFINITY
        for enum_member in OutputLoadImpedance:
            if response.upper() == enum_member.value.upper():
                return enum_member
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Could not parse impedance response: '{response}'",
        ) from e
get_output_polarity(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_output_polarity(self, channel: int | str) -> OutputPolarity:
    ch = self._validate_channel(channel)
    response = (self._query(f"OUTPut{ch}:POLarity?")).strip().upper()
    try:
        return OutputPolarity(response)
    except ValueError as e:
        if response == "NORM":
            return OutputPolarity.NORMAL
        if response == "INV":
            return OutputPolarity.INVERTED
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"OUTPut{ch}:POLarity?",
            message=f"Unexpected polarity response from instrument: {response}",
        ) from e
get_output_state(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_output_state(self, channel: int | str) -> SCPIOnOff:
    ch = self._validate_channel(channel)
    try:
        q = self.scpi_engine.build("get_output_state", channel=ch)[0]
        response = (self._query(q)).strip()
    except Exception:
        response = (self._query(f"OUTPut{ch}:STATe?")).strip()
    state = SCPIOnOff.ON if response == "1" else SCPIOnOff.OFF
    self._logger.debug(f"Channel {ch}: Output state is {state.value}")
    return state
get_phase(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_phase(self, channel: int | str, query_type: OutputLoadImpedance | None = None) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:PHASe?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} limit)"
    response = (self._query(cmd)).strip()
    try:
        ph = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse phase float from response: '{response}'",
        ) from e
    unit = self.get_angle_unit()
    self._logger.debug(f"Channel {ch}: Phase{type_str} is {ph} {unit}")
    return ph
get_phase_unlock_error_state()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_phase_unlock_error_state(self) -> SCPIOnOff:
    response = (self._query("SOUR1:PHASe:UNLock:ERRor:STATe?")).strip()
    s = response.strip().upper()
    if s in {"1", "ON", "TRUE"}:
        state = SCPIOnOff.ON
    elif s in {"0", "OFF", "FALSE"}:
        state = SCPIOnOff.OFF
    else:
        raise InstrumentCommunicationError(
            self.config.model,
            "SOUR1:PHASe:UNLock:ERRor:STATe?",
            f"Unexpected response: {response}",
        )
    self._logger.debug(f"Phase unlock error state is {state.value}")
    return state
get_pulse_duty_cycle(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_duty_cycle(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:DCYCle?")).strip()
    return float(response)
get_pulse_hold_mode(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_hold_mode(self, channel: int | str) -> str:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:HOLD?")).strip().upper()
    return response
get_pulse_period(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_period(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:PERiod?")).strip()
    return float(response)
get_pulse_transition_both(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_transition_both(self, channel: int | str) -> float:
    warnings.warn(
        "Querying PULS:TRAN:BOTH; specific query may not exist or might return leading edge time.",
        UserWarning,
        stacklevel=2,
    )
    return self.get_pulse_transition_leading(channel)
get_pulse_transition_leading(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_transition_leading(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:TRANsition:LEADing?")).strip()
    return float(response)
get_pulse_transition_trailing(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_transition_trailing(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:TRANsition:TRAiling?")).strip()
    return float(response)
get_pulse_width(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_pulse_width(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:PULS:WIDTh?")).strip()
    return float(response)
get_ramp_symmetry(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_ramp_symmetry(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:RAMP:SYMMetry?")).strip()
    return float(response)
get_selected_arbitrary_waveform_name(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_selected_arbitrary_waveform_name(self, channel: int | str) -> str:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:ARBitrary?")).strip()
    if response.startswith('"') and response.endswith('"'):
        response = response[1:-1]
    self._logger.debug(f"Channel {ch}: Currently selected arbitrary waveform is '{response}'")
    return response
get_square_duty_cycle(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_square_duty_cycle(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:SQUare:DCYCle?")).strip()
    return float(response)
get_square_period(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_square_period(self, channel: int | str) -> float:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:FUNC:SQUare:PERiod?")).strip()
    return float(response)
get_sync_output_mode(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_sync_output_mode(self, channel: int | str) -> SyncMode:
    ch = self._validate_channel(channel)
    response = (self._query(f"OUTPut{ch}:SYNC:MODE?")).strip().upper()
    try:
        return SyncMode(response)
    except ValueError as e:
        if response == "NORM":
            return SyncMode.NORMAL
        if response == "CARR":
            return SyncMode.CARRIER
        if response == "MARK":
            return SyncMode.MARKER
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"OUTPut{ch}:SYNC:MODE?",
            message=f"Unexpected sync mode response from instrument: {response}",
        ) from e
get_sync_output_polarity(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_sync_output_polarity(self, channel: int | str) -> OutputPolarity:
    ch = self._validate_channel(channel)
    response = (self._query(f"OUTPut{ch}:SYNC:POLarity?")).strip().upper()
    try:
        return OutputPolarity(response)
    except ValueError as e:
        if response == "NORM":
            return OutputPolarity.NORMAL
        if response == "INV":
            return OutputPolarity.INVERTED
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"OUTPut{ch}:SYNC:POLarity?",
            message=f"Unexpected sync polarity response from instrument: {response}",
        ) from e
get_sync_output_source()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_sync_output_source(self) -> int:
    response = (self._query("OUTPut:SYNC:SOURce?")).strip().upper()
    match = re.match(r"CH(\d+)", response)
    if match:
        src_ch = int(match.group(1))
        self._logger.debug(f"Sync output source is CH{src_ch}")
        return src_ch
    else:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command="OUTPut:SYNC:SOURce?",
            message=f"Unexpected response querying Sync source: '{response}'",
        )
get_sync_output_state()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_sync_output_state(self) -> SCPIOnOff:
    response = (self._query("OUTPut:SYNC:STATe?")).strip()
    s = response.strip().upper()
    state = (
        SCPIOnOff.ON
        if s in {"1", "ON", "TRUE"}
        else SCPIOnOff.OFF
        if s in {"0", "OFF", "FALSE"}
        else (_ for _ in ()).throw(
            InstrumentCommunicationError(
                self.config.model, "OUTPut:SYNC:STATe?", f"Unexpected response: {response}"
            )
        )
    )
    self._logger.debug(f"Sync output state is {state.value}")
    return state
get_voltage_autorange_state(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_voltage_autorange_state(self, channel: int | str) -> SCPIOnOff:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:VOLTage:RANGe:AUTO?")).strip()
    s = response.strip().upper()
    state = (
        SCPIOnOff.ON
        if s in {"1", "ON", "TRUE"}
        else SCPIOnOff.OFF
        if s in {"0", "OFF", "FALSE"}
        else (_ for _ in ()).throw(
            InstrumentCommunicationError(
                self.config.model,
                f"SOUR{ch}:VOLTage:RANGe:AUTO?",
                f"Unexpected response: {response}",
            )
        )
    )
    self._logger.debug(
        f"Channel {ch}: Voltage autorange state is {state.value} (Query response: {response})"
    )
    return state
get_voltage_limit_high(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_voltage_limit_high(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:VOLTage:LIMit:HIGH?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} possible)"
    response = (self._query(cmd)).strip()
    try:
        val = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse high limit float from response: '{response}'",
        ) from e
    self._logger.debug(f"Channel {ch}: Voltage high limit{type_str} is {val} V")
    return val
get_voltage_limit_low(channel, query_type=None)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_voltage_limit_low(
    self, channel: int | str, query_type: OutputLoadImpedance | None = None
) -> float:
    ch = self._validate_channel(channel)
    cmd = f"SOUR{ch}:VOLTage:LIMit:LOW?"
    type_str = ""
    if query_type:
        cmd += f" {query_type.value}"
        type_str = f" ({query_type.name} possible)"
    response = (self._query(cmd)).strip()
    try:
        val = float(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse low limit float from response: '{response}'",
        ) from e
    self._logger.debug(f"Channel {ch}: Voltage low limit{type_str} is {val} V")
    return val
get_voltage_limits_state(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_voltage_limits_state(self, channel: int | str) -> SCPIOnOff:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:VOLTage:LIMit:STATe?")).strip()
    s = response.strip().upper()
    state = (
        SCPIOnOff.ON
        if s in {"1", "ON", "TRUE"}
        else SCPIOnOff.OFF
        if s in {"0", "OFF", "FALSE"}
        else (_ for _ in ()).throw(
            InstrumentCommunicationError(
                self.config.model,
                f"SOUR{ch}:VOLTage:LIMit:STATe?",
                f"Unexpected response: {response}",
            )
        )
    )
    self._logger.debug(f"Channel {ch}: Voltage limits state is {state.value}")
    return state
get_voltage_unit(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def get_voltage_unit(self, channel: int | str) -> VoltageUnit:
    ch = self._validate_channel(channel)
    response = (self._query(f"SOUR{ch}:VOLTage:UNIT?")).strip().upper()
    try:
        return VoltageUnit(response)
    except ValueError as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=f"SOUR{ch}:VOLTage:UNIT?",
            message=f"Unexpected voltage unit response from instrument: {response}",
        ) from e
list_directory(path='')
Source code in pytestlab/instruments/WaveformGenerator.py
def list_directory(self, path: str = "") -> FileSystemInfo:
    path_scpi = f' "{path}"' if path else ""
    cmd = f"MMEMory:CATalog:ALL?{path_scpi}"
    response = (self._query(cmd)).strip()
    try:
        parts = response.split(",", 2)
        if len(parts) < 2:
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                command=cmd,
                message=f"Unexpected response format from MMEM:CAT?: {response}",
            )
        bytes_used = int(parts[0])
        bytes_free = int(parts[1])
        info = FileSystemInfo(bytes_used=bytes_used, bytes_free=bytes_free)
        if len(parts) > 2 and parts[2]:
            file_pattern = r'"([^"]+),([^"]*),(\d+)"'
            listings = re.findall(file_pattern, parts[2])
            for name, ftype, size_str in listings:
                file_type = ftype if ftype else "FILE"
                try:
                    size = int(size_str)
                except ValueError:
                    self._log(
                        f"Warning: Could not parse size '{size_str}' for file '{name}'.",
                        level="warning",
                    )
                    continue
                info.files.append({"name": name, "type": file_type.upper(), "size": size})
        self._logger.debug(
            f"Directory listing for '{path or 'current dir'}': Used={info.bytes_used}, Free={info.bytes_free}, Items={len(info.files)}"
        )
        return info
    except (ValueError, IndexError) as e:
        raise InstrumentCommunicationError(
            instrument=self.config.model,
            command=cmd,
            message=f"Failed to parse MMEM:CAT? response: '{response}'. Error: {e}",
        ) from e
select_arbitrary_waveform(channel, arb_name)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def select_arbitrary_waveform(self, channel: int | str, arb_name: str) -> None:
    ch = self._validate_channel(channel)
    if not arb_name:
        raise InstrumentParameterError(
            parameter="arb_name", message="Arbitrary waveform name cannot be empty."
        )
    if '"' in arb_name or "'" in arb_name:
        raise InstrumentParameterError(
            parameter="arb_name",
            value=arb_name,
            message="Arbitrary waveform name cannot contain quotes.",
        )
    quoted_arb_name = f'"{arb_name}"'
    self._send_command(f"SOUR{ch}:FUNC:ARBitrary {quoted_arb_name}")
    self._logger.debug(f"Channel {ch}: Active arbitrary waveform selection set to '{arb_name}'")
    self._error_check()
set_am_depth(channel, depth_percent)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_am_depth(self, channel: int | str, depth_percent: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(depth_percent)
    if isinstance(depth_percent, int | float) and not (0 <= float(depth_percent) <= 120):
        self._log(
            f"Warning: AM depth {depth_percent}% is outside typical 0-120 range.",
            level="warning",
        )
    self._send_command(f"SOUR{ch}:AM:DEPTh {cmd_val}")
    self._logger.debug(f"Channel {ch}: AM depth set to {depth_percent}%")
    self._error_check()
set_am_source(channel, source)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_am_source(self, channel: int | str, source: ModulationSource) -> None:
    ch = self._validate_channel(channel)
    cmd_src = source.value
    if cmd_src == f"CH{ch}":
        raise InstrumentParameterError(
            parameter="source",
            value=source,
            message=f"Channel {ch} cannot be its own AM source.",
        )
    if cmd_src == ModulationSource.CH2.value and self.channel_count < 2:
        raise InstrumentParameterError(
            parameter="source",
            value=source,
            message="CH2 source invalid for 1-channel instrument.",
        )
    self._send_command(f"SOUR{ch}:AM:SOURce {cmd_src}")
    self._logger.debug(f"Channel {ch}: AM source set to {cmd_src}")
    self._error_check()
set_amplitude(channel, amplitude)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_amplitude(
    self, channel: int | str, amplitude: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    amp_cmd_val = self._format_value_min_max_def(amplitude)
    if isinstance(amplitude, int | float):
        if 0 <= (ch - 1) < len(self.config.channels):
            channel_config_model = self.config.channels[ch - 1]
            channel_config_model.amplitude_range.assert_in_range(
                float(amplitude), name=f"Amplitude for CH{ch}"
            )
    try:
        for cmd in self.scpi_engine.build("set_amplitude", channel=ch, amplitude=amp_cmd_val):
            self._send_command(cmd)
    except Exception:
        self._send_command(f"SOUR{ch}:VOLTage {amp_cmd_val}")
    unit = self.get_voltage_unit(ch)
    self._logger.debug(
        f"Channel {ch}: Amplitude set to {amplitude} (in current unit: {unit.value}, using SCPI value: {amp_cmd_val})"
    )
    self._error_check()
set_angle_unit(unit)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_angle_unit(self, unit: str) -> None:
    unit_upper = unit.upper().strip()
    valid_scpi_units = {"DEGREE", "RADIAN", "SECOND", "DEG", "RAD", "SEC"}
    map_to_scpi_preferred = {
        "DEG": "DEGREE",
        "DEGREES": "DEGREE",
        "RAD": "RADIAN",
        "RADIANS": "RADIAN",
        "SEC": "SECOND",
        "SECONDS": "SECOND",
    }
    scpi_to_send = map_to_scpi_preferred.get(unit_upper, unit_upper)
    if scpi_to_send not in valid_scpi_units and unit_upper not in valid_scpi_units:
        raise InstrumentParameterError(
            parameter="unit",
            value=unit,
            valid_range=["DEGREE", "RADIAN", "SECOND"],
            message="Invalid angle unit.",
        )
    self._send_command(f"UNIT:ANGLe {scpi_to_send}")
    self._logger.debug(f"Global angle unit set to {scpi_to_send}")
    self._error_check()
set_arbitrary_waveform_sample_rate(channel, sample_rate)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_arbitrary_waveform_sample_rate(
    self, channel: int | str, sample_rate: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(sample_rate)
    if isinstance(sample_rate, int | float):
        if (
            hasattr(self.config, "waveforms")
            and hasattr(self.config.waveforms, "arbitrary")
            and self.config.waveforms.arbitrary is not None
            and hasattr(self.config.waveforms.arbitrary, "sampling_rate")
            and self.config.waveforms.arbitrary.sampling_rate is not None
        ):
            self.config.waveforms.arbitrary.sampling_rate.assert_in_range(
                float(sample_rate), name="Arbitrary sample rate"
            )
    self._send_command(f"SOUR{ch}:FUNC:ARB:SRATe {cmd_val}")
    self._logger.debug(
        f"Channel {ch}: Arbitrary waveform sample rate set to {sample_rate} Sa/s (using SCPI value: {cmd_val})"
    )
    self._error_check()
set_burst_cycles(channel, n_cycles)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_burst_cycles(self, channel: int | str, n_cycles: int | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val: str
    log_val: int | str = n_cycles
    if isinstance(n_cycles, str):
        nc_upper = n_cycles.upper().strip()
        if nc_upper in {"MIN", "MINIMUM"}:
            cmd_val = OutputLoadImpedance.MINIMUM.value
        elif nc_upper in {"MAX", "MAXIMUM"}:
            cmd_val = OutputLoadImpedance.MAXIMUM.value
        elif nc_upper in {"INF", "INFINITY"}:
            cmd_val = "INFinity"
        else:
            raise InstrumentParameterError(
                parameter="n_cycles",
                value=n_cycles,
                message="Invalid string for burst cycles.",
            )
    elif isinstance(n_cycles, int):
        if n_cycles < 1:
            raise InstrumentParameterError(
                parameter="n_cycles",
                value=n_cycles,
                message="Burst cycle count must be positive.",
            )
        inst_max_cycles = 100_000_000
        if n_cycles > inst_max_cycles:
            self._log(
                f"Warning: Burst cycles {n_cycles} > typical max ({inst_max_cycles}).",
                level="warning",
            )
        cmd_val = str(n_cycles)
    else:
        raise InstrumentParameterError(
            parameter="n_cycles",
            value=n_cycles,
            message=f"Invalid type '{type(n_cycles)}' for burst cycles.",
        )
    self._send_command(f"SOUR{ch}:BURSt:NCYCles {cmd_val}")
    self._logger.debug(f"Channel {ch}: Burst cycles set to {log_val}")
    self._error_check()
set_burst_mode(channel, mode)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_burst_mode(self, channel: int | str, mode: BurstMode) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:BURSt:MODE {mode.value}")
    self._logger.debug(f"Channel {ch}: Burst mode set to {mode.value}")
    self._error_check()
set_burst_period(channel, period_sec)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_burst_period(self, channel: int | str, period_sec: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(period_sec)
    self._send_command(f"SOUR{ch}:BURSt:INTernal:PERiod {cmd_val}")
    self._logger.debug(f"Channel {ch}: Internal burst period set to {period_sec} s")
    self._error_check()
set_fm_deviation(channel, deviation_hz)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_fm_deviation(self, channel: int | str, deviation_hz: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(deviation_hz)
    self._send_command(f"SOUR{ch}:FM:DEViation {cmd_val}")
    self._logger.debug(f"Channel {ch}: FM deviation set to {deviation_hz} Hz")
    self._error_check()
set_frequency(channel, frequency)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_frequency(
    self, channel: int | str, frequency: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    freq_cmd_val = self._format_value_min_max_def(frequency)
    if isinstance(frequency, int | float):
        if 0 <= (ch - 1) < len(self.config.channels):
            channel_config_model = self.config.channels[ch - 1]
            channel_config_model.frequency_range.assert_in_range(
                float(frequency), name=f"Frequency for CH{ch}"
            )
    try:
        for cmd in self.scpi_engine.build("set_frequency", channel=ch, frequency=freq_cmd_val):
            self._send_command(cmd)
        self._logger.debug(
            f"Channel {ch}: Frequency set to {frequency} Hz (using SCPI value: {freq_cmd_val}) via SCPIEngine"
        )
        self._error_check()
    except Exception:
        self._send_command(f"SOUR{ch}:FREQ {freq_cmd_val}")
        self._logger.debug(
            f"Channel {ch}: Frequency set to {frequency} Hz (using SCPI value: {freq_cmd_val})"
        )
        self._error_check()
set_function(channel, function_type, **kwargs)

Sets the primary waveform function and associated parameters for a channel.

Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_function(
    self, channel: int | str, function_type: WaveformType | str, **kwargs: Any
) -> None:
    """
    Sets the primary waveform function and associated parameters for a channel.
    """
    ch = self._validate_channel(channel)
    scpi_func_short = self._get_scpi_function_name(function_type)

    standard_params_set: dict[str, bool] = {}
    # Assuming FUNC_ARB should be WaveformType.ARB.value
    if "frequency" in kwargs and scpi_func_short != WaveformType.ARB.value:
        self.set_frequency(ch, kwargs.pop("frequency"))
        standard_params_set["frequency"] = True
    if "amplitude" in kwargs:
        self.set_amplitude(ch, kwargs.pop("amplitude"))
        standard_params_set["amplitude"] = True
    if "offset" in kwargs:
        self.set_offset(ch, kwargs.pop("offset"))
        standard_params_set["offset"] = True

    # Prefer SCPIEngine if available
    try:
        for cmd in self.scpi_engine.build("set_function", channel=ch, function=scpi_func_short):
            self._send_command(cmd)
        self._logger.debug(
            f"Channel {ch}: Function set to {function_type} (SCPI: {scpi_func_short}) via SCPIEngine"
        )
        self._error_check()
    except Exception:
        self._send_command(f"SOUR{ch}:FUNC {scpi_func_short}")
        self._logger.debug(
            f"Channel {ch}: Function set to {function_type} (SCPI: {scpi_func_short})"
        )
        self._error_check()

    if kwargs:
        # Ensure WAVEFORM_PARAM_COMMANDS keys are WaveformType enum members
        # And scpi_func_short is mapped to its corresponding WaveformType enum member if it's a string
        func_enum_key: WaveformType | None = None
        if isinstance(function_type, WaveformType):
            func_enum_key = function_type
        elif isinstance(function_type, str):
            try:
                # First try to convert SCPI string directly to enum member
                func_enum_key = WaveformType(scpi_func_short)
            except ValueError:
                # If that fails, try to map profile config values to enum members
                scpi_to_enum_map = {
                    "SINUSOID": WaveformType.SINE,
                    "SQUARE": WaveformType.SQUARE,
                    "RAMP": WaveformType.RAMP,
                    "PULSE": WaveformType.PULSE,
                    "NOISE": WaveformType.NOISE,
                    "DC": WaveformType.DC,
                    "ARB": WaveformType.ARB,
                    "ARBITRARY": WaveformType.ARB,
                    # Add enum values as fallback
                    "SIN": WaveformType.SINE,
                    "SQU": WaveformType.SQUARE,
                    "PULS": WaveformType.PULSE,
                    "NOIS": WaveformType.NOISE,
                }
                func_enum_key = scpi_to_enum_map.get(scpi_func_short.upper())
                if func_enum_key is None:
                    self._logger.warning(
                        f"SCPI function '{scpi_func_short}' not mappable to WaveformType enum for parameter lookup."
                    )

        param_cmds_for_func = (
            WAVEFORM_PARAM_COMMANDS.get(func_enum_key) if func_enum_key else None
        )

        if not param_cmds_for_func:
            self._logger.warning(
                f"No specific parameters defined for function '{function_type}' (SCPI: {scpi_func_short}). "
                f"Ignoring remaining kwargs: {kwargs}"
            )
            if any(k not in standard_params_set for k in kwargs):
                raise InstrumentParameterError(
                    message=f"Unknown parameters {list(kwargs.keys())} passed for function {function_type}."
                )
            return

        for param_name, value in kwargs.items():
            cmd = ""
            if param_name in param_cmds_for_func:
                try:
                    if param_name in ["duty_cycle", "symmetry"] and isinstance(
                        value, int | float
                    ):
                        if not (0 <= float(value) <= 100):
                            self._logger.warning(
                                f"Parameter '{param_name}' value {value}% is outside the "
                                f"typical 0-100 range. Instrument validation will apply."
                            )

                    value_to_format = value
                    if isinstance(
                        value, ArbFilterType | ArbAdvanceMode
                    ):  # Pass enum value for formatting
                        value_to_format = value.value

                    formatted_value = self._format_value_min_max_def(value_to_format)
                    cmd_lambda = param_cmds_for_func[param_name]
                    cmd = cmd_lambda(ch, formatted_value)

                    self._send_command(cmd)
                    self._logger.debug(f"Channel {ch}: Parameter '{param_name}' set to {value}")
                    self._error_check()
                except InstrumentParameterError as ipe:
                    raise InstrumentParameterError(
                        parameter=param_name,
                        value=value,
                        message=f"Invalid value for function '{function_type}'. Cause: {ipe}",
                    ) from ipe
                except InstrumentCommunicationError:
                    raise
                except Exception as e:
                    self._logger.error(
                        f"Error setting parameter '{param_name}' for function '{scpi_func_short}': {e}"
                    )
                    raise InstrumentCommunicationError(
                        instrument=self.config.model,
                        command=cmd,
                        message=f"Failed to set parameter {param_name}",
                    ) from e
            else:
                raise InstrumentParameterError(
                    parameter=param_name,
                    message=f"Parameter is not supported for function '{function_type}' ({scpi_func_short}). Supported: {list(param_cmds_for_func.keys())}",
                )
set_offset(channel, offset)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_offset(self, channel: int | str, offset: float | OutputLoadImpedance | str) -> None:
    ch = self._validate_channel(channel)
    offset_cmd_val = self._format_value_min_max_def(offset)
    try:
        for cmd in self.scpi_engine.build("set_offset", channel=ch, offset=offset_cmd_val):
            self._send_command(cmd)
    except Exception:
        self._send_command(f"SOUR{ch}:VOLTage:OFFSet {offset_cmd_val}")
    self._logger.debug(f"Channel {ch}: Offset set to {offset} V")
    self._error_check()
set_output_load_impedance(channel, impedance)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_output_load_impedance(
    self, channel: int | str, impedance: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    cmd_impedance = self._format_value_min_max_def(impedance)
    # No per-channel load impedance limits in WaveformGeneratorConfig; skipping explicit validation.
    self._send_command(f"OUTPut{ch}:LOAD {cmd_impedance}")
    self._logger.debug(
        f"Channel {ch}: Output load impedance setting updated to {impedance} (using SCPI value: {cmd_impedance})"
    )
    self._error_check()
set_output_polarity(channel, polarity)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_output_polarity(self, channel: int | str, polarity: OutputPolarity) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"OUTPut{ch}:POLarity {polarity.value}")
    self._logger.debug(f"Channel {ch}: Output polarity set to {polarity.value}")
    self._error_check()
set_output_state(channel, state)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call  # Duplicated @validate_call removed
def set_output_state(self, channel: int | str, state: SCPIOnOff) -> None:
    ch = self._validate_channel(channel)
    try:
        for cmd in self.scpi_engine.build("set_output_state", channel=ch, state=state.value):
            self._send_command(cmd)
    except Exception:
        self._send_command(f"OUTPut{ch}:STATe {state.value}")
    self._logger.debug(f"Channel {ch}: Output state set to {state.value}")
    self._error_check()
set_phase(channel, phase)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_phase(self, channel: int | str, phase: float | OutputLoadImpedance | str) -> None:
    ch = self._validate_channel(channel)
    phase_cmd_val = self._format_value_min_max_def(phase)
    # No phase range validation available in WaveformGeneratorConfig; skipping explicit validation.
    self._send_command(f"SOUR{ch}:PHASe {phase_cmd_val}")
    unit = self.get_angle_unit()
    self._logger.debug(
        f"Channel {ch}: Phase set to {phase} (in current unit: {unit}, using SCPI value: {phase_cmd_val})"
    )
    self._error_check()
set_phase_reference(channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_phase_reference(self, channel: int | str) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:PHASe:REFerence")
    self._logger.debug(f"Channel {ch}: Phase reference reset (current phase defined as 0).")
    self._error_check()
set_phase_unlock_error_state(state)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_phase_unlock_error_state(self, state: SCPIOnOff) -> None:
    self._send_command(f"SOUR1:PHASe:UNLock:ERRor:STATe {state.value}")
    self._logger.debug(f"Phase unlock error state set to {state.value}")
    self._error_check()
set_sweep_spacing(channel, spacing)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_sweep_spacing(self, channel: int | str, spacing: SweepSpacing) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:SWEep:SPACing {spacing.value}")
    self._logger.debug(f"Channel {ch}: Sweep spacing set to {spacing.value}")
    self._error_check()
set_sweep_start_frequency(channel, freq_hz)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_sweep_start_frequency(self, channel: int | str, freq_hz: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(freq_hz)
    self._send_command(f"SOUR{ch}:FREQuency:STARt {cmd_val}")
    self._logger.debug(f"Channel {ch}: Sweep start frequency set to {freq_hz} Hz")
    self._error_check()
set_sweep_stop_frequency(channel, freq_hz)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_sweep_stop_frequency(self, channel: int | str, freq_hz: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(freq_hz)
    self._send_command(f"SOUR{ch}:FREQuency:STOP {cmd_val}")
    self._logger.debug(f"Channel {ch}: Sweep stop frequency set to {freq_hz} Hz")
    self._error_check()
set_sweep_time(channel, sweep_time_sec)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_sweep_time(self, channel: int | str, sweep_time_sec: float | str) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(sweep_time_sec)
    self._send_command(f"SOUR{ch}:SWEep:TIME {cmd_val}")
    self._logger.debug(f"Channel {ch}: Sweep time set to {sweep_time_sec} s")
    self._error_check()
set_sync_output_mode(channel, mode)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_sync_output_mode(self, channel: int | str, mode: SyncMode) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"OUTPut{ch}:SYNC:MODE {mode.value}")
    self._logger.debug(f"Channel {ch}: Sync output mode set to {mode.value}")
    self._error_check()
set_sync_output_polarity(channel, polarity)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_sync_output_polarity(self, channel: int | str, polarity: OutputPolarity) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"OUTPut{ch}:SYNC:POLarity {polarity.value}")
    self._logger.debug(f"Channel {ch}: Sync output polarity set to {polarity.value}")
    self._error_check()
set_sync_output_source(source_channel)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_sync_output_source(self, source_channel: int) -> None:
    ch_to_set = self._validate_channel(source_channel)
    self._send_command(f"OUTPut:SYNC:SOURce CH{ch_to_set}")
    self._logger.debug(f"Sync output source set to CH{ch_to_set}")
    self._error_check()
set_sync_output_state(state)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_sync_output_state(self, state: SCPIOnOff) -> None:
    self._send_command(f"OUTPut:SYNC:STATe {state.value}")
    self._logger.debug(f"Sync output state set to {state.value}")
    self._error_check()
set_trigger_slope(channel, slope)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_trigger_slope(self, channel: int | str, slope: TriggerSlope) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"TRIGger{ch}:SLOPe {slope.value}")
    self._logger.debug(f"Channel {ch}: Trigger slope set to {slope.value}")
    self._error_check()
set_trigger_source(channel, source)
Source code in pytestlab/instruments/WaveformGenerator.py
def set_trigger_source(self, channel: int | str, source: TriggerSource) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"TRIGger{ch}:SOURce {source.value}")
    self._logger.debug(f"Channel {ch}: Trigger source set to {source.value}")
    self._error_check()
set_voltage_autorange_state(channel, state)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_autorange_state(self, channel: int | str, state: SCPIOnOff) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:VOLTage:RANGe:AUTO {state.value}")
    self._logger.debug(f"Channel {ch}: Voltage autorange state set to {state.value}")
    self._error_check()
set_voltage_limit_high(channel, voltage)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_limit_high(
    self, channel: int | str, voltage: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(voltage)
    self._send_command(f"SOUR{ch}:VOLTage:LIMit:HIGH {cmd_val}")
    self._logger.debug(
        f"Channel {ch}: Voltage high limit set to {voltage} V (using SCPI value: {cmd_val})"
    )
    self._error_check()
set_voltage_limit_low(channel, voltage)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_limit_low(
    self, channel: int | str, voltage: float | OutputLoadImpedance | str
) -> None:
    ch = self._validate_channel(channel)
    cmd_val = self._format_value_min_max_def(voltage)
    self._send_command(f"SOUR{ch}:VOLTage:LIMit:LOW {cmd_val}")
    self._logger.debug(
        f"Channel {ch}: Voltage low limit set to {voltage} V (using SCPI value: {cmd_val})"
    )
    self._error_check()
set_voltage_limits_state(channel, state)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_limits_state(self, channel: int | str, state: SCPIOnOff) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:VOLTage:LIMit:STATe {state.value}")
    self._logger.debug(f"Channel {ch}: Voltage limits state set to {state.value}")
    self._error_check()
set_voltage_unit(channel, unit)
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def set_voltage_unit(self, channel: int | str, unit: VoltageUnit) -> None:
    ch = self._validate_channel(channel)
    self._send_command(f"SOUR{ch}:VOLTage:UNIT {unit.value}")
    self._logger.debug(f"Channel {ch}: Voltage unit set to {unit.value}")
    self._error_check()
synchronize_phase_all_channels()
Source code in pytestlab/instruments/WaveformGenerator.py
@validate_call
def synchronize_phase_all_channels(self) -> None:
    if self.channel_count < 2:
        self._logger.warning(
            "Warning: Phase synchronization command sent, but primarily intended for multi-channel instruments."
        )
    self._send_command("PHASe:SYNChronize")
    self._logger.debug("All channels/internal phase generators synchronized.")
    self._error_check()
trigger_now(channel=None)
Source code in pytestlab/instruments/WaveformGenerator.py
def trigger_now(self, channel: int | str | None = None) -> None:
    if channel is not None:
        ch = self._validate_channel(channel)
        self._send_command(f"TRIGger{ch}")
        self._logger.debug(f"Sent immediate channel-specific trigger command TRIGger{ch}")
    else:
        self._send_command("*TRG")
        self._logger.debug("Sent general bus trigger command *TRG")
    self._error_check()

Multimeter

pytestlab.instruments.Multimeter

Attributes

logger = get_logger(__name__) module-attribute

Classes

Multimeter(config, backend, **kwargs)

Bases: Instrument[MultimeterConfig]

Drives a Digital Multimeter (DMM) for various measurements.

This class provides a high-level interface for controlling a DMM, building upon the base Instrument class. It includes methods for common DMM operations such as measuring voltage, current, resistance, and frequency. It also handles instrument-specific configurations and can incorporate measurement uncertainty based on the provided configuration.

ATTRIBUTE DESCRIPTION
config

The Pydantic configuration object (MultimeterConfig) containing settings specific to this DMM.

TYPE: MultimeterConfig

Source code in pytestlab/instruments/instrument.py
def __init__(self, config: ConfigType, backend: InstrumentIO, **kwargs: Any) -> None:
    """
    Initialize the Instrument class.

    Args:
        config (ConfigType): Configuration for the instrument.
        backend (InstrumentIO): The communication backend instance.
        **kwargs: Additional keyword arguments.
    """
    if not isinstance(config, InstrumentConfig):  # Check against the bound base
        raise InstrumentConfigurationError(
            self.__class__.__name__,
            f"A valid InstrumentConfig-compatible object must be provided, but got {type(config).__name__}.",
        )

    self.config = config
    self._backend = backend
    self._command_log = []

    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    self._logger = get_logger(logger_name)

    self._logger.info(
        f"Instrument '{logger_name}': Initializing with backend '{type(backend).__name__}'."
    )
    # Get SCPI data and convert to compatible format
    if hasattr(self.config, "scpi") and self.config.scpi is not None:
        if hasattr(self.config.scpi, "model_dump"):
            scpi_section = self.config.scpi.model_dump()
        else:
            scpi_section = {}
    else:
        scpi_section = {}
    self.scpi_engine = SCPIEngine(scpi_section)
Attributes
config instance-attribute
Functions
configure_measurement(function, range_val=None, resolution=None)

Configures the instrument for a measurement without triggering it.

Source code in pytestlab/instruments/Multimeter.py
def configure_measurement(
    self, function: DMMFunction, range_val: str | None = None, resolution: str | None = None
):
    """Configures the instrument for a measurement without triggering it."""
    scpi_function_val = function.value
    range_for_query = range_val.upper() if range_val is not None else "AUTO"
    resolution_for_query = resolution.upper() if resolution is not None else "DEF"
    try:
        cmds = self.scpi_engine.build(
            "configure",
            function=scpi_function_val,
            range=range_for_query,
            resolution=resolution_for_query,
        )
    except Exception as e:
        from ..errors import InstrumentConfigurationError

        raise InstrumentConfigurationError(
            self.config.model, "Missing SCPI alias 'configure' in YAML profile."
        ) from e
    for c in cmds:
        self._send_command(c)
    self._logger.info(
        f"Configured DMM for {function} with range={range_for_query}, resolution={resolution_for_query}"
    )
from_config(config, debug_mode=False) classmethod
Source code in pytestlab/instruments/Multimeter.py
@classmethod
def from_config(
    cls: type["Multimeter"], config: InstrumentConfig, debug_mode: bool = False
) -> "Multimeter":
    # This method is generally handled by the `AutoInstrument` factory.
    # It's provided here for completeness but direct instantiation is preferred
    # when not using the factory.
    # If config is a dict that needs to be passed to MultimeterConfig constructor:
    # return cls(config=MultimeterConfig(**config), debug_mode=debug_mode)
    # If config is already a MultimeterConfig instance:
    # Creation of concrete instrument drivers is handled by AutoInstrument.from_config().
    # Keep this stub for legacy API compatibility while matching the base signature expectations.
    raise NotImplementedError(
        "Instantiate via AutoInstrument.from_config(); direct construction is disabled."
    )
get_config()

Retrieves the current measurement configuration from the DMM.

This method queries the instrument to determine its current settings, such as the active measurement function, range, and resolution. It then parses this information into a structured MultimeterConfigResult object.

RETURNS DESCRIPTION
MultimeterConfigResult

A MultimeterConfigResult dataclass instance with the DMM's current

MultimeterConfigResult

configuration.

RAISES DESCRIPTION
InstrumentDataError

If the configuration string from the DMM cannot be parsed.

Source code in pytestlab/instruments/Multimeter.py
def get_config(self) -> MultimeterConfigResult:
    """Retrieves the current measurement configuration from the DMM.

    This method queries the instrument to determine its current settings,
    such as the active measurement function, range, and resolution. It then
    parses this information into a structured `MultimeterConfigResult` object.

    Returns:
        A `MultimeterConfigResult` dataclass instance with the DMM's current
        configuration.

    Raises:
        InstrumentDataError: If the configuration string from the DMM
                             cannot be parsed.
    """
    # Query the instrument for its current configuration. The response is typically
    # a string like '"VOLT:DC 10,0.0001"'.
    try:
        config_str: str = (
            (self._query(self.scpi_engine.build("get_config")[0])).replace('"', "").strip()
        )
    except Exception as e:
        from ..errors import InstrumentConfigurationError

        raise InstrumentConfigurationError(
            self.config.model, "Missing SCPI alias 'get_config' in YAML profile."
        ) from e
    try:
        # Handle cases where resolution is not returned, e.g., "FRES 1.000000E+02"
        parts = config_str.split()
        mode_part = parts[0]

        # Settings part can be complex, find first comma
        settings_part = " ".join(parts[1:])
        if "," in settings_part:
            range_str, resolution_str = settings_part.split(",", 1)
        else:
            range_str = settings_part
            resolution_str = "N/A"  # Resolution not specified in query response

        # Parse the string to extract the mode, range, and resolution.
        range_value_float: float = float(range_str)
    except (ValueError, IndexError) as e:
        raise InstrumentDataError(
            self.config.model, f"Failed to parse configuration string: '{config_str}'"
        ) from e

    # Determine human-friendly measurement mode and assign units based on mode
    measurement_mode_str: str = ""  # Renamed
    unit_str: str = ""  # Renamed
    mode_upper: str = mode_part.upper()
    if mode_upper.startswith("VOLT"):
        measurement_mode_str = "Voltage"
        unit_str = "V"
    elif mode_upper.startswith("CURR"):
        measurement_mode_str = "Current"
        unit_str = "A"
    elif "RES" in mode_upper:  # Catches RES and FRES
        measurement_mode_str = "Resistance"
        unit_str = "Ohm"
    elif "FREQ" in mode_upper:
        measurement_mode_str = "Frequency"
        unit_str = "Hz"
    elif mode_upper.startswith("TEMP"):
        measurement_mode_str = "Temperature"
        unit_str = "°C"  # Default; could also be °F depending on settings
    else:
        measurement_mode_str = mode_part

    return MultimeterConfigResult(
        measurement_mode=measurement_mode_str,
        range_value=range_value_float,
        resolution=resolution_str.strip(),
        units=unit_str,
    )
measure(function, range_val=None, resolution=None)

Executes a measurement and returns the result.

This method performs a measurement using the specified function, range, and resolution. It handles both autorange and fixed-range measurements, and incorporates measurement uncertainty based on the instrument's configuration.

PARAMETER DESCRIPTION
function

The measurement function to use (e.g., VOLTAGE_DC, CURRENT_AC).

TYPE: DMMFunction

range_val

The measurement range (e.g., "1V", "AUTO"). If not provided, "AUTO" is used. The value is validated against the ranges defined in the instrument's configuration.

TYPE: str | None DEFAULT: None

resolution

The desired resolution (e.g., "MIN", "MAX", "DEF"). If not provided, "DEF" (default) is used.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
MeasurementResult

A MeasurementResult object containing the measured value (as a float

MeasurementResult

or UFloat), units, and other metadata.

RAISES DESCRIPTION
InstrumentParameterError

If an unsupported range_val is provided.

Source code in pytestlab/instruments/Multimeter.py
def measure(
    self, function: DMMFunction, range_val: str | None = None, resolution: str | None = None
) -> MeasurementResult:
    """Executes a measurement and returns the result.

    This method performs a measurement using the specified function, range,
    and resolution. It handles both autorange and fixed-range measurements,
    and incorporates measurement uncertainty based on the instrument's
    configuration.

    Args:
        function: The measurement function to use (e.g., VOLTAGE_DC, CURRENT_AC).
        range_val: The measurement range (e.g., "1V", "AUTO"). If not provided,
                   "AUTO" is used. The value is validated against the ranges
                   defined in the instrument's configuration.
        resolution: The desired resolution (e.g., "MIN", "MAX", "DEF"). If not
                    provided, "DEF" (default) is used.

    Returns:
        A `MeasurementResult` object containing the measured value (as a float
        or `UFloat`), units, and other metadata.

    Raises:
        InstrumentParameterError: If an unsupported `range_val` is provided.
    """
    # Check for and clear any existing errors before measurement
    try:
        self.clear_status()
        errors = self.get_all_errors()
        if errors:
            self._logger.warning(
                f"Cleared {len(errors)} existing errors before measurement: {errors}"
            )
    except Exception as e:
        self._logger.warning(f"Failed to clear errors before measurement: {e}")

    # Verify instrument is responsive
    # Determine if ID preflight should run (avoid mocked SCPI identify alias)
    do_id_check = True
    try:
        ident_candidate = self.scpi_engine.build("identify")[0]
        if not isinstance(ident_candidate, str) or "IDN" not in ident_candidate.upper():
            do_id_check = False
    except Exception:
        # Leave do_id_check as True; id() will fallback to *IDN?
        pass
    try:
        if do_id_check:
            self.id()  # This should work if instrument is not in error state
    except Exception as e:
        self._logger.warning(f"Instrument not responsive before measurement: {e}")
        # Try to recover from error state
        if self.attempt_error_recovery():
            self._logger.info("Successfully recovered instrument from error state")
        else:
            self._logger.error("Failed to recover instrument from error state")
            raise InstrumentCommunicationError(
                instrument=self.config.model,
                message=f"Instrument not responsive and recovery failed: {e}",
            ) from e

    scpi_function_val = function.value
    is_autorange = range_val is None or range_val.upper() == "AUTO"

    # The MEASure command is a combination of CONFigure, INITiate, and FETCh.
    # This is convenient but makes querying the actual range used in autorange tricky.
    # For accurate uncertainty, we will use CONFigure separately when in autorange.
    if is_autorange:
        self.set_measurement_function(function)
        # Autorange ON
        try:
            cmds = self.scpi_engine.build(
                "set_range_auto", function=scpi_function_val, state=True
            )
        except Exception as e:
            from ..errors import InstrumentConfigurationError

            raise InstrumentConfigurationError(
                self.config.model, "Missing SCPI alias 'set_range_auto' in YAML profile."
            ) from e
        for c in cmds:
            self._send_command(c)

        # Resolution
        default_resolution = resolution.upper() if resolution is not None else "DEF"
        try:
            cmds = self.scpi_engine.build(
                "set_resolution", function=scpi_function_val, resolution=default_resolution
            )
        except Exception as e:
            from ..errors import InstrumentConfigurationError

            raise InstrumentConfigurationError(
                self.config.model, "Missing SCPI alias 'set_resolution' in YAML profile."
            ) from e
        for c in cmds:
            self._send_command(c)

        # Read
        try:
            q = self.scpi_engine.build("read")[0]
        except Exception as e:
            from ..errors import InstrumentConfigurationError

            raise InstrumentConfigurationError(
                self.config.model, "Missing SCPI alias 'read' in YAML profile."
            ) from e
        response_str = self._query(q)
    else:
        # Use the combined MEASure? command for fixed range
        range_for_query = range_val.upper() if range_val is not None else "AUTO"
        resolution_for_query = resolution.upper() if resolution is not None else "DEF"
        try:
            q = self.scpi_engine.build(
                "measure",
                function=scpi_function_val,
                range=range_for_query,
                resolution=resolution_for_query,
            )[0]
        except Exception as e:
            from ..errors import InstrumentConfigurationError

            raise InstrumentConfigurationError(
                self.config.model, "Missing SCPI alias 'measure' in YAML profile."
            ) from e
        self._logger.debug(f"Executing DMM measure query via SCPIEngine: {q}")
        response_str = self._query(q)

    try:
        reading = float(response_str)
    except ValueError as e:
        raise InstrumentDataError(
            self.config.model, f"Could not parse measurement reading: '{response_str}'"
        ) from e

    value_to_return: float | UFloat = reading

    # --- Uncertainty Calculation ---
    function_spec = self._get_function_spec(function)
    if function_spec:
        try:
            # Determine the actual range used by the instrument to find the correct spec
            current_instrument_config = self.get_config()
            actual_instrument_range = current_instrument_config.range_value
            self._logger.debug(f"Actual instrument range: {actual_instrument_range}")

            # Find the matching range specification
            matching_range_spec = None
            # Find the smallest nominal range that is >= the actual range used.
            # Assumes specs in YAML are sorted by nominal value, which is typical.
            if function_spec.ranges:
                self._logger.debug(f"Found {len(function_spec.ranges)} range specifications")
                # Debug: show the structure of the first range spec
                if function_spec.ranges:
                    first_range = function_spec.ranges[0]
                    self._logger.debug(f"First range spec structure: {first_range}")
                    self._logger.debug(f"First range spec dir: {dir(first_range)}")
                    self._logger.debug(
                        f"First range spec dict: {first_range.__dict__ if hasattr(first_range, '__dict__') else 'No __dict__'}"
                    )

                # Handle different range field names based on function type
                range_field = None
                # Map function types to their corresponding range field names
                if function == DMMFunction.VOLTAGE_DC or function == DMMFunction.VOLTAGE_AC:
                    if hasattr(function_spec.ranges[0], "nominal_V"):
                        range_field = "nominal_V"
                elif function == DMMFunction.CURRENT_DC or function == DMMFunction.CURRENT_AC:
                    if hasattr(function_spec.ranges[0], "nominal_A"):
                        range_field = "nominal_A"
                elif function == DMMFunction.RESISTANCE or function == DMMFunction.FRESISTANCE:
                    if hasattr(function_spec.ranges[0], "nominal_ohm"):
                        range_field = "nominal_ohm"
                elif function == DMMFunction.CAPACITANCE:
                    if hasattr(function_spec.ranges[0], "nominal_F"):
                        range_field = "nominal_F"

                # Fallback to generic fields if specific ones aren't found
                if range_field is None:
                    if hasattr(function_spec.ranges[0], "max"):
                        range_field = "max"
                    elif hasattr(function_spec.ranges[0], "max_val"):
                        range_field = "max_val"

                self._logger.debug(f"Using range field: {range_field}")

                if range_field:
                    try:
                        # Filter out ranges with None values and provide defaults
                        valid_ranges = []
                        for r in function_spec.ranges:
                            range_value = getattr(r, range_field, None)
                            if range_value is not None:
                                valid_ranges.append((r, range_value))
                            else:
                                self._logger.warning(
                                    f"Range spec {r} has None value for {range_field}"
                                )

                        if valid_ranges:
                            # Sort by range value
                            valid_ranges.sort(key=lambda x: x[1])
                            sorted_ranges = [r for r, _ in valid_ranges]

                            for r_spec in sorted_ranges:
                                rng_val = getattr(r_spec, range_field, None)
                                if rng_val is None:
                                    continue
                                range_value = float(rng_val)
                                self._logger.debug(
                                    f"Checking range spec: {range_value} >= {actual_instrument_range}"
                                )
                                if range_value >= actual_instrument_range:
                                    matching_range_spec = r_spec
                                    self._logger.debug(
                                        f"Found matching range spec: {range_value}"
                                    )
                                    break

                            # Fallback to the largest range if no suitable one is found
                            if not matching_range_spec:
                                matching_range_spec = max(valid_ranges, key=lambda x: x[1])[0]
                                self._logger.debug(
                                    f"Using fallback range spec: {getattr(matching_range_spec, range_field, 0)}"
                                )
                        else:
                            self._logger.warning(
                                f"No valid ranges found for field {range_field}"
                            )

                    except Exception as sort_error:
                        self._logger.error(f"Error during range sorting: {sort_error}")
                        # Fallback to first range if sorting fails
                        if function_spec.ranges:
                            matching_range_spec = function_spec.ranges[0]
                            self._logger.debug("Using fallback to first range spec")
            else:
                self._logger.debug("No range specifications found")

            if matching_range_spec:
                accuracy_spec = matching_range_spec.accuracy
                if accuracy_spec:
                    # Use the appropriate range field for the '% of range' calculation
                    range_for_calc = None
                    # Use the same field detection logic as above
                    if function == DMMFunction.VOLTAGE_DC or function == DMMFunction.VOLTAGE_AC:
                        range_for_calc = getattr(matching_range_spec, "nominal_V", None)
                    elif (
                        function == DMMFunction.CURRENT_DC or function == DMMFunction.CURRENT_AC
                    ):
                        range_for_calc = getattr(matching_range_spec, "nominal_A", None)
                    elif (
                        function == DMMFunction.RESISTANCE
                        or function == DMMFunction.FRESISTANCE
                    ):
                        range_for_calc = getattr(matching_range_spec, "nominal_ohm", None)
                    elif function == DMMFunction.CAPACITANCE:
                        range_for_calc = getattr(matching_range_spec, "nominal_F", None)

                    # Fallback to generic fields if specific ones aren't found
                    if range_for_calc is None:
                        if hasattr(matching_range_spec, "max"):
                            range_for_calc = matching_range_spec.max
                        elif hasattr(matching_range_spec, "max_val"):
                            range_for_calc = matching_range_spec.max_val

                    if range_for_calc is not None:
                        std_dev = accuracy_spec.calculate_std_dev(reading, range_for_calc)
                        if std_dev > 0:
                            value_to_return = ufloat(reading, std_dev)
                            self._logger.debug(
                                f"Applied accuracy spec for range {range_for_calc}, value: {value_to_return}"
                            )
                        else:
                            self._logger.debug(
                                "Calculated uncertainty is zero. Returning float."
                            )
                    else:
                        self._logger.warning(
                            "Could not determine range value for uncertainty calculation. Returning float."
                        )
                else:
                    self._logger.warning(
                        f"No applicable accuracy specification found for function '{function}' at range {actual_instrument_range}. Returning float."
                    )
            else:
                self._logger.warning(
                    f"Could not find a matching range specification for function '{function}' at range {actual_instrument_range}. Returning float."
                )

        except Exception as e:
            self._logger.error(f"Error during uncertainty calculation: {e}. Returning float.")
    else:
        self._logger.debug(
            f"No measurement function specification in config for '{function}'. Returning float."
        )

    units_val, measurement_name_val = self._get_measurement_unit_and_type(function)

    return MeasurementResult(
        values=value_to_return,
        instrument=self.config.model,
        units=units_val,
        measurement_type=measurement_name_val,
    )
set_measurement_function(function)

Configures the primary measurement function of the DMM.

This method sets the DMM to measure a specific quantity, such as DC Voltage, AC Current, or Resistance.

PARAMETER DESCRIPTION
function

The desired measurement function, as defined by the DMMFunction enum.

TYPE: DMMFunction

Source code in pytestlab/instruments/Multimeter.py
def set_measurement_function(self, function: DMMFunction) -> None:
    """Configures the primary measurement function of the DMM.

    This method sets the DMM to measure a specific quantity, such as DC
    Voltage, AC Current, or Resistance.

    Args:
        function: The desired measurement function, as defined by the
                  `DMMFunction` enum.
    """
    try:
        cmds = self.scpi_engine.build("set_function", function=function.value)
    except Exception as e:
        from ..errors import InstrumentConfigurationError

        raise InstrumentConfigurationError(
            self.config.model, "Missing SCPI alias 'set_function' in YAML profile."
        ) from e
    for c in cmds:
        self._send_command(c)
    self._logger.info(f"Set measurement function to {function} via SCPIEngine")
set_trigger_source(source)

Sets the trigger source for initiating a measurement.

The trigger source determines what event will cause the DMM to start taking a reading. - "IMM": Immediate, the DMM triggers as soon as it's ready. - "EXT": External, a hardware signal on the rear panel triggers the DMM. - "BUS": A software command (*TRG) triggers the DMM.

PARAMETER DESCRIPTION
source

The desired trigger source.

TYPE: Literal['IMM', 'EXT', 'BUS']

Source code in pytestlab/instruments/Multimeter.py
def set_trigger_source(self, source: Literal["IMM", "EXT", "BUS"]) -> None:
    """Sets the trigger source for initiating a measurement.

    The trigger source determines what event will cause the DMM to start
    taking a reading.
    - "IMM": Immediate, the DMM triggers as soon as it's ready.
    - "EXT": External, a hardware signal on the rear panel triggers the DMM.
    - "BUS": A software command (`*TRG`) triggers the DMM.

    Args:
        source: The desired trigger source.
    """
    try:
        cmds = self.scpi_engine.build("set_trigger_source", source=source)
    except Exception as e:
        from ..errors import InstrumentConfigurationError

        raise InstrumentConfigurationError(
            self.config.model, "Missing SCPI alias 'set_trigger_source' in YAML profile."
        ) from e
    for c in cmds:
        self._send_command(c)
    self._logger.info(f"Set trigger source to {source} via SCPIEngine")

MultimeterConfigResult(measurement_mode, range_value, resolution, units='') dataclass

Stores the current measurement configuration of the multimeter.

This data class holds the state of the multimeter's configuration at a point in time, such as the measurement mode, range, and resolution. It is typically returned by methods that query the instrument's status.

ATTRIBUTE DESCRIPTION
measurement_mode

The type of measurement being made (e.g., "Voltage").

TYPE: str

range_value

The configured measurement range.

TYPE: float

resolution

The configured resolution.

TYPE: str

units

The units for the measurement range (e.g., "V", "A").

TYPE: str

Attributes
measurement_mode instance-attribute
range_value instance-attribute
resolution instance-attribute
units = '' class-attribute instance-attribute
Functions
__str__()
Source code in pytestlab/instruments/Multimeter.py
def __str__(self) -> str:
    return (
        f"Measurement Mode: {self.measurement_mode}\n"
        f"Range: {self.range_value} {self.units}\n"
        f"Resolution: {self.resolution}"
    )

Functions

DC Active Load

pytestlab.instruments.DCActiveLoad

Instrument driver for a DC Active Load. Provides methods to set the operating mode, program the load value, enable/disable the output, and query measurements (current, voltage, power) from the Keysight EL30000 Series bench DC electronic loads.

Classes

DCActiveLoad(config, backend, **kwargs)

Bases: Instrument

Drives a DC Electronic Load instrument, such as the Keysight EL30000 series.

This class provides a driver for controlling a DC Active Load, enabling programmatic control over its operating modes and settings. It is designed to work with SCPI-compliant instruments and leverages a detailed Pydantic configuration model to provide uncertainty-aware measurements and feature-rich control.

The driver supports the following primary operations: - Setting the operating mode (Constant Current, Voltage, Power, Resistance). - Programming the load value for the current mode. - Enabling or disabling the load's input. - Measuring voltage, current, and power with uncertainty. - Configuring and controlling transient and battery test modes.

Source code in pytestlab/instruments/DCActiveLoad.py
def __init__(self, config: DCActiveLoadConfig, backend: InstrumentIO, **kwargs: Any) -> None:
    super().__init__(config, backend, **kwargs)
    self.current_mode = None
Attributes
config instance-attribute
current_mode = None class-attribute instance-attribute
Functions
configure_transient_mode(mode, channel=1)

Sets the operating mode of the transient generator.

Source code in pytestlab/instruments/DCActiveLoad.py
def configure_transient_mode(
    self, mode: Literal["CONTinuous", "PULSe", "TOGGle", "LIST"], channel: int = 1
) -> None:
    """Sets the operating mode of the transient generator."""
    for cmd in self.scpi_engine.build("transient_set_mode", mode=mode.upper(), channel=channel):
        self._send_command(cmd)
enable_battery_test(state, channel=1)

Enables or disables the battery test operation.

Source code in pytestlab/instruments/DCActiveLoad.py
def enable_battery_test(self, state: bool, channel: int = 1) -> None:
    """Enables or disables the battery test operation."""
    for cmd in self.scpi_engine.build("battery_enable", state=state, channel=channel):
        self._send_command(cmd)
enable_input(state, channel=1)

Enables or disables the load's input.

PARAMETER DESCRIPTION
state

True to enable the input, False to disable.

TYPE: bool

channel

The channel to control (default is 1).

TYPE: int DEFAULT: 1

Source code in pytestlab/instruments/DCActiveLoad.py
def enable_input(self, state: bool, channel: int = 1) -> None:
    """Enables or disables the load's input.

    Args:
        state: True to enable the input, False to disable.
        channel: The channel to control (default is 1).
    """
    for cmd in self.scpi_engine.build("set_input_state", state=state, channel=channel):
        self._send_command(cmd)
    self._logger.info(f"Input on channel {channel} turned {'ON' if state else 'OFF'}.")
fetch_datalogger_data(num_points, channel=1)

Fetches the specified number of logged data points.

Source code in pytestlab/instruments/DCActiveLoad.py
def fetch_datalogger_data(self, num_points: int, channel: int = 1) -> list[float]:
    """Fetches the specified number of logged data points."""
    q = self.scpi_engine.build("fetch_datalogger", points=num_points, channel=channel)[0]
    resp = self._query(q)
    return list(self.scpi_engine.parse("fetch_datalogger", resp))
fetch_scope_data(measurement, channel=1)

Fetches the captured waveform (scope) data as a NumPy array.

Source code in pytestlab/instruments/DCActiveLoad.py
def fetch_scope_data(
    self, measurement: Literal["current", "voltage", "power"], channel: int = 1
) -> np.ndarray:
    """Fetches the captured waveform (scope) data as a NumPy array."""
    # Removed unused scpi_map (mapping was unused).
    raw_block = self._query_raw(
        self.scpi_engine.build("fetch_array", quantity=measurement, channel=channel)[0]
    )
    data_bytes = self.scpi_engine.parse("fetch_array", raw_block)
    return np.frombuffer(data_bytes, dtype=np.float32)
from_config(config, debug_mode=False) classmethod

Factory method aligning with base Instrument.from_config signature. Backend selection is handled by the factory layer; direct use constructs the driver only.

Source code in pytestlab/instruments/DCActiveLoad.py
@classmethod
def from_config(
    cls: type[DCActiveLoad], config: InstrumentConfig, debug_mode: bool = False
) -> DCActiveLoad:
    """
    Factory method aligning with base Instrument.from_config signature.
    Backend selection is handled by the factory layer; direct use constructs the driver only.
    """
    if not isinstance(config, DCActiveLoadConfig):
        config = DCActiveLoadConfig(**dict(config))

    # Provide a minimal no-op backend to satisfy typing when constructing directly.
    class _NoopBackend:
        def connect(self) -> None:
            return None

        def disconnect(self) -> None:
            return None

        def write(self, cmd: str) -> None:
            return None

        def query(self, cmd: str, delay: float | None = None) -> str:
            return ""

        def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
            return b""

        def close(self) -> None:
            return None

        def set_timeout(self, timeout_ms: int) -> None:
            return None

        def get_timeout(self) -> int:
            return 5000

    return cls(config=config, backend=_NoopBackend(), debug_mode=debug_mode)
get_battery_test_measurement(metric, channel=1)

Queries a measurement from the ongoing battery test.

Source code in pytestlab/instruments/DCActiveLoad.py
def get_battery_test_measurement(
    self, metric: Literal["capacity", "power", "time"], channel: int = 1
) -> float:
    """Queries a measurement from the ongoing battery test."""
    q = self.scpi_engine.build("battery_measure", metric=metric, channel=channel)[0]
    return float(self.scpi_engine.parse("battery_measure", self._query(q)))
health_check()

Performs a health check on the DC Electronic Load.

Source code in pytestlab/instruments/DCActiveLoad.py
def health_check(self) -> HealthReport:
    """Performs a health check on the DC Electronic Load."""
    report = HealthReport()
    try:
        report.instrument_idn = self.id()
        errors = self.get_all_errors()
        if errors:
            report.status = HealthStatus.WARNING
            report.warnings.extend([f"Stored Error: {code} - {msg}" for code, msg in errors])
        else:
            report.status = HealthStatus.OK
    except Exception as e:
        report.status = HealthStatus.ERROR
        report.errors.append(f"Health check failed: {e}")
    return report
is_input_enabled(channel=1)

Queries the state of the load's input.

RETURNS DESCRIPTION
bool

True if the input is enabled, False otherwise.

Source code in pytestlab/instruments/DCActiveLoad.py
def is_input_enabled(self, channel: int = 1) -> bool:
    """Queries the state of the load's input.

    Returns:
        True if the input is enabled, False otherwise.
    """
    q = self.scpi_engine.build("get_input_state", channel=channel)[0]
    response = self._query(q)
    s = response.strip().upper()
    return s in ("1", "ON", "TRUE")
measure_current()

Measures the sinking current, including uncertainty if available.

Source code in pytestlab/instruments/DCActiveLoad.py
def measure_current(self) -> MeasurementResult:
    """Measures the sinking current, including uncertainty if available."""
    return self._measure_with_uncertainty("current")
measure_power()

Measures the power being dissipated, including uncertainty if available.

Source code in pytestlab/instruments/DCActiveLoad.py
def measure_power(self) -> MeasurementResult:
    """Measures the power being dissipated, including uncertainty if available."""
    return self._measure_with_uncertainty("power")
measure_voltage()

Measures the voltage across the load, including uncertainty if available.

Source code in pytestlab/instruments/DCActiveLoad.py
def measure_voltage(self) -> MeasurementResult:
    """Measures the voltage across the load, including uncertainty if available."""
    return self._measure_with_uncertainty("voltage")
set_battery_cutoff_capacity(capacity, state=True, channel=1)

Configures the capacity (Ah) cutoff condition for the battery test.

Source code in pytestlab/instruments/DCActiveLoad.py
def set_battery_cutoff_capacity(
    self, capacity: float, state: bool = True, channel: int = 1
) -> None:
    """Configures the capacity (Ah) cutoff condition for the battery test."""
    for cmd in self.scpi_engine.build(
        "battery_cutoff_capacity_state", state=state, channel=channel
    ):
        self._send_command(cmd)
    if state:
        for cmd in self.scpi_engine.build(
            "battery_cutoff_capacity", capacity=capacity, channel=channel
        ):
            self._send_command(cmd)
set_battery_cutoff_timer(time_s, state=True, channel=1)

Configures the timer (seconds) cutoff condition for the battery test.

Source code in pytestlab/instruments/DCActiveLoad.py
def set_battery_cutoff_timer(self, time_s: float, state: bool = True, channel: int = 1) -> None:
    """Configures the timer (seconds) cutoff condition for the battery test."""
    for cmd in self.scpi_engine.build(
        "battery_cutoff_timer_state", state=state, channel=channel
    ):
        self._send_command(cmd)
    if state:
        for cmd in self.scpi_engine.build(
            "battery_cutoff_timer", time_s=time_s, channel=channel
        ):
            self._send_command(cmd)
set_battery_cutoff_voltage(voltage, state=True, channel=1)

Configures the voltage cutoff condition for the battery test.

Source code in pytestlab/instruments/DCActiveLoad.py
def set_battery_cutoff_voltage(
    self, voltage: float, state: bool = True, channel: int = 1
) -> None:
    """Configures the voltage cutoff condition for the battery test."""
    for cmd in self.scpi_engine.build(
        "battery_cutoff_voltage_state", state=state, channel=channel
    ):
        self._send_command(cmd)
    if state:
        for cmd in self.scpi_engine.build(
            "battery_cutoff_voltage", voltage=voltage, channel=channel
        ):
            self._send_command(cmd)
set_load(value)

Programs the load's setpoint for the current operating mode.

This method sets the target value that the load will maintain. The unit of the value argument depends on the currently active mode: - "CC" mode: value is in Amperes (A). - "CV" mode: value is in Volts (V). - "CP" mode: value is in Watts (W). - "CR" mode: value is in Ohms (Ω).

PARAMETER DESCRIPTION
value

The target value for the load.

TYPE: float

RAISES DESCRIPTION
InstrumentParameterError

If the operating mode has not been set first by calling set_mode().

Source code in pytestlab/instruments/DCActiveLoad.py
def set_load(self, value: float) -> None:
    """Programs the load's setpoint for the current operating mode.

    This method sets the target value that the load will maintain. The unit
    of the `value` argument depends on the currently active mode:
    - "CC" mode: `value` is in Amperes (A).
    - "CV" mode: `value` is in Volts (V).
    - "CP" mode: `value` is in Watts (W).
    - "CR" mode: `value` is in Ohms (Ω).

    Args:
        value: The target value for the load.

    Raises:
        InstrumentParameterError: If the operating mode has not been set first
                                  by calling `set_mode()`.
    """
    if self.current_mode is None:
        raise InstrumentParameterError("Load mode has not been set. Call set_mode() first.")

    command_map = {
        "CC": ("set_current_level", "CURRent"),
        "CV": ("set_voltage_level", "VOLTage"),
        "CP": ("set_power_level", "POWer"),
        "CR": ("set_resistance_level", "RESistance"),
    }
    mapping = command_map.get(self.current_mode)

    if mapping:
        cmd_name, _legacy_prefix = mapping
        for cmd in self.scpi_engine.build(cmd_name, value=value):
            self._send_command(cmd)
        self._logger.info(
            f"Load value set to {value} in mode {self.current_mode} via SCPIEngine."
        )
    else:
        raise InstrumentParameterError(
            f"Internal error: Unknown current_mode '{self.current_mode}'."
        )
set_mode(mode)

Sets the operating mode of the electronic load.

This method configures the load to operate in one of the supported modes. The mode determines what physical quantity the load will attempt to keep constant.

The supported modes are: - "CC": Constant Current - "CV": Constant Voltage - "CP": Constant Power - "CR": Constant Resistance

PARAMETER DESCRIPTION
mode

The desired operating mode. The input is case-insensitive.

TYPE: str

RAISES DESCRIPTION
InstrumentParameterError

If the specified mode is not supported.

Source code in pytestlab/instruments/DCActiveLoad.py
def set_mode(self, mode: str) -> None:
    """Sets the operating mode of the electronic load.

    This method configures the load to operate in one of the supported modes.
    The mode determines what physical quantity the load will attempt to keep
    constant.

    The supported modes are:
    - "CC": Constant Current
    - "CV": Constant Voltage
    - "CP": Constant Power
    - "CR": Constant Resistance

    Args:
        mode: The desired operating mode. The input is case-insensitive.

    Raises:
        InstrumentParameterError: If the specified mode is not supported.
    """
    mode_upper = mode.upper()
    valid_modes = ["CC", "CV", "CP", "CR"]
    if mode_upper not in valid_modes:
        raise InstrumentParameterError(
            parameter="mode",
            value=mode,
            valid_range=valid_modes,
            message=f"Unsupported mode '{mode}'. Valid modes are: {', '.join(valid_modes)}.",
        )
    for cmd in self.scpi_engine.build("set_mode", mode=mode_upper):
        self._send_command(cmd)
    self.current_mode = mode_upper
    self._logger.info(f"Operating mode set to {mode_upper} via SCPIEngine.")
set_range(value, channel=1)

Sets the operating range for the current mode.

PARAMETER DESCRIPTION
value

The maximum expected value to set the range. Can also be "MIN" or "MAX".

TYPE: float | str

channel

The channel to configure (default is 1).

TYPE: int DEFAULT: 1

Source code in pytestlab/instruments/DCActiveLoad.py
def set_range(self, value: float | str, channel: int = 1) -> None:
    """Sets the operating range for the current mode.

    Args:
        value: The maximum expected value to set the range. Can also be "MIN" or "MAX".
        channel: The channel to configure (default is 1).
    """
    if self.current_mode is None:
        raise InstrumentParameterError("Mode must be set before setting range.")
    for cmd in self.scpi_engine.build(
        "mode_set_range", mode=self.current_mode, value=value, channel=channel
    ):
        self._send_command(cmd)
    self._logger.info(
        f"Range for mode {self.current_mode} on channel {channel} set for value {value}."
    )
set_slew_rate(rate, channel=1)

Sets the slew rate for the current operating mode.

PARAMETER DESCRIPTION
rate

The desired slew rate. Units depend on the mode (A/s, V/s, etc.). Can also be "MIN", "MAX", or "INF".

TYPE: float | str

channel

The channel to configure (default is 1).

TYPE: int DEFAULT: 1

Source code in pytestlab/instruments/DCActiveLoad.py
def set_slew_rate(self, rate: float | str, channel: int = 1) -> None:
    """Sets the slew rate for the current operating mode.

    Args:
        rate: The desired slew rate. Units depend on the mode (A/s, V/s, etc.).
              Can also be "MIN", "MAX", or "INF".
        channel: The channel to configure (default is 1).
    """
    if self.current_mode is None:
        raise InstrumentParameterError("Mode must be set before setting slew rate.")

    for cmd in self.scpi_engine.build(
        "mode_set_slew", mode=self.current_mode, rate=rate, channel=channel
    ):
        self._send_command(cmd)
    self._logger.info(
        f"Slew rate for mode {self.current_mode} on channel {channel} set to {rate}."
    )
set_transient_level(value, channel=1)

Sets the secondary (transient) level for the current operating mode.

Source code in pytestlab/instruments/DCActiveLoad.py
def set_transient_level(self, value: float, channel: int = 1) -> None:
    """Sets the secondary (transient) level for the current operating mode."""
    if self.current_mode is None:
        raise InstrumentParameterError("Mode must be set before setting transient level.")
    for cmd in self.scpi_engine.build(
        "transient_set_level", mode=self.current_mode, value=value, channel=channel
    ):
        self._send_command(cmd)
short_input(state, channel=1)

Enables or disables a short circuit on the input.

PARAMETER DESCRIPTION
state

True to enable the short, False to disable.

TYPE: bool

channel

The channel to control (default is 1).

TYPE: int DEFAULT: 1

Source code in pytestlab/instruments/DCActiveLoad.py
def short_input(self, state: bool, channel: int = 1) -> None:
    """Enables or disables a short circuit on the input.

    Args:
        state: True to enable the short, False to disable.
        channel: The channel to control (default is 1).
    """
    for cmd in self.scpi_engine.build("input_short_state", state=state, channel=channel):
        self._send_command(cmd)
    self._logger.info(f"Input short on channel {channel} turned {'ON' if state else 'OFF'}.")
start_transient(continuous=False, channel=1)

Initiates the transient trigger system.

Source code in pytestlab/instruments/DCActiveLoad.py
def start_transient(self, continuous: bool = False, channel: int = 1) -> None:
    """Initiates the transient trigger system."""
    for cmd in self.scpi_engine.build(
        "transient_start", continuous=continuous, channel=channel
    ):
        self._send_command(cmd)
stop_transient(channel=1)

Aborts any pending or in-progress transient operations.

Source code in pytestlab/instruments/DCActiveLoad.py
def stop_transient(self, channel: int = 1) -> None:
    """Aborts any pending or in-progress transient operations."""
    for cmd in self.scpi_engine.build("transient_abort", channel=channel):
        self._send_command(cmd)

Spectrum Analyzer

pytestlab.instruments.SpectrumAnalyser

Classes

PlaceholderMeasurementResult(x, y, x_label='Frequency (Hz)', y_label='Amplitude (dBm)')

Source code in pytestlab/instruments/SpectrumAnalyser.py
def __init__(
    self,
    x: list[float],
    y: list[float],
    x_label: str = "Frequency (Hz)",
    y_label: str = "Amplitude (dBm)",
):
    self.x = x
    self.y = y
    self.x_label = x_label
    self.y_label = y_label
Attributes
x = x instance-attribute
x_label = x_label instance-attribute
y = y instance-attribute
y_label = y_label instance-attribute
Functions

SpectrumAnalyser(config, backend, **kwargs)

Bases: Instrument[SpectrumAnalyzerConfig]

Source code in pytestlab/instruments/instrument.py
def __init__(self, config: ConfigType, backend: InstrumentIO, **kwargs: Any) -> None:
    """
    Initialize the Instrument class.

    Args:
        config (ConfigType): Configuration for the instrument.
        backend (InstrumentIO): The communication backend instance.
        **kwargs: Additional keyword arguments.
    """
    if not isinstance(config, InstrumentConfig):  # Check against the bound base
        raise InstrumentConfigurationError(
            self.__class__.__name__,
            f"A valid InstrumentConfig-compatible object must be provided, but got {type(config).__name__}.",
        )

    self.config = config
    self._backend = backend
    self._command_log = []

    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    self._logger = get_logger(logger_name)

    self._logger.info(
        f"Instrument '{logger_name}': Initializing with backend '{type(backend).__name__}'."
    )
    # Get SCPI data and convert to compatible format
    if hasattr(self.config, "scpi") and self.config.scpi is not None:
        if hasattr(self.config.scpi, "model_dump"):
            scpi_section = self.config.scpi.model_dump()
        else:
            scpi_section = {}
    else:
        scpi_section = {}
    self.scpi_engine = SCPIEngine(scpi_section)
Functions
configure_measurement(center_freq=None, span=None, rbw=None)
Source code in pytestlab/instruments/SpectrumAnalyser.py
def configure_measurement(
    self, center_freq: float | None = None, span: float | None = None, rbw: float | None = None
) -> None:
    if center_freq is not None:
        self._send_command(f"FREQ:CENT {center_freq}")  # Use SCPI_MAP later
        self.config.frequency_center = center_freq  # Update config
    if span is not None:
        self._send_command(f"FREQ:SPAN {span}")
        self.config.frequency_span = span  # Update config
    if rbw is not None:
        self._send_command(f"BAND {rbw}")  # RBW command
        self.config.resolution_bandwidth = rbw  # Update config
get_trace(channel=1)
Source code in pytestlab/instruments/SpectrumAnalyser.py
def get_trace(
    self, channel: int = 1
) -> PlaceholderMeasurementResult:  # Use actual MeasurementResult later
    # Example: Query trace data, parse it (often CSV or binary)
    # raw_data_str = self._query(f"TRAC:DATA? TRACE{channel}") # Use SCPI_MAP
    # For simulation, SimBackend needs to be taught to respond to this
    # For now, return dummy data
    # freqs = [1e9, 2e9, 3e9] # Dummy frequencies
    # amps = [-20, -30, -25]  # Dummy amplitudes
    # return PlaceholderMeasurementResult(x=freqs, y=amps)
    self._logger.warning(
        "get_trace for SpectrumAnalyser is a placeholder and returns dummy data."
    )
    # Simulating a basic trace for now
    sim_freqs = [
        self.config.frequency_center
        or 1e9
        - (self.config.frequency_span or 100e6) / 2
        + i * ((self.config.frequency_span or 100e6) / 10)
        for i in range(11)
    ]
    sim_amps = [-20.0 - i * 2 for i in range(11)]  # Dummy amplitudes
    return PlaceholderMeasurementResult(x=sim_freqs, y=sim_amps)

Vector Network Analyzer (VNA)

pytestlab.instruments.VectorNetworkAnalyser

Classes

SParameterData(frequencies, s_params, param_names)

Source code in pytestlab/instruments/VectorNetworkAnalyser.py
def __init__(
    self, frequencies: list[float], s_params: list[list[complex]], param_names: list[str]
):
    self.frequencies = frequencies  # List of frequencies
    self.s_params = s_params  # List of lists, each inner list contains complex S-param values for a given S-parameter type
    self.param_names = param_names  # List of S-parameter names, e.g., ["S11", "S21"]
Attributes
frequencies = frequencies instance-attribute
param_names = param_names instance-attribute
s_params = s_params instance-attribute
Functions

VectorNetworkAnalyser(config, backend, **kwargs)

Bases: Instrument[VNAConfig]

Source code in pytestlab/instruments/instrument.py
def __init__(self, config: ConfigType, backend: InstrumentIO, **kwargs: Any) -> None:
    """
    Initialize the Instrument class.

    Args:
        config (ConfigType): Configuration for the instrument.
        backend (InstrumentIO): The communication backend instance.
        **kwargs: Additional keyword arguments.
    """
    if not isinstance(config, InstrumentConfig):  # Check against the bound base
        raise InstrumentConfigurationError(
            self.__class__.__name__,
            f"A valid InstrumentConfig-compatible object must be provided, but got {type(config).__name__}.",
        )

    self.config = config
    self._backend = backend
    self._command_log = []

    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    self._logger = get_logger(logger_name)

    self._logger.info(
        f"Instrument '{logger_name}': Initializing with backend '{type(backend).__name__}'."
    )
    # Get SCPI data and convert to compatible format
    if hasattr(self.config, "scpi") and self.config.scpi is not None:
        if hasattr(self.config.scpi, "model_dump"):
            scpi_section = self.config.scpi.model_dump()
        else:
            scpi_section = {}
    else:
        scpi_section = {}
    self.scpi_engine = SCPIEngine(scpi_section)
Attributes
model_config = {'arbitrary_types_allowed': True} class-attribute instance-attribute
Functions
configure_s_parameter_sweep(s_params=None, start_freq=None, stop_freq=None, num_points=None, if_bandwidth=None, power_level=None)
Source code in pytestlab/instruments/VectorNetworkAnalyser.py
def configure_s_parameter_sweep(
    self,
    s_params: list[str] | None = None,  # e.g. ["S11", "S21"]
    start_freq: float | None = None,
    stop_freq: float | None = None,
    num_points: int | None = None,
    if_bandwidth: float | None = None,
    power_level: float | None = None,
) -> None:
    if s_params is not None:
        # SCPI command to select S-parameters might be like: CALC:PAR:DEF "S11"
        # This is highly instrument specific. For now, just update config.
        self.config.s_parameters = s_params
        self._logger.info(f"VNA S-parameters set to: {s_params}")
    if start_freq is not None:
        self._send_command(f"SENS:FREQ:STAR {start_freq}")  # Example SCPI
        self.config.start_frequency = start_freq
    if stop_freq is not None:
        self._send_command(f"SENS:FREQ:STOP {stop_freq}")  # Example SCPI
        self.config.stop_frequency = stop_freq
    if num_points is not None:
        self._send_command(f"SENS:SWE:POIN {num_points}")  # Example SCPI
        self.config.num_points = num_points
    if if_bandwidth is not None:
        self._send_command(f"SENS:BWID {if_bandwidth}")  # Example SCPI for IF bandwidth
        self.config.if_bandwidth = if_bandwidth
    if power_level is not None:
        self._send_command(f"SOUR:POW {power_level}")  # Example SCPI for power
        self.config.power_level = power_level
    self._logger.info("VNA measurement configured (simulated).")
get_s_parameter_data()
Source code in pytestlab/instruments/VectorNetworkAnalyser.py
def get_s_parameter_data(self) -> SParameterData:
    # Example: Query S-parameter data. This is often complex, involving selecting
    # the S-parameter, then querying data (e.g., in Real, Imaginary or LogMag, Phase format).
    # raw_data_str = self._query(f"CALC:DATA? SDAT") # Example SCPI for S-parameter data
    # For simulation, SimBackend needs to be taught to respond.
    self._logger.warning(
        "get_s_parameter_data for VNA is a placeholder and returns dummy data."
    )

    num_points = self.config.num_points or 101
    start_f = self.config.start_frequency or 1e9
    stop_f = self.config.stop_frequency or 2e9

    frequencies = [
        start_f + i * (stop_f - start_f) / (num_points - 1 if num_points > 1 else 1)
        for i in range(num_points)
    ]

    s_params_to_measure = self.config.s_parameters or ["S11"]
    sim_s_params_data: list[list[complex]] = []

    for _ in s_params_to_measure:
        # Dummy data: e.g., S11 a simple reflection, S21 a simple transmission
        param_data = []
        for i in range(num_points):
            # Create some varying complex numbers
            real_part = -0.1 * i / num_points
            imag_part = -0.05 * (1 - i / num_points)
            param_data.append(complex(real_part, imag_part))
        sim_s_params_data.append(param_data)

    return SParameterData(
        frequencies=frequencies, s_params=sim_s_params_data, param_names=s_params_to_measure
    )

Power Meter

pytestlab.instruments.PowerMeter

Classes

PowerMeter(config, backend, **kwargs)

Bases: Instrument[PowerMeterConfig]

Drives a Power Meter instrument for power measurements.

This class provides a high-level interface for controlling a power meter, building upon the base Instrument class. It includes methods for configuring the power sensor and reading power values.

Source code in pytestlab/instruments/instrument.py
def __init__(self, config: ConfigType, backend: InstrumentIO, **kwargs: Any) -> None:
    """
    Initialize the Instrument class.

    Args:
        config (ConfigType): Configuration for the instrument.
        backend (InstrumentIO): The communication backend instance.
        **kwargs: Additional keyword arguments.
    """
    if not isinstance(config, InstrumentConfig):  # Check against the bound base
        raise InstrumentConfigurationError(
            self.__class__.__name__,
            f"A valid InstrumentConfig-compatible object must be provided, but got {type(config).__name__}.",
        )

    self.config = config
    self._backend = backend
    self._command_log = []

    logger_name = (
        self.config.model if hasattr(self.config, "model") else self.__class__.__name__
    )
    self._logger = get_logger(logger_name)

    self._logger.info(
        f"Instrument '{logger_name}': Initializing with backend '{type(backend).__name__}'."
    )
    # Get SCPI data and convert to compatible format
    if hasattr(self.config, "scpi") and self.config.scpi is not None:
        if hasattr(self.config.scpi, "model_dump"):
            scpi_section = self.config.scpi.model_dump()
        else:
            scpi_section = {}
    else:
        scpi_section = {}
    self.scpi_engine = SCPIEngine(scpi_section)
Functions
configure_sensor(channel=1, freq=None, averaging_count=None, units=None)

Configures the settings for a specific power sensor channel.

This method allows setting the frequency compensation, averaging count, and power units for the measurement.

PARAMETER DESCRIPTION
channel

The sensor channel number to configure (default is 1).

TYPE: int DEFAULT: 1

freq

The frequency compensation value in Hz.

TYPE: float | None DEFAULT: None

averaging_count

The number of measurements to average.

TYPE: int | None DEFAULT: None

units

The desired power units (e.g., "dBm", "W").

TYPE: str | None DEFAULT: None

Source code in pytestlab/instruments/PowerMeter.py
def configure_sensor(
    self,
    channel: int = 1,
    freq: float | None = None,
    averaging_count: int | None = None,
    units: str | None = None,
) -> None:
    """Configures the settings for a specific power sensor channel.

    This method allows setting the frequency compensation, averaging count,
    and power units for the measurement.

    Args:
        channel: The sensor channel number to configure (default is 1).
        freq: The frequency compensation value in Hz.
        averaging_count: The number of measurements to average.
        units: The desired power units (e.g., "dBm", "W").
    """
    # The specific SCPI commands can vary between power meter models.
    # The following are common examples.

    # Set the frequency compensation for the sensor.
    if freq is not None:
        self._send_command(f"SENS{channel}:FREQ {freq}")
        self.config.frequency_compensation_value = freq  # Update local config state

    # Set the number of readings to average.
    if averaging_count is not None:
        self._send_command(f"SENS{channel}:AVER:COUN {averaging_count}")
        self.config.averaging_count = averaging_count  # Update local config state

    # Set the units for the power measurement.
    if units is not None:
        # Validate against config-declared choices when available; otherwise accept.
        allowed: set[str] = set()
        field = PowerMeterConfig.model_fields.get("power_units")
        ann = getattr(field, "annotation", None)
        if ann is not None:
            try:
                args = get_args(ann)
                if args:
                    allowed = {str(x) for x in args}
            except Exception:
                allowed = set()

        if not allowed or units in allowed:
            self._send_command(f"UNIT:POW {units.upper()}")
            self.config.power_units = units  # type: ignore[assignment]
        else:
            self._logger.warning(
                f"Invalid power units '{units}' specified. Using config default '{self.config.power_units}'."
            )

    self._logger.info(f"Power meter sensor channel {channel} configured.")
read_power(channel=1)

Reads the power from a specified sensor channel.

This method queries the instrument for a power reading. Note that this is a placeholder implementation and currently returns simulated data.

PARAMETER DESCRIPTION
channel

The sensor channel number to read from (default is 1).

TYPE: int DEFAULT: 1

RETURNS DESCRIPTION
float

The measured power as a float. The units depend on the current

float

instrument configuration.

Source code in pytestlab/instruments/PowerMeter.py
def read_power(self, channel: int = 1) -> float:
    """Reads the power from a specified sensor channel.

    This method queries the instrument for a power reading. Note that this
    is a placeholder implementation and currently returns simulated data.

    Args:
        channel: The sensor channel number to read from (default is 1).

    Returns:
        The measured power as a float. The units depend on the current
        instrument configuration.
    """
    # In a real implementation, you would query the instrument.
    # Example: raw_power_str = self._query(f"FETC{channel}?")
    # The SimBackend would need to be configured to provide realistic responses.
    self._logger.warning(
        f"read_power for PowerMeter channel {channel} is a placeholder and returns dummy data."
    )

    # Simulate a power reading based on the configured units.
    sim_power = -10.0  # Default dummy power in dBm
    if self.config.power_units == "W":
        sim_power = 0.0001  # 100uW
    elif self.config.power_units == "mW":
        sim_power = 0.1  # 0.1mW
    elif self.config.power_units == "uW":
        sim_power = 100.0  # 100uW

    # For more realistic simulations, a small random variation could be added.
    # import random
    # sim_power *= (1 + random.uniform(-0.01, 0.01))

    return sim_power

Facade Pattern

All instrument drivers expose "facade" objects for common operations, enabling a fluent, chainable API. For example, you can configure and enable a channel with:

scope.channel(1).setup(scale=0.5, offset=0).enable()

See the 10-Minute Tour for practical examples.


Simulation Support

All drivers support simulation via the simulate=True flag or by using a simulated backend. See the Simulation Guide for details.


Extending Drivers

To add support for a new instrument, create a profile YAML file and use AutoInstrument.from_config() or subclass Instrument. See Creating Profiles for guidance.