iqm-exa-common 25.33__py3-none-any.whl → 26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. exa/common/api/proto_serialization/_parameter.py +4 -3
  2. exa/common/api/proto_serialization/nd_sweep.py +3 -8
  3. exa/common/api/proto_serialization/sequence.py +5 -5
  4. exa/common/control/sweep/exponential_sweep.py +15 -47
  5. exa/common/control/sweep/fixed_sweep.py +10 -14
  6. exa/common/control/sweep/linear_sweep.py +15 -40
  7. exa/common/control/sweep/option/__init__.py +1 -1
  8. exa/common/control/sweep/option/center_span_base_options.py +14 -7
  9. exa/common/control/sweep/option/center_span_options.py +13 -6
  10. exa/common/control/sweep/option/constants.py +2 -2
  11. exa/common/control/sweep/option/fixed_options.py +8 -2
  12. exa/common/control/sweep/option/option_converter.py +4 -8
  13. exa/common/control/sweep/option/start_stop_base_options.py +20 -6
  14. exa/common/control/sweep/option/start_stop_options.py +20 -5
  15. exa/common/control/sweep/option/sweep_options.py +9 -0
  16. exa/common/control/sweep/sweep.py +52 -16
  17. exa/common/control/sweep/sweep_values.py +58 -0
  18. exa/common/data/base_model.py +40 -0
  19. exa/common/data/parameter.py +123 -68
  20. exa/common/data/setting_node.py +481 -135
  21. exa/common/data/settingnode_v2.html.jinja2 +6 -6
  22. exa/common/data/value.py +49 -0
  23. exa/common/logger/logger.py +1 -1
  24. exa/common/qcm_data/file_adapter.py +2 -6
  25. exa/common/qcm_data/qcm_data_client.py +1 -37
  26. exa/common/sweep/database_serialization.py +30 -98
  27. exa/common/sweep/util.py +4 -5
  28. {iqm_exa_common-25.33.dist-info → iqm_exa_common-26.0.dist-info}/METADATA +2 -2
  29. iqm_exa_common-26.0.dist-info/RECORD +54 -0
  30. exa/common/api/model/__init__.py +0 -15
  31. exa/common/api/model/parameter_model.py +0 -111
  32. exa/common/api/model/setting_model.py +0 -63
  33. exa/common/api/model/setting_node_model.py +0 -72
  34. exa/common/api/model/sweep_model.py +0 -63
  35. exa/common/control/sweep/function_sweep.py +0 -35
  36. exa/common/control/sweep/option/function_options.py +0 -26
  37. exa/common/control/sweep/utils.py +0 -43
  38. iqm_exa_common-25.33.dist-info/RECORD +0 -59
  39. {iqm_exa_common-25.33.dist-info → iqm_exa_common-26.0.dist-info}/LICENSE.txt +0 -0
  40. {iqm_exa_common-25.33.dist-info → iqm_exa_common-26.0.dist-info}/WHEEL +0 -0
  41. {iqm_exa_common-25.33.dist-info → iqm_exa_common-26.0.dist-info}/top_level.txt +0 -0
@@ -101,6 +101,14 @@ The values can be changed with a simple ``=`` syntax:
101
101
 
102
102
  ``node.setting`` refers to the Setting object. ``node.setting.value`` syntax refers to the data stored inside.
103
103
 
104
+ ``SettingNode`` also supports "the path notation" by default (but not if ``align_name`` is set to ``False``,
105
+ since it cannot be made to work consistently if nodes are allowed to be named differently from their paths):
106
+
107
+ .. doctest:: pulse
108
+
109
+ >>> node['flux.voltage']
110
+
111
+ is the same as ``node['flux']['voltage']``.
104
112
 
105
113
  Basic manipulation
106
114
  ------------------
@@ -116,6 +124,27 @@ Adding and deleting new Settings and nodes is simple:
116
124
 
117
125
  It is usually a good idea to make a copy of the original node, so that it won't be modified accidentally.
118
126
 
127
+ The path notation of ``SettingNode``also works when inserting:
128
+
129
+ .. doctest:: pulse
130
+
131
+ >>> node['flux.my.new.path.foo'] = Setting(Parameter('foo'), 1.0)
132
+
133
+ Any nodes that did not already exist under ``node`` will be inserted (in this case ``flux`` already existed, but
134
+ the rest not, so under ``flux`` the nodes ``my``, ``new``, and ``path`` would be added), and then finally the
135
+ value is added as child to the final node. Note: ``SettingNode`` always alings the path and name of any nodes under it,
136
+ so this would result in the new setting being renamed as "flux.my.new.path.foo":
137
+
138
+ .. doctest:: pulse
139
+
140
+ >>> node['flux.my.new.path.foo'] = Setting(Parameter('bar'), 1.0)
141
+
142
+ If ``align_name`` is set to ``False", the name and path of nodes are not automatically aligned, but otherwise the above
143
+ path notation will still work. The added nodes will be named by just their path fragments ("my", "new", "path", and
144
+ so on), and the Setting will be added under the key "foo", but it will still retain its name "bar". Note: the root node
145
+ name will always be excluded from the paths (and names when they are aligned with the path), so that the path of
146
+ ``root.foo.bar`` is ``"foo.bar"``.
147
+
119
148
  To merge values of two SettingNodes, there are helpers :meth:`.SettingNode.merge` and
120
149
  :meth:`.SettingNode.merge_values`.
121
150
 
@@ -177,20 +206,45 @@ flag is used.
177
206
  from __future__ import annotations
178
207
 
179
208
  from collections.abc import Generator, ItemsView, Iterator
180
- from copy import copy, deepcopy
209
+ from copy import copy
210
+ from itertools import permutations
181
211
  import logging
182
212
  import numbers
183
213
  import pathlib
184
- from typing import Any
214
+ from typing import Any, Iterable
185
215
 
186
216
  import jinja2
187
217
  import numpy as np
188
218
 
219
+ from exa.common.data.base_model import BaseModel
189
220
  from exa.common.data.parameter import CollectionType, Parameter, Setting
190
221
  from exa.common.errors.exa_error import UnknownSettingError
222
+ from exa.common.qcm_data.chip_topology import sort_components
191
223
 
224
+ logger = logging.getLogger(__name__)
192
225
 
193
- class SettingNode:
226
+
227
+ def _fix_path_recursive(node: SettingNode | dict, path: str) -> SettingNode | dict:
228
+ """Recursively travel the settings tree and fix the ``path``attribute (also aligns ``name``,
229
+ based on the node type). Deep copies all the child nodes.
230
+ """
231
+ settings = {}
232
+ subtrees = {}
233
+ for key, setting in node.settings.items():
234
+ child_path = f"{path}.{key}"
235
+ update_dict = {"path": child_path}
236
+ if node.align_name:
237
+ update_dict["parameter"] = setting.parameter.model_copy(update={"name": child_path})
238
+ settings[key] = setting.model_copy(update=update_dict)
239
+ for key, subnode in node.subtrees.items():
240
+ subtrees[key] = _fix_path_recursive(subnode, f"{path}.{key}")
241
+ node_update_dict = {"path": path, "settings": settings, "subtrees": subtrees}
242
+ if node.align_name:
243
+ node_update_dict["name"] = path
244
+ return node.model_copy(update=node_update_dict, deep=False)
245
+
246
+
247
+ class SettingNode(BaseModel):
194
248
  """A tree-structured :class:`.Setting` container.
195
249
 
196
250
  Each child of the node is a :class:`.Setting`, or another :class:`SettingNode`.
@@ -202,90 +256,176 @@ class SettingNode:
202
256
  >>> from exa.common.data.parameter import Parameter
203
257
  >>> from exa.common.data.setting_node import SettingNode
204
258
  >>> p1 = Parameter("voltage", "Voltage")
205
- >>> settings = SettingNode('name', volt=p1)
206
- >>> settings.volt.parameter is p1
259
+ >>> f1 = Parameter("frequency", "Frequency")
260
+ >>> sub = SettingNode("sub", frequency=f1)
261
+ >>> settings = SettingNode('name', voltage=p1)
262
+ >>> settings.voltage.parameter is p1
207
263
  True
208
- >>> settings['volt'].parameter is p1
264
+ >>> settings['voltage'].parameter is p1
209
265
  True
210
- >>> settings.volt.value is None
266
+ >>> settings.voltage.value is None
211
267
  True
212
- >>> settings.volt = 7 # updates to Setting(p1, 7)
213
- >>> settings.volt.value
268
+ >>> settings.voltage = 7 # updates to Setting(p1, 7)
269
+ >>> settings.voltage.value
214
270
  7
271
+ >>> settings["sub.frequency"] = 8
272
+ >>> settings["sub.frequency"].value
273
+ 8
215
274
 
216
275
  Args:
217
276
  name: Name of the node.
218
- children: The children given as keyword arguments. Each argument must be a :class:`.Setting`,
277
+ settings: Dict of setting path fraqment names (usually the same as the setting name) to the settings. Mostly
278
+ used when deserialising and otherwise left empty.
279
+ subtrees: Dict of child node path fraqment names (usually the same as the child node name) to the settings.
280
+ Mostly used when deserialising and otherwise left empty.
281
+ path: Optionally give a path for the node, by default empty.
282
+ generate_paths: If set ``True``, all subnodes will get their paths autogenerated correctly. Only set to
283
+ ``False`` if the subnodes already have correct paths set (e.g. when deserialising).
284
+ kwargs: The children given as keyword arguments. Each argument must be a :class:`.Setting`,
219
285
  :class:`.Parameter`, or a :class:`SettingNode`. The keywords are used as the names of the nodes.
220
286
  Parameters will be cast into Settings with the value ``None``.
221
287
 
222
288
  """
223
289
 
224
- def __init__(self, name: str, **children):
225
- self._settings: dict[str, Setting] = {}
226
- self._subtrees: dict[str, SettingNode] = {}
227
- self.name = name
228
- self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
229
- for key, child in children.items():
290
+ name: str
291
+ settings: dict[str, Setting] = {}
292
+ subtrees: dict[str, SettingNode] = {}
293
+ path: str = ""
294
+
295
+ align_name: bool = True
296
+
297
+ def __init__(
298
+ self,
299
+ name: str,
300
+ settings: dict[str, Any] | None = None,
301
+ subtrees: dict[str, Any] | None = None,
302
+ *,
303
+ path: str = "",
304
+ align_name: bool = True,
305
+ generate_paths: bool = True,
306
+ **kwargs,
307
+ ):
308
+ settings: dict[str, Any] = settings or {}
309
+ subtrees: dict[str, Any] = subtrees or {}
310
+
311
+ for key, child in kwargs.items():
230
312
  if isinstance(child, Setting):
231
- self._settings[key] = child
313
+ settings[key] = child
232
314
  elif isinstance(child, Parameter):
233
- self._settings[key] = Setting(child, None)
315
+ settings[key] = Setting(parameter=child, value=None, path=key)
234
316
  elif isinstance(child, SettingNode):
235
- self._subtrees[key] = child
317
+ subtrees[key] = child
236
318
  else:
237
319
  raise ValueError(f"{key} should be a Parameter, Setting or a SettingNode, not {type(child)}.")
320
+ super().__init__(
321
+ name=name,
322
+ settings=settings,
323
+ subtrees=subtrees,
324
+ path=path,
325
+ align_name=align_name,
326
+ **kwargs,
327
+ )
328
+
329
+ if generate_paths:
330
+ self._generate_paths_and_names()
331
+
332
+ def _generate_paths_and_names(self) -> None:
333
+ """This method generates the paths and aligns the names when required."""
334
+ for key, child in self.subtrees.items():
335
+ update_path = f"{self.path}.{key}" if self.path else key
336
+ self.subtrees[key] = _fix_path_recursive(child, update_path)
337
+ for key, child in self.settings.items():
338
+ update_path = f"{self.path}.{key}" if self.path else key
339
+ if isinstance(child, Setting):
340
+ update_dict = {"path": update_path}
341
+ if self.align_name:
342
+ update_dict["parameter"] = child.parameter.copy(name=update_path)
343
+ self[key] = child.model_copy(update=update_dict)
344
+ elif self.align_name:
345
+ self[key] = Setting(
346
+ parameter=child.model_copy(update={"name": update_path}), value=None, path=update_path
347
+ )
348
+ if self.path and self.align_name:
349
+ self.name = self.path
238
350
 
239
351
  def __getattr__(self, key):
240
- if key == "_settings":
241
- # Prevent infinite recursion. If _settings actually exists, this method is not called anyway
352
+ if key == "settings":
353
+ # Prevent infinite recursion. If settings actually exists, this method is not called anyway
242
354
  raise AttributeError
243
- if key in self._settings:
244
- return self._settings[key]
245
- if key in self._subtrees:
246
- return self._subtrees[key]
247
- raise UnknownSettingError(f'{self.__class__.__name__} "{self.name}" has no attribute {key}.')
355
+ if key in self.settings:
356
+ return self.settings[key]
357
+ if key in self.subtrees:
358
+ return self.subtrees[key]
359
+ raise UnknownSettingError(
360
+ f'{self.__class__.__name__} "{self.name}" has no attribute {key}. Children: {self.children.keys()}.'
361
+ )
248
362
 
249
363
  def __dir__(self):
250
364
  """List settings and subtree names, so they occur in IPython autocomplete after ``node.<TAB>``."""
251
- return [name for name in list(self._settings) + list(self._subtrees) if name.isidentifier()] + super().__dir__()
365
+ return [name for name in list(self.settings) + list(self.subtrees) if name.isidentifier()] + super().__dir__()
252
366
 
253
367
  def _ipython_key_completions_(self):
254
368
  """List items and subtree names, so they occur in IPython autocomplete after ``node[<TAB>``"""
255
- return [*self._settings, *self._subtrees]
369
+ return [*self.settings, *self.subtrees]
256
370
 
257
371
  def __setattr__(self, key, value):
258
372
  """Overrides default attribute assignment to allow the following syntax: ``self.foo = 3`` which is
259
373
  equivalent to ``self.foo.value.update(3)`` (if ``foo`` is a :class:`.Setting`).
260
374
  """
261
- if isinstance(value, Parameter):
262
- value = value.set(None)
263
- if isinstance(value, Setting):
264
- self._settings[key] = value
265
- self._subtrees.pop(key, None)
375
+ path = self._get_path(key)
376
+ if isinstance(value, (Setting, Parameter)):
377
+ if isinstance(value, Parameter):
378
+ if self.align_name:
379
+ value = value.model_copy(update={"name": path})
380
+ value = Setting(parameter=value, value=None, path=path)
381
+ else:
382
+ update_dict = {"path": path}
383
+ if self.align_name:
384
+ update_dict["parameter"] = value.parameter.model_copy(update={"name": path})
385
+ value = value.model_copy(update=update_dict)
386
+ self.settings[key] = value
387
+ self.subtrees.pop(key, None)
266
388
  elif isinstance(value, SettingNode):
267
- self._subtrees[key] = value
268
- self._settings.pop(key, None)
269
- elif key != "_settings" and key in self._settings: # != prevents infinite recursion
270
- self._settings[key] = self._settings[key].update(value)
389
+ self.subtrees[key] = _fix_path_recursive(value, path)
390
+ self.settings.pop(key, None)
391
+ elif key != "settings" and key in self.settings: # != prevents infinite recursion
392
+ self.settings[key] = self.settings[key].update(value)
271
393
  else:
272
394
  self.__dict__[key] = value
273
395
 
274
396
  def __delattr__(self, key):
275
- if key in self._settings:
276
- del self._settings[key]
277
- elif key in self._subtrees:
278
- del self._subtrees[key]
397
+ if key in self.settings:
398
+ del self.settings[key]
399
+ elif key in self.subtrees:
400
+ del self.subtrees[key]
279
401
  else:
280
402
  del self.__dict__[key]
281
403
 
282
404
  def __getitem__(self, item: str) -> Setting | SettingNode:
283
405
  """Allows dictionary syntax."""
284
- return self.__getattr__(item)
406
+ if len(item.split(".")) == 1:
407
+ return self.__getattr__(item)
408
+ return self.get_node_for_path(item)
285
409
 
286
410
  def __setitem__(self, key: str, value: Any) -> None:
287
411
  """Allows dictionary syntax."""
288
- self.__setattr__(key, value)
412
+ path_fragments = key.split(".")
413
+ node_key = path_fragments[0]
414
+ remaining_path = ".".join(path_fragments[1:])
415
+
416
+ if not remaining_path:
417
+ self.__setattr__(key, value)
418
+ elif node_key in self.children:
419
+ try:
420
+ self[node_key][remaining_path] = value
421
+ except TypeError:
422
+ raise ValueError(f"Path '{key}' is invalid: '{node_key}' is a setting, not a node.")
423
+ else:
424
+ if not isinstance(value, (SettingNode, Setting, Parameter)):
425
+ raise ValueError(
426
+ f"Assigning value {value} to path {key} cannot be done when this path is not found in self."
427
+ )
428
+ self.add_for_path({path_fragments[-1]: value}, path=".".join(path_fragments[:-1]))
289
429
 
290
430
  def __delitem__(self, key):
291
431
  """Allows dictionary syntax."""
@@ -294,8 +434,8 @@ class SettingNode:
294
434
  def __iter__(self):
295
435
  """Allows breadth-first iteration through the tree."""
296
436
  yield self
297
- yield from iter(self._settings.values())
298
- for subtree in self._subtrees.values():
437
+ yield from iter(self.settings.values())
438
+ for subtree in self.subtrees.values():
299
439
  yield from iter(subtree)
300
440
 
301
441
  def nodes_by_type(
@@ -363,21 +503,17 @@ class SettingNode:
363
503
  @property
364
504
  def children(self) -> dict[str, Setting | SettingNode]:
365
505
  """Dictionary of immediate child nodes of this node."""
366
- return {**self._settings, **self._subtrees}
506
+ return {**self.settings, **self.subtrees}
367
507
 
368
508
  @property
369
509
  def child_settings(self) -> ItemsView[str, Setting]:
370
510
  """ItemsView of settings of this node."""
371
- return self._settings.items()
511
+ return self.settings.items()
372
512
 
373
513
  @property
374
514
  def child_nodes(self) -> ItemsView[str, SettingNode]:
375
515
  """ItemsView of immediate child nodes of this node."""
376
- return self._subtrees.items()
377
-
378
- def copy(self) -> SettingNode:
379
- """Return a deepcopy of this SettingNode."""
380
- return deepcopy(self)
516
+ return self.subtrees.items()
381
517
 
382
518
  def get_parent_of(self, name: str) -> SettingNode:
383
519
  """Get the first SettingNode that has a Setting named `name`.
@@ -408,32 +544,49 @@ class SettingNode:
408
544
  return next((item for item in self if item.name == name), None)
409
545
 
410
546
  @staticmethod
411
- def merge(first: SettingNode, second: SettingNode) -> SettingNode:
547
+ def merge(
548
+ first: SettingNode,
549
+ second: SettingNode,
550
+ merge_nones: bool = False,
551
+ align_name: bool = True,
552
+ deep_copy: bool = True,
553
+ ) -> SettingNode:
412
554
  """Recursively combine the tree structures and values of two SettingNodes.
413
555
 
414
556
  In case of conflicting nodes,values in `first` take priority regardless of the replaced content in `second`.
415
- `None` values are never prioritized.
557
+ `None` values are not prioritized unless ``merge_nones`` is set to ``True``.
416
558
 
417
559
  Args:
418
560
  first: SettingNode to merge, whose values and structure take priority
419
561
  second: SettingNode to merge.
562
+ merge_nones: Whether to merge also ``None`` values from ``first`` to ``second``.
563
+ align_name: Whether to align the paths (and also names if ``second`` does not use ``align_name==False``)
564
+ when merging the nodes. Should never be set ``False`` unless the paths in ``first`` already align with
565
+ what they should be in ``second`` (setting it ``False`` in such cases can improve performance).
566
+ deep_copy: Whether to deepcopy or just shallow copy all the sub-nodes. Set to ``False`` with high caution
567
+ and understand the consequences.
420
568
 
421
569
  Returns:
422
570
  A new SettingNode constructed from arguments.
423
571
 
424
572
  """
425
- new = second.copy()
426
- for key, item in first._settings.items():
427
- if item.value is not None:
428
- new[key] = copy(item)
429
- for key, item in first._subtrees.items():
430
- if key in new._subtrees:
431
- new[key] = SettingNode.merge(item, new[key])
573
+ new = second.model_copy(deep=deep_copy)
574
+ for key, item in first.settings.items():
575
+ if merge_nones or item.value is not None:
576
+ if align_name:
577
+ new[key] = item.copy(name=item.name.replace(f"{first.name}.", ""))
578
+ else:
579
+ new.settings[key] = item.copy()
580
+ for key, item in first.subtrees.items():
581
+ item_copy = item.model_copy(update={"name": item.name.replace(f"{first.name}.", "")}, deep=deep_copy)
582
+ subs = new if align_name else new.subtrees
583
+ if key in new.subtrees:
584
+ subs[key] = SettingNode.merge(item_copy, new[key])
432
585
  else:
433
- new[key] = copy(item)
586
+ subs[key] = item_copy
434
587
 
435
588
  for key, item in first.__dict__.items():
436
- if key not in ["_settings", "_subtrees"]:
589
+ if key not in ["settings", "subtrees"]:
437
590
  new[key] = copy(item)
438
591
  return new
439
592
 
@@ -448,17 +601,17 @@ class SettingNode:
448
601
  will be replaced.
449
602
 
450
603
  """
451
- for key, item in other._settings.items():
452
- if key in self._settings and (prioritize_other or (self[key].value is None)):
453
- self[key] = item.value
454
- for key, item in other._subtrees.items():
455
- if key in self._subtrees:
456
- self[key].merge_values(copy(item), prioritize_other)
604
+ for key, item in other.settings.items():
605
+ if key in self.settings and (prioritize_other or (self[key].value is None)):
606
+ self.settings[key] = Setting(self.settings[key].parameter, item.value)
607
+ for key, item in other.subtrees.items():
608
+ if key in self.subtrees:
609
+ self.subtrees[key].merge_values(copy(item), prioritize_other)
457
610
 
458
611
  def prune(self, other: SettingNode) -> None:
459
612
  """Recursively delete all branches from this SettingNode that are not found in ``other``."""
460
- for key, node in self._subtrees.copy().items():
461
- if key not in other._subtrees:
613
+ for key, node in self.subtrees.copy().items():
614
+ if key not in other.subtrees:
462
615
  del self[key]
463
616
  else:
464
617
  self[key].prune(other[key])
@@ -471,10 +624,10 @@ class SettingNode:
471
624
 
472
625
  """
473
626
 
474
- def append_lines(key: str, node: SettingNode, lines: List[str], indents: List[bool]): # noqa: F821
627
+ def append_lines(node: SettingNode, lines: list[str], indents: list[bool]):
475
628
  indent = "".join([" ║ " if i else " " for i in indents])
476
629
  if len(indents) < levels:
477
- for key, setting in node._settings.items(): # noqa: PLR1704
630
+ for key, setting in node.settings.items():
478
631
  if setting.value is None:
479
632
  value = "None (automatic/unspecified)"
480
633
  elif setting.parameter.collection_type == CollectionType.NDARRAY:
@@ -482,23 +635,25 @@ class SettingNode:
482
635
  else:
483
636
  value = f"{setting.value} {setting.unit}"
484
637
  lines.append(indent + f" ╠─ {key}: {setting.label} = {value}")
485
- if node._subtrees:
638
+ if node.subtrees:
486
639
  lines.append(indent + " ║ ")
487
640
  subtrees_written = 0
488
- for key, subtree in node._subtrees.items():
489
- lines.append(indent + f' ╠═ {key}: "{subtree.name}"')
490
- if subtrees_written == len(node._subtrees) - 1:
641
+ for key, subtree in node.subtrees.items():
642
+ lines.append(indent + f' ╠═ {key}: "{subtree.name}: align_name={subtree.align_name}"')
643
+ if subtrees_written == len(node.subtrees) - 1:
491
644
  lines[-1] = lines[-1].replace("╠", "╚")
492
- append_lines(key, subtree, lines, indents + [subtrees_written < len(node._subtrees) - 1])
645
+ append_lines(subtree, lines, indents + [subtrees_written < len(node.subtrees) - 1])
493
646
  subtrees_written += 1
494
647
  lines[-1] = lines[-1].replace("╠", "╚")
495
648
 
496
- lines = [f'"{self.name}"']
497
- append_lines("", self, lines, [])
649
+ lines = [f'"{self.name}: align_name={self.align_name}"']
650
+ append_lines(self, lines, [])
498
651
  print("\n", "\n".join(lines))
499
652
 
500
653
  def __eq__(self, other: SettingNode):
501
- return isinstance(other, SettingNode) and self.children == other.children and self.name == other.name
654
+ return isinstance(other, SettingNode) and (
655
+ (self.name, self.settings, self.subtrees) == (other.name, other.settings, other.subtrees)
656
+ )
502
657
 
503
658
  def __repr__(self):
504
659
  return f"{self.__class__.__name__}{self.children}"
@@ -518,9 +673,9 @@ class SettingNode:
518
673
  A new SettingNode with the same structure as the original, but where node instances are of type `cls`.
519
674
 
520
675
  """
521
- new = cls(name=node.name, **node._settings)
522
- for key, subnode in node._subtrees.items():
523
- new[key] = cls.transform_node_types(subnode)
676
+ new = cls(name=node.name, **node.settings, align_name=node.align_name, generate_paths=False)
677
+ for key, subnode in node.subtrees.items():
678
+ new.subtrees[key] = cls.transform_node_types(subnode)
524
679
  return new
525
680
 
526
681
  def set_from_dict(self, dct: dict[str, Any], strict: bool = False) -> None:
@@ -538,57 +693,24 @@ class SettingNode:
538
693
  for key, value in dct.items():
539
694
  if key not in self.children:
540
695
  error = UnknownSettingError(f"Tried to set {key} to {value}, but no such node exists in {self.name}.")
541
- self.logger.debug(error.message)
696
+ logger.debug(error.message)
542
697
  if strict:
543
698
  raise error
544
699
  continue
545
700
  if isinstance(value, dict) and isinstance(self[key], SettingNode):
546
701
  self[key].set_from_dict(value)
702
+ elif type(value) is str:
703
+ self.settings[key] = Setting(
704
+ self.settings[key].name,
705
+ self.settings[key].parameter.data_type.cast(value),
706
+ path=self.settings[key].path,
707
+ )
547
708
  else:
548
- self[key] = self[key].parameter.data_type.cast(value) if type(value) == str else value # noqa: E721
709
+ self.settings[key] = self.settings[key].update(value)
549
710
 
550
711
  def setting_with_path_name(self, setting: Setting) -> Setting:
551
- """Get Setting from ``self`` where the name of the parameter is replaced with its path in ``self``.
552
-
553
- The path is defined as the sequence of attribute keys leading to the node with ``setting`` in it.
554
- The method is used for conveniently saving settings tree observations with their settings tree path
555
- as the ``dut_field``.
556
-
557
- Args:
558
- setting: Setting to search for.
559
-
560
- Returns:
561
- Copy of Setting ``setting`` where the parameter name is replaced with its path in ``self``.
562
-
563
- Raises:
564
- UnknownSettingError: If ``name`` is not found within ``self``.
565
-
566
- """
567
-
568
- def _search(settings: SettingNode, name: str, prefix: str) -> tuple[str, Setting] | tuple[None, None]:
569
- for key, value in settings.children.items():
570
- path_prefix = f"{prefix}.{key}" if prefix else key
571
- if isinstance(value, Setting) and value.name == setting.name:
572
- return path_prefix, value
573
- if isinstance(value, SettingNode) and value.find_by_name(setting.name):
574
- return _search(value, setting, path_prefix)
575
- return None, None
576
-
577
- path, found_setting = _search(self, setting, "")
578
- if path is None:
579
- raise UnknownSettingError(f'{name} not found inside {self.__class__.__name__} "{self.name}".') # noqa: F821
580
- param = found_setting.parameter
581
- return Setting(
582
- Parameter(
583
- name=path,
584
- label=param.label,
585
- unit=param.unit,
586
- data_type=param.data_type,
587
- collection_type=param.collection_type,
588
- element_indices=param.element_indices,
589
- ),
590
- found_setting.value,
591
- )
712
+ """Get a copy of a setting with its name replaced with the path name."""
713
+ return self.find_by_name(setting.name).with_path_name()
592
714
 
593
715
  def diff(self, other: SettingNode, *, path: str = "") -> list[str]:
594
716
  """Recursive diff between two SettingNodes.
@@ -637,9 +759,9 @@ class SettingNode:
637
759
  node_diff.append(f"node name: {self.name}/{other.name}")
638
760
 
639
761
  # compare settings
640
- b_keys = set(other._settings)
641
- for key, a_setting in self._settings.items():
642
- b_setting = other._settings.get(key)
762
+ b_keys = set(other.settings)
763
+ for key, a_setting in self.settings.items():
764
+ b_setting = other.settings.get(key)
643
765
  if b_setting is None:
644
766
  node_diff.append(f"-setting: {key}")
645
767
  continue
@@ -647,21 +769,21 @@ class SettingNode:
647
769
  if a_setting != b_setting:
648
770
  node_diff.append(diff_settings(a_setting, b_setting, key))
649
771
  for key in b_keys:
650
- if key not in self._settings:
772
+ if key not in self.settings:
651
773
  node_diff.append(f"+setting: {key}")
652
774
 
653
775
  # compare subnodes
654
776
  diff_subnodes: list[tuple[SettingNode, SettingNode, str]] = []
655
- b_keys = set(other._subtrees)
656
- for key, a_sub in self._subtrees.items():
657
- b_sub = other._subtrees.get(key)
777
+ b_keys = set(other.subtrees)
778
+ for key, a_sub in self.subtrees.items():
779
+ b_sub = other.subtrees.get(key)
658
780
  if b_sub is None:
659
781
  node_diff.append(f"-subnode: {key}")
660
782
  else:
661
783
  b_keys.remove(key)
662
784
  diff_subnodes.append((a_sub, b_sub, key))
663
785
  for key in b_keys:
664
- if key not in self._subtrees:
786
+ if key not in self.subtrees:
665
787
  node_diff.append(f"+subnode: {key}")
666
788
 
667
789
  # add path prefixes
@@ -704,3 +826,227 @@ class SettingNode:
704
826
  jenv = jinja2.Environment(loader=jinja2.FileSystemLoader(tmpl_path), auto_reload=True)
705
827
 
706
828
  return jenv.get_template("settingnode_v2.html.jinja2").render(s=self, withsi=self._withsiprefix, startopen=0)
829
+
830
+ def get_node_for_path(self, path: str) -> Setting | SettingNode:
831
+ """Return the node corresponding to the given path.
832
+
833
+ Args:
834
+ path: The path.
835
+
836
+ Returns:
837
+ The node at ``path`` in self.
838
+
839
+ Raises:
840
+ ValueError: If the given path cannot be found in self.
841
+
842
+ """
843
+ keys = path.split(".")
844
+ node = self
845
+ for index, key in enumerate(keys, start=1):
846
+ if index < len(keys) and key in node.settings:
847
+ raise ValueError(f"Path '{path}' is invalid: '{key}' is a setting, not a node.")
848
+ if key not in node.children:
849
+ raise KeyError(f"Path '{path}' is invalid: key '{key}' is not found in the preceding node {node}.")
850
+ node = node[key]
851
+ return node
852
+
853
+ def add_for_path(
854
+ self,
855
+ nodes: Iterable[Setting | Parameter | SettingNode] | dict[str, Setting | Parameter | SettingNode],
856
+ path: str,
857
+ override_values: dict[str, Any] | None = None,
858
+ ) -> None:
859
+ """Add nodes to ``self`` while creating the missing nodes in-between.
860
+
861
+ Whether the names and paths are aligned is determined by the attribute ``align_name`` of the current node
862
+ (``self``). All the created missing nodes will use this same ``align_name`` value,
863
+ which determines whether their names will align with their paths.
864
+
865
+ Args:
866
+ nodes: Nodes to add as new leaves/branches of ``path``. If of type ``dict``, maps the keys used in
867
+ ``self.settings`` or ``self.subtrees`` to the nodes themselves. If ``align_name=False``, the key and
868
+ the node name can differ, but otherwise the names will be replaced by the path anyways).
869
+ path: Path in ``self`` to which ``nodes`` will be added. If the path or any part (suffix) of it is not
870
+ found in self, the associated nodes will be created automatically.
871
+ override_values: Optionally override the values for the `Settings` corresponding to ``nodes``. This dict
872
+ should have the same structure as ``nodes``, including matching names.
873
+
874
+ """
875
+ override_values = override_values or {}
876
+ path_split = path.split(".")
877
+ # find the depth already found in self
878
+ latest_node = self
879
+ levels_to_add = []
880
+ for idx, fragment in enumerate(path_split):
881
+ if fragment in latest_node.children:
882
+ latest_node = latest_node[fragment]
883
+ else:
884
+ levels_to_add = path_split[idx:]
885
+ break
886
+ # add the missing levels
887
+ for fragment in levels_to_add:
888
+ latest_node[fragment] = SettingNode(name=fragment, align_name=latest_node.align_name)
889
+ latest_node = latest_node[fragment]
890
+ # finally add the nodes
891
+ nodes_to_add = nodes.values() if isinstance(nodes, dict) else nodes
892
+ nodes_keys = list(nodes.keys()) if isinstance(nodes, dict) else []
893
+ for idx, node in enumerate(nodes_to_add):
894
+ key = nodes_keys[idx] if isinstance(nodes, dict) else node.name.split(".")[-1]
895
+ if isinstance(node, SettingNode):
896
+ latest_node[key] = node
897
+ else:
898
+ default_value = node.value if isinstance(node, Setting) else None
899
+ parameter = node.parameter if isinstance(node, Setting) else node
900
+ value = override_values.get(node.name) if override_values.get(node.name) is not None else default_value
901
+ latest_node[key] = Setting(parameter, value)
902
+
903
+ def get_default_implementation_name(self, gate: str, locus: str | Iterable[str]) -> str:
904
+ """Get the default implementation name for a given gate and locus.
905
+
906
+ Takes into account the global default implementation and a possible locus specific implementation and also
907
+ the symmetry properties of the gate.
908
+
909
+ NOTE: using this method requires the standard EXA settings tree structure.
910
+
911
+ Args:
912
+ gate: The name of the gate.
913
+ locus: Individual qubits, couplers, or combinations.
914
+
915
+ Returns:
916
+ The default implementation name.
917
+
918
+ """
919
+ gate_settings = self.gate_definitions[gate]
920
+ for impl in gate_settings.children:
921
+ if (
922
+ isinstance(gate_settings[impl], SettingNode)
923
+ and impl not in ("symmetric", "default_implementation") # sanity check
924
+ and "override_default_for_loci" in gate_settings[impl].children # backwards compatibility
925
+ and gate_settings[impl].override_default_for_loci.value
926
+ ):
927
+ if isinstance(locus, str):
928
+ locus = locus.split("__")
929
+ if gate_settings.symmetric.value:
930
+ loci = list(permutations(locus))
931
+ else:
932
+ loci = [tuple(locus)]
933
+ for permuted_locus in loci:
934
+ locus_str = "__".join(permuted_locus)
935
+ if locus_str in gate_settings[impl].override_default_for_loci.value:
936
+ return impl
937
+ return gate_settings.default_implementation.value
938
+
939
+ def get_gate_node_for_locus(
940
+ self,
941
+ gate: str,
942
+ locus: str | Iterable[str],
943
+ implementation: str | None = None,
944
+ ) -> SettingNode:
945
+ """Get the gate calibration sub-node for the locus given as a parameter if it exists in the settings tree.
946
+
947
+ NOTE: using this method requires the standard EXA settings tree structure.
948
+
949
+ Args:
950
+ gate: The gate to retrieve the settings for.
951
+ locus: Individual qubits, couplers, or combinations.
952
+ implementation: Using a custom rather than the default gate implementation.
953
+
954
+ Returns:
955
+ The settings of the specified locus and gate.
956
+
957
+ """
958
+ pulse_settings = self["gates"] if "gates" in self.children else self
959
+
960
+ if gate not in pulse_settings.children:
961
+ raise ValueError(f"Gate {gate} cannot be found in the pulse settings.")
962
+
963
+ if not implementation:
964
+ implementation = self.get_default_implementation_name(gate, locus)
965
+
966
+ if implementation not in pulse_settings[gate].children:
967
+ raise ValueError(f"Gate implementation {implementation} cannot be found in the pulse settings.")
968
+
969
+ str_loci = self._get_symmetric_loci(gate, implementation, locus)
970
+
971
+ for str_locus in str_loci:
972
+ if str_locus in pulse_settings[gate][implementation].children:
973
+ return pulse_settings[gate][implementation][str_locus]
974
+
975
+ raise ValueError(f"Locus {locus} cannot be found in the pulse settings.")
976
+
977
+ def get_locus_node_paths_for(self, gate: str, implementations: list[str] | None = None) -> list[str]:
978
+ """Get all the gate locus node paths for a given ``gate``.
979
+
980
+ NOTE: using this method requires the standard EXA settings tree structure.
981
+
982
+ Args:
983
+ gate: Gate name.
984
+ implementations: optionally limit the paths by these gate implementations.
985
+
986
+ Returns:
987
+ The locus node (string) paths corresponding to this gate.
988
+
989
+ """
990
+ node_paths = []
991
+ if "gates" not in self.children or gate not in self.gates.children:
992
+ return node_paths
993
+ if implementations is not None:
994
+ impls = [i for i in self.gates[gate].children if i in implementations]
995
+ else:
996
+ impls = self.gates[gate].children
997
+ for impl_name in impls:
998
+ if isinstance(self.gates[gate][impl_name], SettingNode):
999
+ for locus in self.gates[gate][impl_name].children:
1000
+ if isinstance(self.gates[gate][impl_name][locus], SettingNode):
1001
+ node_paths.append(f"{gate}.{impl_name}.{locus}")
1002
+ return node_paths
1003
+
1004
+ def get_gate_properties_for_locus(
1005
+ self, gate: str, locus: str | Iterable[str], implementation: str | None = None
1006
+ ) -> SettingNode:
1007
+ """Get the gate characterization sub-node for the locus given as a parameter if it exists in the settings tree.
1008
+
1009
+ NOTE: using this method requires the standard EXA settings tree structure.
1010
+
1011
+ Args:
1012
+ gate: The gate to retrieve the settings for.
1013
+ locus: Individual qubits, couplers, or combinations.
1014
+ implementation: Using a custom rather than the default gate implementation.
1015
+
1016
+ Returns:
1017
+ The settings of the specified locus and gate.
1018
+
1019
+ """
1020
+ if not implementation:
1021
+ implementation = self.get_default_implementation_name(gate, locus)
1022
+ gate_properties = self.characterization.gate_properties
1023
+ if gate not in gate_properties.children:
1024
+ raise ValueError(f"Gate {gate} cannot be found in the characterization settings.")
1025
+ if implementation not in gate_properties[gate].children:
1026
+ raise ValueError(
1027
+ f"Implementation {implementation} of Gate {gate} cannot be found in the characterization settings."
1028
+ )
1029
+
1030
+ str_loci = self._get_symmetric_loci(gate, implementation, locus)
1031
+ for str_locus in str_loci:
1032
+ if str_locus in gate_properties[gate][implementation].children:
1033
+ return gate_properties[gate][implementation][str_locus]
1034
+
1035
+ raise ValueError(f"Locus {locus} cannot be found in the gate properties characterization settings.")
1036
+
1037
+ def _get_symmetric_loci(self, gate: str, implementation: str, locus: str) -> list[str]:
1038
+ if not isinstance(locus, str):
1039
+ if self.gate_definitions[gate][implementation].symmetric.value:
1040
+ str_loci = ["__".join(sort_components(locus))]
1041
+ elif self.gate_definitions[gate].symmetric.value:
1042
+ str_loci = ["__".join(item) for item in list(permutations(locus))]
1043
+ else:
1044
+ str_loci = ["__".join(locus)]
1045
+ else:
1046
+ str_loci = [locus]
1047
+ return str_loci
1048
+
1049
+ def _get_path(self, key) -> str:
1050
+ if not self.path:
1051
+ return key
1052
+ return f"{self.path}.{key}"