Source code for FIREQ_SERVER.execution.sweep_experiment

"""Sweep experiment module.

This module implements the SweepExperiment class, which is responsible for executing multi-point sweep experiments on
the FIREQ system. It provides methods to parse sweep configurations, compute variable values, and execute the experiment
"""

import logging
import re
import copy
from queue import Queue

import numpy as np
from ..network import FIREQNetworkPacket
import time


[docs] class SweepExperiment: """Execute multi-sweep points using high-level callbacks. :param OPERATORS: string containings the operator characters available :type OPERATORS: str """ OPERATORS = "+-" # TODO: check if is necessary to constrain the operators def __init__(self, server, queue_out: Queue, logger: logging.Logger | None = None) -> None: """ Initialize with the sweeping expressions and variables values. :param node: object representing the FIREQ system :type node: FIREQSystemNode :param queue: queue istance :type queue: Queue """ # Fireq Node self._server = server self._queue_out = queue_out self.log = logger or logging.getLogger(__name__) # dict with all the computed sweeping variables self.computed_vars = {} self._current_sweep_point = {} def _check_sweep_expression(self, expr: str) -> None: """Check the sweep expressions for validity. Raise ValueError if the expression does not pass the check. The first character of the expression must be '#' and the rest of the expression must contain only valid operators. :param expr: expression to check :type expr: str """ if not expr.startswith("#"): raise ValueError(f"Invalid sweep expression: {expr}. Must start with '#'.") # check if the expression contains only valid operators (TODO: can be removed and method can be static) for char in expr[1:]: if not char.isalnum() and char not in {"_", " ", "(", ")"}: # check only special char if char not in self.OPERATORS: raise ValueError(f"Invalid operator used for the expression: '{char}'") @staticmethod def _get_vars(expr: str) -> tuple[str]: """Extract the involved varibles from expression. The expression shuold not cointain the first char '#' :param expr: expression :type str :return tuple with the involved variables :rtype tuple[str] """ return tuple(re.findall(r"[a-zA-Z_]\w*", expr)) def _parse_callbacks(self, callbacks: list) -> tuple[list[tuple[callable, str, tuple[str]]], list[str]]: """Parse the sweep configuration and fill sweep_cost dict and sweep_routine set. :param callbacks: configuration parameters for the experiment :type callbacks: dict :return: the sweep_routine (the callbacks to execute with expressions and involved variables) and vars_order (the variables to change in order of cost) :rtype: tuple[set[tuple[callable, str, tuple[str]]], list[str]] """ sweep_costs = {} sweep_routine = [] self.log.debug(f"{callbacks}") # identify the sweeping expressions compute cost and fill the expr_routine dict for callback, expr, cost in callbacks: # check expression validity self._check_sweep_expression(expr) # get the variables from the expression variables = self._get_vars(expr[1:]) # remove the '#' character and get the variables # update sweep_cost dict for var in variables: if var not in sweep_costs: sweep_costs[var] = cost else: sweep_costs[var] += cost # update sweep_routine sweep_routine.append((callback, compile(expr[1:], "compiled_expression", "eval"), variables)) # order the varibles based on their costs vars_order = sorted(sweep_costs, key=lambda x: sweep_costs[x], reverse=True) self.log.debug(f"Variable sweep order: {vars_order}") return sweep_routine, vars_order def _put_sweep_info_in_network(self, vars_order: list[str]) -> None: """Put the header on the queue with the variables order. :param vars_order: list of variables ordered by total cost :type vars_order: list[str] """ self._queue_out.put( FIREQNetworkPacket({"type": "sweep_experiment_header", "variables_order": copy.deepcopy(vars_order)}) ) @staticmethod def _compute_variable_values(var: dict[str, dict]) -> dict[str : np.ndarray]: """Compute all the values for the variables. The field 'mode' must be present and indicates how the values are computed - 'lin' mode: linear spacing -> require fields 'start' (first element), 'stop' (last element), 'num' (number of elements) - 'const' mode: constant value -> require field 'value' (constant and unique value) - 'list' list: list of values -> reuire field 'values' (list of elements) :param var: dict with variable and correspondent description :type var: dict[str: dict] :return: dict with sweeping values :rtype: dict[str: np.array] """ return_dict = {} # iterate through variables for var_name, var_description in var.items(): # check that the variable is not in the dict already if return_dict.get(var_name) is not None: raise KeyError(f"Variable '{var_name}' has already been defined earlier.") # get variable "mode" field, check if it exists mode = var_description.get("mode") # get mode if mode == "lin": try: return_dict[var_name] = np.linspace( var_description["start"], var_description["stop"], var_description["num"] ) except KeyError as e: raise KeyError( f"For 'lin' mode the keys 'start', 'stop' and 'num' must be present for '{var_name}' \ variable." ) from e except Exception as e: raise TypeError(f"Error during computing sweeping values for '{var_name}' variable: {e}") from e elif mode == "const": try: return_dict[var_name] = np.array([var_description["value"]]) except KeyError as e: raise KeyError(f"For 'const' mode the key 'value' must be present for '{var_name}' variable") from e except Exception as e: raise TypeError(f"Error during computing sweeping values for '{var_name}' variable: {e}") from e elif mode == "list": try: return_dict[var_name] = np.array(var_description["values"]) except KeyError as e: raise KeyError(f"For 'list' mode the key 'values' must be present for '{var_name}' variable") from e except Exception as e: raise TypeError(f"Error during computing sweeping values for '{var_name}' variable: {e}") from e elif mode is None: raise KeyError(f"For the sweeping variable '{var_name}' the 'mode' field is not present") else: raise ValueError(f"Sweeping spacing '{ var_description['mode'] }' not available") return return_dict def _nested_loop_recursive( self, sweep_routine: list, vars_order: list, depth: int = 0, accounted_callbacks: set = None ) -> None: """Iterate recursivelly through variables executing callbacks and finally run the experiment. :param dict_vars: dict with the value associated to each varible for the current step :type dict_vars: dict :param sweep_routine: sweep_routine dict for the current step :type sweep_routine: set :param vars_order: variables ordered by total cost :type vars_order: list :param iterating_vars: list of iterating variables for the current step :type iterating_vars: list """ if accounted_callbacks is None: accounted_callbacks = set() # get the name of the variable that is iterating in this loop iterating_var = vars_order[depth] # set of callback indices that are executing in this loop callbacks_executing = set() # add callbacks to execute in the loop callbacks = [] for index, (func, compiled, variable_dependencies) in enumerate(sweep_routine): # check if the callback has already been accounted for if index in accounted_callbacks: continue # check if the callback must be executed if set(variable_dependencies).issubset(vars_order[: depth + 1]): # expression to execute callbacks.append((func, compiled)) callbacks_executing.add(index) # iterate over the values of the variable for value in self.computed_vars[iterating_var]: # update the dict_vars with the current value of the variable self._current_sweep_point[iterating_var] = value # execute the callbacks for ll_callback, compiled_eval in callbacks: ll_callback(eval(compiled_eval, {}, self._current_sweep_point)) # recursively call the function to iterate over the next variable or call the function to run # the experiement this is the inner most loop if depth == len(vars_order) - 1: self._server._run_experiment() else: self._nested_loop_recursive( sweep_routine=sweep_routine, vars_order=vars_order, depth=depth + 1, accounted_callbacks=accounted_callbacks | callbacks_executing, )
[docs] def run(self, sweep_callbacks: list, variables: list) -> int: """Run the sweep experiment by parsing the configuration and executing the recursive sweep routine. Sweep callbacks is a list of tuples: (callback func, expression, callback cost) :param sweep_callbacks: A list of tuples containing the sweep expressions. :type sweep_callbacks: list[tuple[callable, str, int]] :param variables: A list of dictionaries containing the variable parameters for the sweep. :type variables: dict[str: dict] """ # compute variables values self.computed_vars = self._compute_variable_values(variables) # parse the configuration sweep_routine, vars_order = self._parse_callbacks(sweep_callbacks) # create and the header on the queue self._put_sweep_info_in_network(vars_order) # execute the experiment changing the sweeping variables start = time.perf_counter_ns() self._nested_loop_recursive( sweep_routine, vars_order, ) stop = time.perf_counter_ns() return stop - start