Skip to content

Commit f11aaee

Browse files
Normalize list-selection order to configuration order across every holder
1 parent e604efa commit f11aaee

9 files changed

Lines changed: 142 additions & 27 deletions

File tree

‎pyaml/common/holders/diagnostic_holder.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ def __getitem__(self, key: str | list[str] | tuple[str, ...]) -> "Element | Elem
124124
store = self._peer._DIAG
125125
if isinstance(key, str) and not key.startswith(("re:", "~")) and not is_wildcard(key):
126126
return self._peer._get_diagnostic(key)
127-
names = resolve_names(store.keys(), key, what="Diagnostic")
128-
return ElementArray("", [store[n] for n in names])
127+
matched = set(resolve_names(store.keys(), key, what="Diagnostic"))
128+
return ElementArray("", [v for n, v in store.items() if n in matched])
129129

130130
@property
131131
def betatron_tune(self) -> BetatronTuneMonitor:

‎pyaml/common/holders/element_holder.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ def find_elements(self, filter: str | list[str] | tuple[str, ...]) -> list[str]:
336336
If a literal pattern (or a ``~``-prefixed literal) matches no
337337
element, or a ``re:`` pattern is not a valid regular expression.
338338
"""
339-
return resolve_names(self._ALL.keys(), filter, what="Element")
339+
matched = set(resolve_names(self._ALL.keys(), filter, what="Element"))
340+
return [n for n in self._ALL if n in matched]
340341

341342
def _fill_array(
342343
self,
@@ -518,14 +519,14 @@ def __getitem__(self, key: int | slice | str | list[str] | tuple[str, ...]) -> E
518519
"""
519520
if isinstance(key, str):
520521
if key.startswith("re:") or key.startswith("~") or is_wildcard(key):
521-
names = resolve_names(self._ALL.keys(), key)
522-
return self.get()._typed_array([self._ALL[n] for n in names])
522+
matched = set(resolve_names(self._ALL.keys(), key))
523+
return self.get()._typed_array([v for n, v in self._ALL.items() if n in matched])
523524
if key not in self._ALL:
524525
raise PyAMLException(f"Element {key} not defined")
525526
return self._ALL[key]
526527
if isinstance(key, (list, tuple)):
527-
names = resolve_names(self._ALL.keys(), key)
528-
return self.get()._typed_array([self._ALL[n] for n in names])
528+
matched = set(resolve_names(self._ALL.keys(), key))
529+
return self.get()._typed_array([v for n, v in self._ALL.items() if n in matched])
529530
if isinstance(key, int):
530531
return list(self._ALL.values())[key]
531532
if isinstance(key, slice):

‎pyaml/common/holders/rf_holder.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ def __getitem__(self, key: str | list[str] | tuple[str, ...]) -> "RFTransmitter
8080
store = self._peer._RFTRANSMITTER
8181
if isinstance(key, str) and not key.startswith(("re:", "~")) and not is_wildcard(key):
8282
return self._peer._get("RFTransmitter", key, store)
83-
names = resolve_names(store.keys(), key, what="RFTransmitter")
84-
return ElementArray("", [store[n] for n in names])
83+
matched = set(resolve_names(store.keys(), key, what="RFTransmitter"))
84+
return ElementArray("", [v for n, v in store.items() if n in matched])
8585

8686
def add(self, rf: RFTransmitter):
8787
"""
@@ -195,8 +195,8 @@ def __getitem__(self, key: str | list[str] | tuple[str, ...]) -> "RFPlant | Elem
195195
store = self._peer._RFPLANT
196196
if isinstance(key, str) and not key.startswith(("re:", "~")) and not is_wildcard(key):
197197
return self._peer._get("RFPlant", key, store)
198-
names = resolve_names(store.keys(), key, what="RFPlant")
199-
return ElementArray("", [store[n] for n in names])
198+
matched = set(resolve_names(store.keys(), key, what="RFPlant"))
199+
return ElementArray("", [v for n, v in store.items() if n in matched])
200200

201201
def add(self, rf: RFPlant):
202202
"""

‎pyaml/common/holders/tool_holder.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ def __getitem__(self, key: str | list[str] | tuple[str, ...]) -> "Element | Elem
115115
store = self._peer._TOOLS
116116
if isinstance(key, str) and not key.startswith(("re:", "~")) and not is_wildcard(key):
117117
return self._peer._get_tool(key)
118-
names = resolve_names(store.keys(), key, what="Tool")
119-
return ElementArray("", [store[n] for n in names])
118+
matched = set(resolve_names(store.keys(), key, what="Tool"))
119+
return ElementArray("", [v for n, v in store.items() if n in matched])
120120

121121
def _validate_type(self, name: str, obj: Element, expected_type: type) -> Element:
122122
"""

‎tests/common/test_array_holder_navigation.py‎

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,17 @@ def test_configured_exclusion_family_reproduced_with_selection_and_difference():
116116

117117

118118
def test_configured_exclusion_family_reproduced_in_a_single_getitem_call():
119-
"""The YAML `elements:` selector list (`[QD2*, QF1*, ~QF1E-C05, ~Q???-C06]`) works verbatim through `[]`.
119+
"""The YAML `elements:` selector list (`[QD2*, QF1*, ~QF1E-C05, ~Q???-C06]`) selects the
120+
same *family* of elements through `[]` as declared for `QForTest`.
120121
121122
Selection goes through the top-level holder, not `.magnets`, so it resolves patterns
122123
against the same global element pool `find_elements()`/`_fill_array` use to build
123-
`QForTest` in the YAML, and so lands in the same order.
124+
`QForTest` in the YAML. The two therefore agree on *which* elements are selected, but not
125+
necessarily on their order: `_fill_array` concatenates matches pattern by pattern (all
126+
`QD2*` matches, then all `QF1*` matches) to build the named array, while `[]` (like every
127+
other list-selection accessor, see `test_every_accessor_returns_a_list_selection_in_configuration_order`)
128+
normalizes its result to configuration order regardless of how many patterns were
129+
involved or in what order they matched.
124130
"""
125131
sr = Accelerator.load("tests/config/EBSTune-patterns.yaml", ignore_external=True)
126132
sr.design.get_lattice().disable_6d()
@@ -129,7 +135,8 @@ def test_configured_exclusion_family_reproduced_in_a_single_getitem_call():
129135
reproduced = sr.design[["QD2*", "QF1*", "~QF1E-C05", "~Q???-C06"]]
130136

131137
assert type(reproduced) is type(q_for_test)
132-
assert reproduced == q_for_test
138+
assert set(reproduced.names()) == set(q_for_test.names())
139+
assert reproduced.names() == [n for n in sr.design.get().names() if n in set(q_for_test.names())]
133140

134141

135142
def test_array_getitem_supports_a_negated_regex_pattern():
@@ -193,7 +200,9 @@ def test_magnet_holder_getitem_wildcard_returns_a_magnet_array(holder):
193200
def test_magnet_and_magnets_holder_getitem_agree_on_order(holder):
194201
""".magnet (singular) and .magnets (plural) must return the same elements in the same
195202
order for the same name list, regardless of the order the names were requested in. The
196-
reference order is the configuration order, not the order of the requested names."""
203+
reference order is the configuration order, not the order of the requested names. This
204+
must also hold for the top-level holder itself (`[]` and `find_elements`), not just its
205+
typed sub-holders."""
197206
forward = holder.magnet[["SH1A-C01-H", "SH1A-C01-V"]]
198207
backward = holder.magnet[["SH1A-C01-V", "SH1A-C01-H"]]
199208
assert forward.names() == backward.names() == ["SH1A-C01-H", "SH1A-C01-V"]
@@ -202,6 +211,33 @@ def test_magnet_and_magnets_holder_getitem_agree_on_order(holder):
202211
backward_plural = holder.magnets[["SH1A-C01-V", "SH1A-C01-H"]]
203212
assert forward_plural.names() == backward_plural.names() == forward.names()
204213

214+
forward_top_level = holder[["SH1A-C01-H", "SH1A-C01-V"]]
215+
backward_top_level = holder[["SH1A-C01-V", "SH1A-C01-H"]]
216+
assert forward_top_level.names() == backward_top_level.names() == forward.names()
217+
218+
assert holder.find_elements(["SH1A-C01-H", "SH1A-C01-V"]) == forward.names()
219+
assert holder.find_elements(["SH1A-C01-V", "SH1A-C01-H"]) == forward.names()
220+
221+
222+
def test_combined_function_magnet_and_bpm_holders_return_list_selections_in_configuration_order(holder):
223+
"""Every typed sub-holder must normalize list selections to configuration order, not just
224+
.magnet/.magnets: this must also hold for .combined_function_magnet(s) and .bpm(s)."""
225+
cfm_forward = holder.combined_function_magnet[["SH1A-C01", "SH1A-C02"]]
226+
cfm_backward = holder.combined_function_magnet[["SH1A-C02", "SH1A-C01"]]
227+
assert cfm_forward.names() == cfm_backward.names() == ["SH1A-C01", "SH1A-C02"]
228+
229+
cfm_forward_plural = holder.combined_function_magnets[["SH1A-C01", "SH1A-C02"]]
230+
cfm_backward_plural = holder.combined_function_magnets[["SH1A-C02", "SH1A-C01"]]
231+
assert cfm_forward_plural.names() == cfm_backward_plural.names() == cfm_forward.names()
232+
233+
bpm_forward = holder.diagnostic.bpm[["BPM_C04-01", "BPM_C04-02"]]
234+
bpm_backward = holder.diagnostic.bpm[["BPM_C04-02", "BPM_C04-01"]]
235+
assert bpm_forward.names() == bpm_backward.names() == ["BPM_C04-01", "BPM_C04-02"]
236+
237+
bpm_forward_plural = holder.diagnostic.bpms[["BPM_C04-01", "BPM_C04-02"]]
238+
bpm_backward_plural = holder.diagnostic.bpms[["BPM_C04-02", "BPM_C04-01"]]
239+
assert bpm_forward_plural.names() == bpm_backward_plural.names() == bpm_forward.names()
240+
205241

206242
@pytest.mark.parametrize(
207243
"select",
@@ -214,12 +250,29 @@ def test_magnet_and_magnets_holder_getitem_agree_on_order(holder):
214250
],
215251
)
216252
@pytest.mark.parametrize("reverse", [False, True], ids=["forward", "reversed"])
217-
def test_every_accessor_returns_a_list_selection_in_configuration_order(holder, select, reverse):
253+
@pytest.mark.parametrize(
254+
"key, expected_members",
255+
[
256+
pytest.param(
257+
["SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H"],
258+
{"SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H"},
259+
id="literal-names",
260+
),
261+
pytest.param(
262+
["SH1A-C0?-H", "SH1A-C01-V"],
263+
{"SH1A-C01-H", "SH1A-C02-H", "SH1A-C01-V"},
264+
id="wildcard-and-literal",
265+
),
266+
],
267+
)
268+
def test_every_accessor_returns_a_list_selection_in_configuration_order(holder, select, reverse, key, expected_members):
218269
"""Every name-based accessor must return a list-of-patterns selection in configuration
219-
order, whatever the order of the requested names, so that positional values line up the
220-
same way whichever accessor built the array."""
221-
key = ["SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H"]
222-
expected = [n for n in holder.get().names() if n in key]
270+
order, whatever the order of the requested names/patterns, so that positional values line
271+
up the same way whichever accessor built the array. This must hold both for a list of
272+
literal names and for a list mixing a wildcard (matching several names) with a literal
273+
name, since a wildcard entry is where a naive pattern-order passthrough diverges from
274+
configuration order the most visibly."""
275+
expected = [n for n in holder.get().names() if n in expected_members]
223276
assert select(holder, key[::-1] if reverse else key) == expected
224277

225278

‎tests/common/test_element_holder_collection.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,9 @@ def test_invalid_regex_raises_pyaml_exception(holder):
226226

227227

228228
def test_list_of_patterns_unions_results(holder):
229+
"""Order follows configuration (registration) order, not the order patterns were requested in."""
229230
selected = holder[["BPM_C04-01", "SH1A-C0?-H"]]
230-
assert selected.names() == ["BPM_C04-01", "SH1A-C01-H", "SH1A-C02-H"]
231+
assert selected.names() == ["SH1A-C01-H", "SH1A-C02-H", "BPM_C04-01"]
231232

232233

233234
def test_list_of_patterns_with_missing_literal_raises(holder):
@@ -253,20 +254,21 @@ def test_find_elements_supports_regex(holder):
253254

254255

255256
def test_find_elements_supports_a_list_of_patterns(holder):
256-
assert holder.find_elements(["BPM_C04-01", "SH1A-C0?-H"]) == ["BPM_C04-01", "SH1A-C01-H", "SH1A-C02-H"]
257+
"""Order follows configuration (registration) order, not the order patterns were requested in."""
258+
assert holder.find_elements(["BPM_C04-01", "SH1A-C0?-H"]) == ["SH1A-C01-H", "SH1A-C02-H", "BPM_C04-01"]
257259

258260

259261
def test_find_elements_supports_an_exclusion_in_a_list(holder):
260262
both_planes = holder.find_elements(["SH1A-C0?-H", "SH1A-C0?-V"])
261-
assert both_planes == ["SH1A-C01-H", "SH1A-C02-H", "SH1A-C01-V", "SH1A-C02-V"]
263+
assert both_planes == ["SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H", "SH1A-C02-V"]
262264

263265
minus_one = holder.find_elements(["SH1A-C0?-H", "SH1A-C0?-V", "~SH1A-C02-V"])
264-
assert minus_one == ["SH1A-C01-H", "SH1A-C02-H", "SH1A-C01-V"]
266+
assert minus_one == ["SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H"]
265267

266268

267269
def test_getitem_supports_an_exclusion_in_a_list(holder):
268270
selected = holder[["SH1A-C0?-H", "SH1A-C0?-V", "~SH1A-C02-V"]]
269-
assert selected.names() == ["SH1A-C01-H", "SH1A-C02-H", "SH1A-C01-V"]
271+
assert selected.names() == ["SH1A-C01-H", "SH1A-C01-V", "SH1A-C02-H"]
270272

271273

272274
def test_getitem_lone_exclusion_pattern_means_everything_except(holder):

‎tests/diagnostics/test_diagnostic_accessors.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import copy
2+
13
import pytest
24

35
from pyaml.accelerator import Accelerator
@@ -114,6 +116,27 @@ def test_diagnostic_getitem_wildcard_returns_an_array():
114116
assert design.diagnostic["MISSING*"].names() == []
115117

116118

119+
def test_diagnostic_getitem_list_selection_ignores_request_order():
120+
"""A list-of-patterns selection lands in configuration order, whatever order the names
121+
were requested in, same as every other holder (see
122+
test_every_accessor_returns_a_list_selection_in_configuration_order). Only one diagnostic
123+
is registered by any existing fixture, so a distinct second entry is poked directly into
124+
the store (a renamed copy of the existing monitor) to make request order and
125+
configuration order observably different."""
126+
design = Accelerator.load(
127+
"tests/config/EBSOrbit.yaml",
128+
ignore_external=True,
129+
include_locations=False,
130+
).design
131+
spare_monitor = copy.copy(design.get_betatron_tune_monitor("BETATRON_TUNE"))
132+
spare_monitor._name = "SPARE_BETATRON_TUNE"
133+
design._DIAG["SPARE_BETATRON_TUNE"] = spare_monitor
134+
135+
forward = design.diagnostic[["BETATRON_TUNE", "SPARE_BETATRON_TUNE"]]
136+
backward = design.diagnostic[["SPARE_BETATRON_TUNE", "BETATRON_TUNE"]]
137+
assert forward.names() == backward.names() == ["BETATRON_TUNE", "SPARE_BETATRON_TUNE"]
138+
139+
117140
def test_diagnostic_bpm_getitem_exact_name_matches_get():
118141
design = Accelerator.load(
119142
"tests/config/EBSOrbit.yaml",

‎tests/rf/test_rf_holder.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import copy
2+
13
import pytest
24

35
from pyaml.accelerator import Accelerator
@@ -43,6 +45,31 @@ def test_rf_transmitter_holder_getitem_list_of_patterns(design):
4345
assert selected.names() == ["RFTRA1", "RFTRA2"]
4446

4547

48+
def test_rf_transmitter_holder_getitem_list_selection_ignores_request_order(design):
49+
"""A list-of-patterns selection lands in configuration order, whatever order the names
50+
were requested in, same as every other holder (see
51+
test_every_accessor_returns_a_list_selection_in_configuration_order)."""
52+
forward = design.rf.transmitter[["RFTRA1", "RFTRA2"]]
53+
backward = design.rf.transmitter[["RFTRA2", "RFTRA1"]]
54+
assert forward.names() == backward.names() == ["RFTRA1", "RFTRA2"]
55+
56+
57+
def test_rf_holder_getitem_list_selection_ignores_request_order(design):
58+
"""Same configuration-order guarantee as the RF transmitter holder, at the RF plant level.
59+
60+
Only one RF plant is registered by this fixture, so a distinct second entry is poked
61+
directly into the store (a renamed copy of the existing plant) to make request order and
62+
configuration order observably different.
63+
"""
64+
spare_plant = copy.copy(design.rf.get("DEFAULT_RF_PLANT"))
65+
spare_plant._name = "SPARE_RF_PLANT"
66+
design._RFPLANT["SPARE_RF_PLANT"] = spare_plant
67+
68+
forward = design.rf[["DEFAULT_RF_PLANT", "SPARE_RF_PLANT"]]
69+
backward = design.rf[["SPARE_RF_PLANT", "DEFAULT_RF_PLANT"]]
70+
assert forward.names() == backward.names() == ["DEFAULT_RF_PLANT", "SPARE_RF_PLANT"]
71+
72+
4673
def test_rf_transmitter_holder_getitem_regex(design):
4774
matching = design.rf.transmitter["re:^RFTRA[12]$"]
4875
assert sorted(matching.names()) == ["RFTRA1", "RFTRA2"]

‎tests/tuning_tools/test_tool_holder.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ def test_tool_holder_getitem_list_of_patterns(design):
3333
assert selected.names() == ["DEFAULT_TUNE_CORRECTION", "DEFAULT_ORBIT_CORRECTION"]
3434

3535

36+
def test_tool_holder_getitem_list_selection_ignores_request_order(design):
37+
"""A list-of-patterns selection lands in configuration order, whatever order the names
38+
were requested in, same as every other holder (see
39+
test_every_accessor_returns_a_list_selection_in_configuration_order)."""
40+
forward = design.tool[["DEFAULT_TUNE_CORRECTION", "DEFAULT_ORBIT_CORRECTION"]]
41+
backward = design.tool[["DEFAULT_ORBIT_CORRECTION", "DEFAULT_TUNE_CORRECTION"]]
42+
assert forward.names() == backward.names() == ["DEFAULT_TUNE_CORRECTION", "DEFAULT_ORBIT_CORRECTION"]
43+
44+
3645
def test_tool_holder_getitem_regex(design):
3746
matching = design.tool["re:^DEFAULT_(TUNE|ORBIT)_CORRECTION$"]
3847
assert sorted(matching.names()) == ["DEFAULT_ORBIT_CORRECTION", "DEFAULT_TUNE_CORRECTION"]

0 commit comments

Comments
 (0)