diff --git a/dashboard/app.py b/dashboard/app.py index f6ae483f..c9f6565e 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -7,9 +7,9 @@ from trame.widgets import plotly, router, vuetify3 as vuetify, html from model_manager import ModelManager -from outputs_manager import OutputManager +from outputs_manager import OutputsManager from optimization_manager import OptimizationManager -from parameters_manager import ParametersManager +from inputs_manager import InputsManager from calibration_manager import SimulationCalibrationManager from sfapi_manager import initialize_sfapi, load_sfapi_card from state_manager import server, state, ctrl, initialize_state @@ -27,9 +27,9 @@ # Globals # ----------------------------------------------------------------------------- -out_manager = None +inputs_manager = None +outputs_manager = None mod_manager = None -par_manager = None opt_manager = None cal_manager = None @@ -43,8 +43,8 @@ def update( reset_model=True, - reset_output=True, - reset_parameters=True, + reset_inputs=True, + reset_outputs=True, reset_calibration=True, reset_plots=True, reset_gui_route_home=True, @@ -55,30 +55,28 @@ def update( ): print("Updating...") global mod_manager - global out_manager - global par_manager + global inputs_manager + global outputs_manager global opt_manager global cal_manager # load input and output variables - input_variables, output_variables, simulation_calibration = load_variables( - state.experiment - ) + inputs, outputs, simulation_calibration = load_variables(state.experiment) # load data db = load_database(state.experiment) exp_data, sim_data = load_data(db) - # reset output - if reset_output: - out_manager = OutputManager(output_variables) + # reset outputs + if reset_outputs: + outputs_manager = OutputsManager(outputs) # reset model if reset_model: mod_manager = ModelManager(db) opt_manager = OptimizationManager(mod_manager) - # reset parameters - if reset_parameters: - par_manager = ParametersManager(mod_manager, input_variables) + # reset inputs + if reset_inputs: + inputs_manager = InputsManager(mod_manager, inputs) elif reset_model: # if resetting only model, model attribute must be updated - par_manager.model = mod_manager + inputs_manager.model = mod_manager # reset calibration if reset_calibration: cal_manager = SimulationCalibrationManager(simulation_calibration) @@ -112,8 +110,8 @@ def update_on_change_experiment(**kwargs): print("Experiment changed...") update( reset_model=True, - reset_output=True, - reset_parameters=True, + reset_inputs=True, + reset_outputs=True, reset_calibration=True, reset_plots=True, reset_gui_route_home=True, @@ -130,8 +128,8 @@ def update_on_change_model(**kwargs): print("Model type changed...") update( reset_model=True, - reset_output=False, - reset_parameters=False, + reset_inputs=False, + reset_outputs=False, reset_calibration=False, reset_plots=True, reset_gui_route_home=True, @@ -143,22 +141,19 @@ def update_on_change_model(**kwargs): @state.change( "displayed_output", - "parameters", + "inputs_new", "opacity", - "parameters_min", - "parameters_max", - "parameters_show_all", "simulation_calibration", "use_inferred_calibration", ) def update_on_change_others(**kwargs): # skip if triggered on server ready (all state variables marked as modified) if len(state.modified_keys) == 1: - print("Parameters, opacity changed...") + print("Inputs, opacity changed...") update( reset_model=False, - reset_output=False, - reset_parameters=False, + reset_inputs=False, + reset_outputs=False, reset_calibration=False, reset_plots=True, reset_gui_route_home=False, @@ -175,12 +170,12 @@ def find_simulation(event, db): # find the document with matching ID from the experiment collection documents = list(db[state.experiment].find({"_id": ObjectId(this_point_id)})) if len(documents) == 1: - this_point_parameters = { - parameter: documents[0][parameter] - for parameter in state.parameters.keys() - if parameter in documents[0] + this_point_inputs = { + input: documents[0][input] + for input in state.inputs_new.keys() + if input in documents[0] } - print(f"Clicked on data point ({this_point_parameters})") + print(f"Clicked on data point ({this_point_inputs})") else: title = "Unable to find database document" msg = f"Error occurred when searching for database document that matches ID {this_point_id}" @@ -277,23 +272,23 @@ def home_route(): with vuetify.VCol(cols=4): with vuetify.VCard(): with vuetify.VTabs( - v_model=("active_tab", "parameters_tab"), + v_model=("active_tab", "inputs_tab"), color="primary", mandatory=True, ): - vuetify.VTab("Parameters", value="parameters_tab") + vuetify.VTab("Inputs", value="inputs_tab") vuetify.VTab("Optimization", value="optimization_tab") vuetify.VTab("ML", value="ml_tab") with vuetify.VWindow(v_model=("active_tab",), mandatory=True): - with vuetify.VWindowItem(value="parameters_tab"): - # output control panel + with vuetify.VWindowItem(value="inputs_tab"): + # outputs control panel with vuetify.VRow(): with vuetify.VCol(): - out_manager.panel() - # parameters control panel + outputs_manager.panel() + # inputs control panel with vuetify.VRow(): with vuetify.VCol(): - par_manager.panel() + inputs_manager.panel() # plots control panel with vuetify.VRow(): with vuetify.VCol(): @@ -317,7 +312,7 @@ def home_route(): with vuetify.VCard(): with vuetify.VCardTitle("Plots"): with vuetify.VContainer( - style=f"height: {400 * len(state.parameters)}px;" + style=f"height: {400 * len(state.inputs_new)}px;" ): figure = plotly.Figure( display_mode_bar="true", diff --git a/dashboard/calibration_manager.py b/dashboard/calibration_manager.py index b77521dd..d7a5b4a1 100644 --- a/dashboard/calibration_manager.py +++ b/dashboard/calibration_manager.py @@ -39,10 +39,10 @@ def convert(value, alpha, beta): add_error(title, msg) print(msg) - def convert_exp_to_sim(self, exp_dict): + def convert_exp_to_sim(self, exp_dict): # FIXME """ Apply calibration to the experimental points, to be passed as - parameters for simulations on NERSC. + inputs for simulations on NERSC. """ def convert(value, alpha, beta): diff --git a/dashboard/parameters_manager.py b/dashboard/inputs_manager.py similarity index 78% rename from dashboard/parameters_manager.py rename to dashboard/inputs_manager.py index 9cfc5a57..42cb2d6a 100644 --- a/dashboard/parameters_manager.py +++ b/dashboard/inputs_manager.py @@ -14,31 +14,23 @@ from state_manager import state, EXPERIMENTS_PATH -class ParametersManager: - def __init__(self, model, input_variables): - print("Initializing parameters manager...") +class InputsManager: + def __init__(self, model, inputs): + print("Initializing inputs manager...") # save model self.__model = model # define state variables - state.parameters = dict() - state.parameters_min = dict() - state.parameters_max = dict() - state.parameters_show_all = dict() - self.parameters_step = dict() state.simulatable = ( self.simulation_scripts_base_path / "submission_script_single" ).is_file() - for parameter_dict in input_variables.values(): - key = parameter_dict["name"] - pmin = float(parameter_dict["value_range"][0]) - pmax = float(parameter_dict["value_range"][1]) - pval = float(parameter_dict["default"]) - state.parameters[key] = pval - state.parameters_min[key] = pmin - state.parameters_max[key] = pmax - state.parameters_show_all[key] = False - self.parameters_step[key] = (pmax - pmin) / 100 - state.parameters_init = copy.deepcopy(state.parameters) + state.inputs_new = copy.deepcopy(inputs) + for input_dict in state.inputs_new.values(): + input_dict["value"] = float(input_dict["default"]) + input_dict["step"] = ( + input_dict["value_range"][1] - input_dict["value_range"][0] / 100 + ) + input_dict["show_all"] = False + state.inputs_init = copy.deepcopy(state.inputs_new) @property def model(self): @@ -53,11 +45,11 @@ def simulation_scripts_base_path(self): return EXPERIMENTS_PATH / f"synapse-{state.experiment}/simulation_scripts/" def reset(self): - print("Resetting parameters to default values...") - # reset parameters to initial values - state.parameters = copy.deepcopy(state.parameters_init) + print("Resetting inputs to default values...") + # reset inputs to initial values + state.inputs_new = copy.deepcopy(state.inputs_init) # push again at flush time - state.dirty("parameters") + state.dirty("inputs_new") async def simulation_kernel(self): try: @@ -71,13 +63,13 @@ async def simulation_kernel(self): [target_path] = await perlmutter.ls(target_path, directory=True) # set the base path where auxiliary files are copied from with tempfile.TemporaryDirectory() as temp_dir: - # store the current simulation parameters in a YAML temporary file + # store the current simulation inputs in a YAML temporary file temp_file_path = ( Path(temp_dir) / "single_simulation_parameters.yaml" ) _, _, simulation_calibration = load_variables(state.experiment) sim_cal = SimulationCalibrationManager(simulation_calibration) - sim_dict = sim_cal.convert_exp_to_sim(state.parameters) + sim_dict = sim_cal.convert_exp_to_sim(state.inputs_new) with open(temp_file_path, "w") as temp_file: yaml.dump(sim_dict, temp_file) temp_file.flush() @@ -148,19 +140,20 @@ def simulation_trigger(self): print(msg) def panel(self): - print("Setting parameters card...") - with vuetify.VExpansionPanels(v_model=("expand_panel_control_parameters", 0)): + print("Setting inputs card...") + with vuetify.VExpansionPanels(v_model=("expand_panel_control_inputs", 0)): with vuetify.VExpansionPanel( - title="Control: Parameters", + title="Control: Inputs", style="font-size: 20px; font-weight: 500;", ): with vuetify.VExpansionPanelText(): - with client.DeepReactive("parameters"): - for count, key in enumerate(state.parameters.keys()): - # create a row for the parameter label + with client.DeepReactive("inputs_new"): + for count, key in enumerate(state.inputs_new.keys()): + print(key) + # create a row for the input label with vuetify.VRow(): vuetify.VListSubheader( - key, + state.inputs_new[key]["name"], style=( "margin-top: 16px;" if count == 0 @@ -169,19 +162,21 @@ def panel(self): ) with vuetify.VRow(no_gutters=True): with vuetify.VSlider( - v_model_number=(f"parameters['{key}']",), - change="flushState('parameters')", + v_model_number=(f"inputs_new['{key}']['value']",), + change="flushState('inputs_new')", hide_details=True, - min=(f"parameters_min['{key}']",), - max=(f"parameters_max['{key}']",), + min=(f"inputs_new['{key}']['value_range'][0]",), + max=(f"inputs_new['{key}']['value_range'][1]",), step=( - f"(parameters_max['{key}'] - parameters_min['{key}']) / 100", + f"(inputs_new['{key}']['value_range'][1] - inputs_new['{key}']['value_range'][0]) / 100", ), style="align-items: center;", ): with vuetify.Template(v_slot_append=True): vuetify.VTextField( - v_model_number=(f"parameters['{key}']",), + v_model_number=( + f"inputs_new['{key}']['value']", + ), density="compact", hide_details=True, readonly=True, @@ -189,15 +184,17 @@ def panel(self): style="margin-top: 0px; padding-top: 0px; width: 100px;", type="number", ) - step = self.parameters_step[key] + step = state.inputs_new[key]["step"] with vuetify.VRow(no_gutters=True): with vuetify.VCol(): vuetify.VTextField( - v_model_number=(f"parameters_min['{key}']",), - change="flushState('parameters_min')", + v_model_number=( + f"inputs_new['{key}']['value_range'][0]", + ), + change="flushState('inputs_new')", density="compact", hide_details=True, - disabled=(f"parameters_show_all['{key}']",), + disabled=(f"inputs_new['{key}']['show_all']",), step=step, __properties=["step"], style="width: 100px;", @@ -206,11 +203,13 @@ def panel(self): ) with vuetify.VCol(): vuetify.VTextField( - v_model_number=(f"parameters_max['{key}']",), - change="flushState('parameters_max')", + v_model_number=( + f"inputs_new['{key}']['value_range'][1]", + ), + change="flushState('inputs_new')", density="compact", hide_details=True, - disabled=(f"parameters_show_all['{key}']",), + disabled=(f"inputs_new['{key}']['show_all']",), step=step, __properties=["step"], style="width: 100px;", @@ -220,11 +219,11 @@ def panel(self): with vuetify.VCol(style="min-width: 100px;"): vuetify.VCheckbox( v_model=( - f"parameters_show_all['{key}']", + f"inputs_new['{key}']['show_all']", False, ), density="compact", - change="flushState('parameters_show_all')", + change="flushState('inputs_new')", label="Show all", ) with vuetify.VRow(align="center"): diff --git a/dashboard/model_manager.py b/dashboard/model_manager.py index c9ed20e3..50433f0e 100644 --- a/dashboard/model_manager.py +++ b/dashboard/model_manager.py @@ -11,7 +11,7 @@ from lume_model.models.ensemble import NNEnsemble from lume_model.models.gp_model import GPModel from trame.widgets import vuetify3 as vuetify -from utils import verify_input_variables, timer, load_config_dict +from utils import verify_inputs, timer, load_config_dict from error_manager import add_error from sfapi_manager import monitor_sfapi_job from state_manager import state @@ -99,7 +99,7 @@ def __init__(self, db): add_error(title, msg) print(msg) return - elif not verify_input_variables(model_file, state.experiment): + elif not verify_inputs(model_file, state.experiment): title = "Model file input variable mismatch" msg = f"Model file {model_file} has different input variables than the configuration file for {state.experiment}" add_error(title, msg) @@ -143,11 +143,11 @@ def is_neural_network_ensemble(self): return self.__is_neural_network_ensemble @timer - def evaluate(self, parameters, output): + def evaluate(self, inputs, output): print("Evaluating model...") if self.__model is not None: # evaluate model - output_dict = self.__model.evaluate(parameters) + output_dict = self.__model.evaluate(inputs) if self.__is_neural_network: # compute mean and mean error mean = output_dict[output] diff --git a/dashboard/optimization_manager.py b/dashboard/optimization_manager.py index 237c4ae2..154cbd6c 100644 --- a/dashboard/optimization_manager.py +++ b/dashboard/optimization_manager.py @@ -12,41 +12,39 @@ def __init__(self, model): self.__model = model state.optimization_target = state.displayed_output - def model_wrapper(self, parameters_array): + def model_wrapper(self, inputs_array): # FIXME print("Wrapping model...") - # convert array of parameters to dictionary - parameters_dict = dict(zip(state.parameters.keys(), parameters_array)) + # convert array of inputs to dictionary + inputs_dict = dict(zip(state.inputs.keys(), inputs_array)) # change sign to the result in order to maximize when optimizing mean, lower, upper = self.__model.evaluate( - parameters_dict, state.optimization_target + inputs_dict, state.optimization_target ) res = -mean if state.optimization_type == "Maximize" else mean return res - def optimize(self): - print("Optimizing parameters...") + def optimize(self): # FIXME + print("Optimizing inputs...") # info print statement skipped to avoid redundancy if self.__model is not None: - # get array of current parameters from state - parameters_values = np.array(list(state.parameters.values())) - # define parameters bounds for optimization - parameters_bounds = [] - for key in state.parameters.keys(): - parameters_bounds.append( - (state.parameters_min[key], state.parameters_max[key]) - ) + # get array of current inputs from state + inputs_values = np.array(list(state.inputs.values())) + # define inputs bounds for optimization + inputs_bounds = [] + for key in state.inputs.keys(): + inputs_bounds.append((state.inputs_min[key], state.inputs_max[key])) # optimize model (maximize output value) res = minimize( fun=self.model_wrapper, - x0=parameters_values, - bounds=parameters_bounds, + x0=inputs_values, + bounds=inputs_bounds, method="Powell", ) print(f"Optimization result:\n{res}") - # update parameters in state with optimal values - state.parameters = dict(zip(state.parameters.keys(), res.x)) + # update inputs in state with optimal values + state.inputs = dict(zip(state.inputs.keys(), res.x)) # push again at flush time - state.dirty("parameters") + state.dirty("inputs") # Force flush now (TODO fix state change listeners, remove workaround) state.flush() # update optimization status @@ -54,16 +52,16 @@ def optimize(self): state.optimization_status = "Completed" else: state.optimization_status = "Failed" - title = "Unable to optimize parameters" - msg = f"Error occurred when optimizing parameters: {res.message}" + title = "Unable to optimize inputs" + msg = f"Error occurred when optimizing inputs: {res.message}" add_error(title, msg) def optimize_trigger(self): try: self.optimize() except Exception as e: - title = "Unable to optimize parameters" - msg = f"Error occurred when optimizing parameters: {e}" + title = "Unable to optimize inputs" + msg = f"Error occurred when optimizing inputs: {e}" add_error(title, msg) print(msg) @@ -85,7 +83,7 @@ def panel(self): vuetify.VSelect( v_model=("optimization_target",), label="Optimization target", - items=(state.output_variables,), + items=(state.outputs,), dense=True, ) with vuetify.VCol(): diff --git a/dashboard/outputs_manager.py b/dashboard/outputs_manager.py index a25a8b25..33144522 100644 --- a/dashboard/outputs_manager.py +++ b/dashboard/outputs_manager.py @@ -3,12 +3,12 @@ from state_manager import state -class OutputManager: - def __init__(self, output_variables): +class OutputsManager: + def __init__(self, outputs): print("Initializing output manager...") # define state variables - state.output_variables = [v["name"] for v in output_variables.values()] - state.displayed_output = state.output_variables[0] + state.outputs = [v["name"] for v in outputs.values()] + state.displayed_output = state.outputs[0] def panel(self): print("Setting output card...") @@ -21,6 +21,6 @@ def panel(self): with vuetify.VRow(): vuetify.VSelect( v_model=("displayed_output",), - items=(state.output_variables,), + items=(state.outputs,), dense=True, ) diff --git a/dashboard/utils.py b/dashboard/utils.py index 2e822bff..c40cc024 100644 --- a/dashboard/utils.py +++ b/dashboard/utils.py @@ -58,16 +58,16 @@ def load_variables(experiment): print("Reading input/output variables from configuration file...") # load configuration dictionary config_dict = load_config_dict(experiment) - # dictionary of input variables (parameters) - input_variables = config_dict["inputs"] - # dictionary of output variables (objectives) - output_variables = config_dict["outputs"] + # dictionary of inputs + inputs = config_dict["inputs"] + # dictionary of outputs + outputs = config_dict["outputs"] # dictionary of calibration variables if "simulation_calibration" in config_dict: simulation_calibration = config_dict["simulation_calibration"] else: simulation_calibration = {} - return (input_variables, output_variables, simulation_calibration) + return (inputs, outputs, simulation_calibration) @timer @@ -85,7 +85,7 @@ def load_data(db): return (exp_data, sim_data) -def verify_input_variables(model_file, experiment): +def verify_inputs(model_file, experiment): print("Checking model consistency...") # read configuration file input_vars, _, _ = load_variables(experiment) @@ -102,7 +102,7 @@ def verify_input_variables(model_file, experiment): # check if configuration list and model list match match = config_vars == model_vars if not match: - print("Input variables in configuration file and model file do not match") + print("Inputs in configuration file and model file do not match") return match @@ -140,10 +140,7 @@ def plot(exp_data, sim_data, model_manager, cal_manager): # convert simulation data to experimental data cal_manager.convert_sim_to_exp(sim_data) # local aliases - parameters = state.parameters - parameters_min = state.parameters_min - parameters_max = state.parameters_max - parameters_show_all = state.parameters_show_all + inputs = state.inputs_new try: objective_name = state.displayed_output except Exception as e: @@ -156,10 +153,10 @@ def plot(exp_data, sim_data, model_manager, cal_manager): df_cds = ["blue", "red"] df_leg = ["Experiment", "Simulation"] # plot - fig = make_subplots(rows=len(parameters), cols=1) + fig = make_subplots(rows=len(inputs), cols=1) global_ymin = float("inf") global_ymax = float("-inf") - for i, key in enumerate(parameters.keys()): + for i, key in enumerate(inputs.keys()): # NOTE row count starts from 1, enumerate count starts from 0 this_row = i + 1 this_col = 1 @@ -169,21 +166,21 @@ def plot(exp_data, sim_data, model_manager, cal_manager): # compute Euclidean distance for df_count, df in enumerate([exp_data, sim_data]): df_copy = df.copy() - # some data sets do not include all parameters + # some data sets do not include all inputs # (e.g., simulation data set does not include GVD) - if key not in df_copy.columns: + if inputs[key]["name"] not in df_copy.columns: continue df_copy["distance"] = 0.0 # loop over all inputs except the current one for subkey in [ subkey - for subkey in parameters.keys() - if (subkey != key and subkey in df_copy.columns) + for subkey in inputs.keys() + if (subkey != key and inputs[subkey]["name"] in df_copy.columns) ]: - pname_loc = subkey - pval_loc = parameters[subkey] - pmin_loc = parameters_min[subkey] - pmax_loc = parameters_max[subkey] + pname_loc = inputs[subkey]["name"] + pval_loc = inputs[subkey]["value"] + pmin_loc = inputs[subkey]["value_range"][0] + pmax_loc = inputs[subkey]["value_range"][1] df_copy["distance"] += ( (df_copy[f"{pname_loc}"] - pval_loc) / (pmax_loc - pmin_loc) ) ** 2 @@ -217,15 +214,17 @@ def hover_section(title, cols, hover_data): return section # Determine which data is shown when hovering over the plot - hover_parameters = list(state.parameters.keys()) - hover_output_variables = state.output_variables - hover_customdata = ["_id"] + hover_parameters + hover_output_variables + hover_inputs = [ + state.inputs_new[key]["name"] for key in state.inputs_new.keys() + ] + hover_outputs = state.outputs + hover_customdata = ["_id"] + hover_inputs + hover_outputs hover_template_lines = hover_section( - "Input variables", hover_parameters, hover_customdata + "Input variables", hover_inputs, hover_customdata ) hover_template_lines += hover_section( - "Output variables", hover_output_variables, hover_customdata + "Output variables", hover_outputs, hover_customdata ) if df_leg[df_count] == "Experiment": hover_experiment = [ @@ -250,7 +249,7 @@ def hover_section(title, cols, hover_data): exp_fig = go.Figure( data=[ go.Scatter( - x=df_copy_filtered[key], + x=df_copy_filtered[inputs[key]["name"]], y=df_copy_filtered[objective_name], mode="markers", marker=dict( @@ -291,13 +290,17 @@ def hover_section(title, cols, hover_data): if model_manager.avail(): input_dict_loc = dict() steps = 1000 - input_dict_loc[key] = torch.linspace( - start=parameters_min[key], - end=parameters_max[key], + model_key = inputs[key]["name"] + input_dict_loc[model_key] = torch.linspace( + start=inputs[key]["value_range"][0], + end=inputs[key]["value_range"][1], steps=steps, ) - for subkey in [subkey for subkey in parameters.keys() if subkey != key]: - input_dict_loc[subkey] = parameters[subkey] * torch.ones(steps) + for subkey in [subkey for subkey in inputs.keys() if subkey != key]: + model_subkey = inputs[subkey]["name"] + input_dict_loc[model_subkey] = inputs[subkey]["value"] * torch.ones( + steps + ) # get mean and lower/upper bounds for uncertainty prediction # (when lower/upper bounds are not predicted by the model, # their values are set to zero to collapse the error range) @@ -310,7 +313,7 @@ def hover_section(title, cols, hover_data): # upper bound upper_bound = go.Scatter( - x=input_dict_loc[key], + x=input_dict_loc[model_key], y=upper, line=dict(color="orange", width=0.3), showlegend=False, @@ -323,7 +326,7 @@ def hover_section(title, cols, hover_data): ) # lower bound lower_bound = go.Scatter( - x=input_dict_loc[key], + x=input_dict_loc[model_key], y=lower, fill="tonexty", # fill area between this trace and the next one fillcolor="rgba(255,165,0,0.25)", # orange with alpha @@ -338,7 +341,7 @@ def hover_section(title, cols, hover_data): ) # scatter plot mod_trace = go.Scatter( - x=input_dict_loc[key], + x=input_dict_loc[model_key], y=mean, line=dict(color="orange"), name="ML Model", @@ -353,7 +356,7 @@ def hover_section(title, cols, hover_data): # ---------------------------------------------------------------------- # add reference input line fig.add_vline( - x=parameters[key], + x=inputs[key]["value"], line_dash="dash", row=this_row, col=this_col, @@ -362,10 +365,10 @@ def hover_section(title, cols, hover_data): # figures style custom_range = ( [None, None] - if parameters_show_all[key] + if inputs[key]["show_all"] else [ - parameters_min[key], - parameters_max[key], + inputs[key]["value_range"][0], + inputs[key]["value_range"][1], ] ) fig.update_xaxes( @@ -378,7 +381,7 @@ def hover_section(title, cols, hover_data): # A bit of padding on either end of the y range so we can see all the data. padding = 0.05 * (global_ymax - global_ymin) - for i, key in enumerate(parameters.keys()): + for i, key in enumerate(inputs.keys()): this_row = i + 1 this_col = 1 fig.update_yaxes(