From 89f7ff930ff0cb9044968e7d128c569bd562f2b5 Mon Sep 17 00:00:00 2001 From: Yonatan Rozmarin Date: Sun, 9 Jun 2024 14:48:12 +0300 Subject: [PATCH 1/7] Add function that sets the config --- quam/components/channels.py | 164 ++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 83 deletions(-) diff --git a/quam/components/channels.py b/quam/components/channels.py index 650a619a..fc537536 100644 --- a/quam/components/channels.py +++ b/quam/components/channels.py @@ -1,5 +1,5 @@ from dataclasses import field -from typing import ClassVar, Dict, List, Optional, Sequence, Literal, Tuple, Union +from typing import ClassVar, Dict, List, Optional, Sequence, Literal, Tuple, Union, Any import warnings from quam.components.hardware import BaseFrequencyConverter, Mixer, LocalOscillator @@ -69,7 +69,7 @@ class DigitalOutputChannel(QuamComponent): Default is False. .""" - opx_output: Tuple[str, int] + opx_output: Union[Tuple[str, int], Tuple[str, int, int]] delay: int = None buffer: int = None @@ -386,27 +386,25 @@ def frame_rotation_2pi(self, angle: QuaNumberType): """ frame_rotation_2pi(angle, self.name) - def _config_add_controller( - self, config: Dict[str, dict], controller_name: str - ) -> Dict[str, dict]: - """Adds a controller to the config if it doesn't exist, and returns its config. - - config.controllers. will be created if it doesn't exist. - It will also add the analog_outputs, digital_outputs, and analog_inputs keys - - Args: - config (dict): The QUA config that's in the process of being generated. - controller_name (str): The name of the controller. - - Returns: - Dict[str, dict]: The config entry for the controller. - """ - config["controllers"].setdefault(controller_name, {}) - controller_cfg = config["controllers"][controller_name] - for key in ["analog_outputs", "digital_outputs", "analog_inputs"]: - controller_cfg.setdefault(key, {}) + def _add_analog_port_to_config(self, address: Union[Tuple[str, int], Tuple[str, int, int]], config, offset: float, port_type: Literal["input", "output"]) -> Dict[str, Any]: + if len(address) == 2: + controller_name, port = address + controller_cfg = _config_add_opx_controller(config, controller_name) + else: + controller_name, fem, port = address + controller_cfg = _config_add_octo_dac_controller(config, controller_name, fem) - return controller_cfg + port_config = controller_cfg[f"analog_{port_type}s"].setdefault(port, {}) + # If no offset specified, it will be added at the end of config generation + if offset is not None: + if abs(port_config.get("offset", offset) - offset) > 1e-4: + raise ValueError( + f"Channel {self.name} has conflicting {port_type} offsets: " + f"{port_config['offset']} and {offset}. Multiple channel " + f"elements are trying to set different offsets to port {port}" + ) + port_config["offset"] = offset + return port_config def _config_add_digital_outputs(self, config: Dict[str, dict]) -> None: """Adds the digital outputs to the QUA config. @@ -470,7 +468,7 @@ class SingleChannel(Channel): is None. """ - opx_output: Tuple[str, int] + opx_output: Union[Tuple[str, int], Tuple[str, int, int]] filter_fir_taps: List[float] = None filter_iir_taps: List[float] = None @@ -512,27 +510,14 @@ def apply_to_config(self, config: dict): if self.intermediate_frequency is not None: element_config["intermediate_frequency"] = self.intermediate_frequency - controller_name, port = self.opx_output - controller_cfg = self._config_add_controller(config, controller_name) - analog_output = controller_cfg["analog_outputs"].setdefault(port, {}) - # If no offset specified, it will be added at the end of the config generation - offset = self.opx_output_offset - if offset is not None: - if abs(analog_output.get("offset", offset) - offset) > 1e-4: - warnings.warn( - f"Channel {self.name} has conflicting output offsets: " - f"{analog_output['offset']} V and {offset} V. Multiple channel " - f"elements are trying to set different offsets to port {port}. " - f"Using the last offset {offset} V" - ) - analog_output["offset"] = offset + port_config = self._add_analog_port_to_config(self.opx_output, config, self.opx_output_offset, "output") if self.filter_fir_taps is not None: - output_filter = analog_output.setdefault("filter", {}) + output_filter = port_config.setdefault("filter", {}) output_filter["feedforward"] = list(self.filter_fir_taps) if self.filter_iir_taps is not None: - output_filter = analog_output.setdefault("filter", {}) + output_filter = port_config.setdefault("filter", {}) output_filter["feedback"] = list(self.filter_iir_taps) @@ -556,7 +541,7 @@ class InSingleChannel(Channel): Used to account for signal smearing. """ - opx_input: Tuple[str, int] + opx_input: Union[Tuple[str, int], Tuple[str, int, int]] opx_input_offset: float = None time_of_flight: int = 24 @@ -576,19 +561,7 @@ def apply_to_config(self, config: dict): config["elements"][self.name]["smearing"] = self.smearing config["elements"][self.name]["time_of_flight"] = self.time_of_flight - controller_name, port = self.opx_input - controller_cfg = self._config_add_controller(config, controller_name) - analog_input = controller_cfg["analog_inputs"].setdefault(port, {}) - offset = self.opx_input_offset - # If no offset specified, it will be added at the end of the config generation - if offset is not None: - if abs(analog_input.get("offset", offset) - offset) > 1e-4: - raise ValueError( - f"Channel {self.name} has conflicting input offsets: " - f"{analog_input['offset']} and {offset}. Multiple channel " - f"elements are trying to set different offsets to port {port}" - ) - analog_input["offset"] = offset + self._add_analog_port_to_config(self.opx_input, config, self.opx_input_offset, "input") def measure( self, @@ -837,8 +810,8 @@ class IQChannel(Channel): for the IQ output. """ - opx_output_I: Tuple[str, int] - opx_output_Q: Tuple[str, int] + opx_output_I: Union[Tuple[str, int], Tuple[str, int, int]] + opx_output_Q: Union[Tuple[str, int], Tuple[str, int, int]] opx_output_offset_I: float = None opx_output_offset_Q: float = None @@ -1003,19 +976,9 @@ def apply_to_config(self, config: dict): ] = self.local_oscillator.frequency for I_or_Q in ["I", "Q"]: - controller_name, port = opx_outputs[I_or_Q] - controller_cfg = self._config_add_controller(config, controller_name) - analog_output = controller_cfg["analog_outputs"].setdefault(port, {}) - # If no offset specified, it will be added at the end of config generation + port_output = opx_outputs[I_or_Q] offset = offsets[I_or_Q] - if offset is not None: - if abs(analog_output.get("offset", offset) - offset) > 1e-4: - raise ValueError( - f"Channel {self.name} has conflicting output offsets: " - f"{analog_output['offset']} and {offset}. Multiple channel " - f"elements are trying to set different offsets to port {port}" - ) - analog_output["offset"] = offset + self._add_analog_port_to_config(port_output, config, offset, "output") @quam_dataclass @@ -1042,8 +1005,8 @@ class InIQChannel(Channel): input_gain (float): The gain of the input channel. Default is None. """ - opx_input_I: Tuple[str, int] - opx_input_Q: Tuple[str, int] + opx_input_I: Union[Tuple[str, int], Tuple[str, int, int]] + opx_input_Q: Union[Tuple[str, int], Tuple[str, int, int]] time_of_flight: int = 24 smearing: int = 0 @@ -1099,23 +1062,12 @@ def apply_to_config(self, config: dict): } for I_or_Q in ["I", "Q"]: - controller_name, port = opx_inputs[I_or_Q] - controller_cfg = self._config_add_controller(config, controller_name) - analog_input = controller_cfg["analog_inputs"].setdefault(port, {}) + curr_input = opx_inputs[I_or_Q] offset = offsets[I_or_Q] - # If no offset specified, it will be added at the end of config generation - if offset is not None: - if abs(analog_input.get("offset", offset) - offset) > 1e-4: - warnings.warn( - f"Channel {self.name} has conflicting input offsets: " - f"{analog_input['offset']} V and {offset} V. Multiple channel " - f"elements are trying to set different offsets to port {port}. " - f"Using the last offset {offset} V" - ) - analog_input["offset"] = offset + port_config = self._add_analog_port_to_config(curr_input, config, offset, port_type="input") if self.input_gain is not None: - controller_cfg["analog_inputs"][port]["gain_db"] = self.input_gain + port_config["gain_db"] = self.input_gain def measure( self, @@ -1491,3 +1443,49 @@ class InIQOutSingleChannel(SingleChannel, InIQChannel): """ pass + + +def _config_add_opx_controller(config: Dict[str, dict], controller_name: str) -> Dict[str, dict]: + """Adds a controller to the config if it doesn't exist, and returns its config. + + config.controllers. will be created if it doesn't exist. + It will also add the analog_outputs, digital_outputs, and analog_inputs keys + + Args: + config (dict): The QUA config that's in the process of being generated. + controller_name (str): The name of the controller. + + Returns: + Dict[str, dict]: The config entry for the controller. + """ + config["controllers"].setdefault(controller_name, {}) + controller_cfg = config["controllers"][controller_name] + for key in ["analog_outputs", "digital_outputs", "analog_inputs"]: + controller_cfg.setdefault(key, {}) + + return controller_cfg + + +def _config_add_octo_dac_controller(config: Dict[str, dict], controller_name: str, fem_idx: int) -> Dict[str, dict]: + """Adds a controller to the config if it doesn't exist, and returns its config. + + config.controllers. will be created if it doesn't exist. + It will also add the analog_outputs, digital_outputs, and analog_inputs keys + + Args: + config (dict): The QUA config that's in the process of being generated. + controller_name (str): The name of the controller. + + Returns: + Dict[str, dict]: The config entry for the controller. + """ + config["controllers"].setdefault(controller_name, {}) + controller_cfg = config["controllers"][controller_name] + controller_cfg.setdefault("fems", {"type": "LF"}) + fem_config = controller_cfg["fems"] + fem_config.setdefault(fem_idx, {}) + fem_config = fem_config[fem_idx] + for key in ["analog_outputs", "digital_outputs", "analog_inputs"]: + fem_config.setdefault(key, {}) + + return controller_cfg From b146f734aa68200644171949b95d49ce9a26177b Mon Sep 17 00:00:00 2001 From: Yonatan Rozmarin Date: Sun, 9 Jun 2024 15:22:41 +0300 Subject: [PATCH 2/7] Fix small bugs --- quam/components/channels.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/quam/components/channels.py b/quam/components/channels.py index fc537536..d2fdaeed 100644 --- a/quam/components/channels.py +++ b/quam/components/channels.py @@ -102,10 +102,18 @@ def apply_to_config(self, config: dict) -> None: See [`QuamComponent.apply_to_config`][quam.core.quam_classes.QuamComponent.apply_to_config] for details. """ - controller_name, port = self.opx_output - controller_cfg = config["controllers"].setdefault(controller_name, {}) - controller_cfg.setdefault("digital_outputs", {}) - port_cfg = controller_cfg["digital_outputs"].setdefault(port, {}) + if len(self.opx_output) == 2: + controller_name, port = self.opx_output + controller_cfg = config["controllers"].setdefault(controller_name, {}) + controller_cfg.setdefault("digital_outputs", {}) + port_cfg = controller_cfg["digital_outputs"].setdefault(port, {}) + else: + controller_name, fem, port = self.opx_output + controller_cfg = config["controllers"].setdefault(controller_name, {}) + controller_cfg.setdefault("fems", {}) + fem_cfg = controller_cfg["fems"].setdefault(fem, {"type": "LF"}) + fem_cfg.setdefault("digital_outputs", {}) + port_cfg = fem_cfg["digital_outputs"].setdefault(port, {}) if self.shareable is not None: if port_cfg.get("shareable", self.shareable) != self.shareable: @@ -1481,11 +1489,11 @@ def _config_add_octo_dac_controller(config: Dict[str, dict], controller_name: st """ config["controllers"].setdefault(controller_name, {}) controller_cfg = config["controllers"][controller_name] - controller_cfg.setdefault("fems", {"type": "LF"}) + controller_cfg.setdefault("fems", {}) fem_config = controller_cfg["fems"] - fem_config.setdefault(fem_idx, {}) + fem_config.setdefault(fem_idx, {"type": "LF"}) fem_config = fem_config[fem_idx] for key in ["analog_outputs", "digital_outputs", "analog_inputs"]: fem_config.setdefault(key, {}) - return controller_cfg + return fem_config From c29b4615c9af8e13e6d93adbaabc8f78a5924ef6 Mon Sep 17 00:00:00 2001 From: Yonatan Rozmarin Date: Sun, 9 Jun 2024 15:32:38 +0300 Subject: [PATCH 3/7] Add test --- .../superconducting_qubits/test_transmon.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/examples/superconducting_qubits/test_transmon.py b/tests/examples/superconducting_qubits/test_transmon.py index 03f3da8e..26e3cf70 100644 --- a/tests/examples/superconducting_qubits/test_transmon.py +++ b/tests/examples/superconducting_qubits/test_transmon.py @@ -72,6 +72,66 @@ def test_transmon_xy(): assert cfg == expected_cfg +def test_transmon_xy_opx1000(): + transmon = Transmon( + id=1, + xy=IQChannel( + opx_output_I=("con1", 2, 1), + opx_output_Q=("con1", 2, 2), + frequency_converter_up=FrequencyConverter( + mixer=Mixer(), + local_oscillator=LocalOscillator(frequency=5e9), + ), + intermediate_frequency=100e6, + ), + ) + + assert transmon.xy.name == "q1.xy" + assert transmon.xy.mixer.name == "q1.xy.mixer" + assert not transmon.xy.operations + assert transmon.xy.mixer.intermediate_frequency == 100e6 + assert transmon.z is None + + cfg = {"controllers": {}, "elements": {}} + + # References first have to be set to None + with pytest.raises(ValueError): + transmon.xy.mixer.local_oscillator_frequency = 5e9 + transmon.xy.mixer.local_oscillator_frequency = None + transmon.xy.mixer.local_oscillator_frequency = 5e9 + + assert transmon.xy.rf_frequency == 5.1e9 + + transmon.xy.apply_to_config(cfg) + expected_cfg = { + "elements": { + "q1.xy": { + "mixInputs": { + "I": ("con1", 2, 1), + "Q": ("con1", 2, 2), + "lo_frequency": 5000000000.0, + "mixer": "q1.xy.mixer", + }, + "intermediate_frequency": 100e6, + "operations": {}, + }, + }, + "controllers": { + "con1": { + "fems": { + 2: { + "type": "LF", + "analog_outputs": {1: {}, 2: {}}, + "digital_outputs": {}, + "analog_inputs": {}, + } + } + } + }, + } + assert cfg == expected_cfg + + def test_transmon_add_pulse(): transmon = Transmon( id=1, From 45b796aeb8708b16033d940549b5f3f364dd3e0e Mon Sep 17 00:00:00 2001 From: Yonatan Rozmarin Date: Sun, 16 Jun 2024 15:46:29 +0300 Subject: [PATCH 4/7] Add try except for union fields --- quam/core/quam_instantiation.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/quam/core/quam_instantiation.py b/quam/core/quam_instantiation.py index bb4d42ec..d6201a7b 100644 --- a/quam/core/quam_instantiation.py +++ b/quam/core/quam_instantiation.py @@ -224,6 +224,20 @@ def instantiate_attr( ) if typing.get_origin(expected_type) == dict: expected_type = dict + elif typing.get_origin(expected_type) == typing.Union: + for union_type in typing.get_args(expected_type): + try: + instantiated_attr = instantiate_attr( + attr_val=attr_val, + expected_type=union_type, + allow_none=allow_none, + fix_attrs=fix_attrs, + validate_type=validate_type, + str_repr=str_repr, + ) + break + except TypeError: + continue elif ( isinstance(expected_type, list) or typing.get_origin(expected_type) == list @@ -240,8 +254,6 @@ def instantiate_attr( expected_type = list elif typing.get_origin(expected_type) == tuple: instantiated_attr = tuple(instantiated_attr) - elif typing.get_origin(expected_type) == typing.Union: - instantiated_attr = attr_val elif typing.get_origin(expected_type) == tuple: if isinstance(attr_val, list): attr_val = tuple(attr_val) From 4f77d928cdeeb6adfbd1f6f4de29a38c8cd9b035 Mon Sep 17 00:00:00 2001 From: Yonatan Rozmarin Date: Mon, 17 Jun 2024 15:51:48 +0300 Subject: [PATCH 5/7] Change an error to warning renamed a function --- quam/components/channels.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/quam/components/channels.py b/quam/components/channels.py index d2fdaeed..ac582275 100644 --- a/quam/components/channels.py +++ b/quam/components/channels.py @@ -400,16 +400,17 @@ def _add_analog_port_to_config(self, address: Union[Tuple[str, int], Tuple[str, controller_cfg = _config_add_opx_controller(config, controller_name) else: controller_name, fem, port = address - controller_cfg = _config_add_octo_dac_controller(config, controller_name, fem) + controller_cfg = _config_add_opx1000_controller(config, controller_name, fem) port_config = controller_cfg[f"analog_{port_type}s"].setdefault(port, {}) # If no offset specified, it will be added at the end of config generation if offset is not None: if abs(port_config.get("offset", offset) - offset) > 1e-4: - raise ValueError( + warnings.warn( f"Channel {self.name} has conflicting {port_type} offsets: " f"{port_config['offset']} and {offset}. Multiple channel " - f"elements are trying to set different offsets to port {port}" + f"elements are trying to set different offsets to port {port}", + UserWarning, ) port_config["offset"] = offset return port_config @@ -1474,7 +1475,7 @@ def _config_add_opx_controller(config: Dict[str, dict], controller_name: str) -> return controller_cfg -def _config_add_octo_dac_controller(config: Dict[str, dict], controller_name: str, fem_idx: int) -> Dict[str, dict]: +def _config_add_opx1000_controller(config: Dict[str, dict], controller_name: str, fem_idx: int) -> Dict[str, dict]: """Adds a controller to the config if it doesn't exist, and returns its config. config.controllers. will be created if it doesn't exist. From 58469ca9c27db9506d6eb9156fca6ee7b6ef5aa4 Mon Sep 17 00:00:00 2001 From: Serwan Asaad Date: Mon, 24 Jun 2024 20:38:14 +0200 Subject: [PATCH 6/7] add final TypeError --- quam/core/quam_instantiation.py | 4 ++++ tests/instantiation/test_instantiation.py | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/quam/core/quam_instantiation.py b/quam/core/quam_instantiation.py index d6201a7b..57680cf4 100644 --- a/quam/core/quam_instantiation.py +++ b/quam/core/quam_instantiation.py @@ -238,6 +238,10 @@ def instantiate_attr( break except TypeError: continue + else: + raise TypeError( + f"Could not instantiate {str_repr} with any of the types in {expected_type}" + ) elif ( isinstance(expected_type, list) or typing.get_origin(expected_type) == list diff --git a/tests/instantiation/test_instantiation.py b/tests/instantiation/test_instantiation.py index 8025c82d..7aa293ff 100644 --- a/tests/instantiation/test_instantiation.py +++ b/tests/instantiation/test_instantiation.py @@ -1,5 +1,5 @@ import pytest -from typing import List, Literal, Optional, Tuple +from typing import List, Literal, Optional, Tuple, Union from quam.core import QuamRoot, QuamComponent, quam_dataclass from quam.core.quam_classes import QuamDict @@ -338,3 +338,22 @@ def test_instantiate_dict_referenced(): ) assert attrs == {"test_attr": "#./reference"} + +@quam_dataclass +class TestQuamComponent(QuamComponent): + a: int + + +def test_instantiate_union_type(): + @quam_dataclass + class TestQuamUnion(QuamComponent): + union_val: Union[int, TestQuamComponent] + + obj = instantiate_quam_class(TestQuamUnion, {"union_val": 42}) + assert obj.union_val == 42 + + obj = instantiate_quam_class(TestQuamUnion, {"union_val": {"a": 42}}) + assert obj.union_val.a == 42 + + with pytest.raises(TypeError): + instantiate_quam_class(TestQuamUnion, {"union_val": {"a": "42"}}) From 38ede8b89439454a9a89b5b82ed61d3e382bad48 Mon Sep 17 00:00:00 2001 From: Serwan Asaad Date: Mon, 24 Jun 2024 20:39:40 +0200 Subject: [PATCH 7/7] refactor: clean up setdefault --- quam/components/channels.py | 41 +++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/quam/components/channels.py b/quam/components/channels.py index ac582275..1a54fbe2 100644 --- a/quam/components/channels.py +++ b/quam/components/channels.py @@ -394,13 +394,21 @@ def frame_rotation_2pi(self, angle: QuaNumberType): """ frame_rotation_2pi(angle, self.name) - def _add_analog_port_to_config(self, address: Union[Tuple[str, int], Tuple[str, int, int]], config, offset: float, port_type: Literal["input", "output"]) -> Dict[str, Any]: + def _add_analog_port_to_config( + self, + address: Union[Tuple[str, int], Tuple[str, int, int]], + config, + offset: float, + port_type: Literal["input", "output"], + ) -> Dict[str, Any]: if len(address) == 2: controller_name, port = address controller_cfg = _config_add_opx_controller(config, controller_name) else: controller_name, fem, port = address - controller_cfg = _config_add_opx1000_controller(config, controller_name, fem) + controller_cfg = _config_add_opx1000_controller( + config, controller_name, fem + ) port_config = controller_cfg[f"analog_{port_type}s"].setdefault(port, {}) # If no offset specified, it will be added at the end of config generation @@ -519,7 +527,9 @@ def apply_to_config(self, config: dict): if self.intermediate_frequency is not None: element_config["intermediate_frequency"] = self.intermediate_frequency - port_config = self._add_analog_port_to_config(self.opx_output, config, self.opx_output_offset, "output") + port_config = self._add_analog_port_to_config( + self.opx_output, config, self.opx_output_offset, "output" + ) if self.filter_fir_taps is not None: output_filter = port_config.setdefault("filter", {}) @@ -570,7 +580,9 @@ def apply_to_config(self, config: dict): config["elements"][self.name]["smearing"] = self.smearing config["elements"][self.name]["time_of_flight"] = self.time_of_flight - self._add_analog_port_to_config(self.opx_input, config, self.opx_input_offset, "input") + self._add_analog_port_to_config( + self.opx_input, config, self.opx_input_offset, "input" + ) def measure( self, @@ -1073,7 +1085,9 @@ def apply_to_config(self, config: dict): for I_or_Q in ["I", "Q"]: curr_input = opx_inputs[I_or_Q] offset = offsets[I_or_Q] - port_config = self._add_analog_port_to_config(curr_input, config, offset, port_type="input") + port_config = self._add_analog_port_to_config( + curr_input, config, offset, port_type="input" + ) if self.input_gain is not None: port_config["gain_db"] = self.input_gain @@ -1454,7 +1468,9 @@ class InIQOutSingleChannel(SingleChannel, InIQChannel): pass -def _config_add_opx_controller(config: Dict[str, dict], controller_name: str) -> Dict[str, dict]: +def _config_add_opx_controller( + config: Dict[str, dict], controller_name: str +) -> Dict[str, dict]: """Adds a controller to the config if it doesn't exist, and returns its config. config.controllers. will be created if it doesn't exist. @@ -1475,7 +1491,9 @@ def _config_add_opx_controller(config: Dict[str, dict], controller_name: str) -> return controller_cfg -def _config_add_opx1000_controller(config: Dict[str, dict], controller_name: str, fem_idx: int) -> Dict[str, dict]: +def _config_add_opx1000_controller( + config: Dict[str, dict], controller_name: str, fem_idx: int +) -> Dict[str, dict]: """Adds a controller to the config if it doesn't exist, and returns its config. config.controllers. will be created if it doesn't exist. @@ -1488,12 +1506,9 @@ def _config_add_opx1000_controller(config: Dict[str, dict], controller_name: str Returns: Dict[str, dict]: The config entry for the controller. """ - config["controllers"].setdefault(controller_name, {}) - controller_cfg = config["controllers"][controller_name] - controller_cfg.setdefault("fems", {}) - fem_config = controller_cfg["fems"] - fem_config.setdefault(fem_idx, {"type": "LF"}) - fem_config = fem_config[fem_idx] + controller_cfg = config["controllers"].setdefault(controller_name, {}) + fems_config = controller_cfg.setdefault("fems", {}) + fem_config = fems_config.setdefault(fem_idx, {"type": "LF"}) for key in ["analog_outputs", "digital_outputs", "analog_inputs"]: fem_config.setdefault(key, {})