Skip to content

Commit 3f82283

Browse files
authored
Merge pull request #7686 from microsoft/jenshnielsen/add_channel_type
Add more explicit channel attributes
2 parents 74529ea + aa76800 commit 3f82283

File tree

7 files changed

+202
-10
lines changed

7 files changed

+202
-10
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
The ``ChannelTuple`` class now has ``multi_parameter`` and ``multi_function`` methods that
2+
provide type-safe access to parameters and functions on all channels in the tuple. These methods
3+
allow accessing attributes with proper type information, improving IDE integration and type checking.
4+
The return type annotation of ``__getattr__`` has been changed to ``Any`` reflecting the fact that
5+
this is not a type safe interface and it is impossible for a static type checker to infer the type
6+
of the dynamic attribute. ``multi_parameter``, ``multi_function`` and ``get_channel_by_name`` should
7+
be used when a more specific type is requested.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The ``RohdeSchwarzZNBBase``, ``MiniCircuitsRCSPDT``, and ``TektronixTPS2012`` drivers now have
2+
explicit type annotations on their ``channels`` submodule, enabling better type checking and
3+
IDE integration.

src/qcodes/instrument/channel.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66
import warnings
77
from collections.abc import Callable, Iterable, Iterator, MutableSequence, Sequence
8-
from typing import TYPE_CHECKING, Any, Generic, cast, overload
8+
from typing import TYPE_CHECKING, Any, Generic, Self, cast, overload
99

1010
from typing_extensions import TypeVar
1111

@@ -441,15 +441,92 @@ def snapshot_base(
441441
}
442442
return snap
443443

444-
def __getattr__(
445-
self, name: str
446-
) -> MultiChannelInstrumentParameter | Callable[..., None] | InstrumentModuleType:
444+
def multi_parameter(
445+
self: Self, name: str
446+
) -> MultiChannelInstrumentParameter[InstrumentModuleType]:
447+
"""
448+
Look up a parameter by name. If this is the name of a parameter on the
449+
channel type contained in this container return a multi-channel parameter
450+
that controls this parameter on all channels in the Sequence.
451+
452+
Args:
453+
name: The name of the parameter that we want to
454+
operate on.
455+
456+
Returns:
457+
MultiChannelInstrumentParameter: The multi-channel parameter
458+
that can be used to get or set all items in a channel list
459+
simultaneously.
460+
461+
Raises:
462+
AttributeError: If no parameter with the given name exists.
463+
464+
"""
465+
if len(self) > 0:
466+
# Check if this is a valid parameter
467+
if name in self._channels[0].parameters:
468+
param = self._construct_multiparam(name)
469+
return param
470+
raise AttributeError(
471+
f"'{self.__class__.__name__}' object has no parameter '{name}'"
472+
)
473+
474+
def multi_function(self, name: str) -> Callable[..., None]:
475+
"""
476+
Look up a callable or QCoDeS function by name. If this is the name of a callable or function
477+
on the channel type contained in this container return a callable that calls this callable on
478+
all channels in the Sequence
479+
480+
Args:
481+
name: The name of the callable/function that we want to
482+
operate on.
483+
484+
Returns:
485+
Callable that calls the functions/callables on all channels in the Sequence.
486+
487+
Raises:
488+
AttributeError: If no callable with the given name exists.
489+
490+
"""
491+
if len(self) == 0:
492+
raise AttributeError(
493+
f"'{self.__class__.__name__}' object has no callable or function '{name}'"
494+
)
495+
# Check if this is a valid function
496+
if name in self._channels[0].functions:
497+
# We want to return a reference to a function that would call the
498+
# function for each of the channels in turn.
499+
def multi_func(*args: Any) -> None:
500+
for chan in self._channels:
501+
chan.functions[name](*args)
502+
503+
return multi_func
504+
505+
# check if this is a method on the channels in the
506+
# sequence
507+
maybe_callable = getattr(self._channels[0], name, None)
508+
if callable(maybe_callable):
509+
510+
def multi_callable(*args: Any) -> None:
511+
for chan in self._channels:
512+
getattr(chan, name)(*args)
513+
514+
return multi_callable
515+
raise AttributeError(
516+
f"'{self.__class__.__name__}' object has no callable or function '{name}'"
517+
)
518+
519+
def __getattr__(self, name: str) -> Any:
447520
"""
448521
Look up an attribute by name. If this is the name of a parameter or
449522
a function on the channel type contained in this container return a
450523
multi-channel function or parameter that can be used to get or
451524
set all items in a channel list simultaneously. If this is the
452-
name of a channel, return that channel.
525+
name of a channel, return that channel. This interface is not
526+
type safe as it will return any matching attribute. To get a channel
527+
by name use ``get_channels_by_name`` instead. To get a parameter use
528+
``multi_parameter``. To get a a callable or a qcodes function use
529+
``multi_function``
453530
454531
Args:
455532
name: The name of the parameter, function or channel that we want to

src/qcodes/instrument_drivers/Minicircuits/_minicircuits_rc_spdt.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
if TYPE_CHECKING:
1212
from typing_extensions import Unpack
1313

14+
from qcodes.instrument.channel import ChannelTuple
1415
from qcodes.parameters import Parameter
1516

1617

@@ -91,7 +92,10 @@ def __init__(
9192
channel = MiniCircuitsRCSPDTChannel(self, f"channel_{c}", c)
9293
channels.append(channel)
9394
self.add_submodule(f"channel_{c}", channel)
94-
self.add_submodule("channels", channels.to_channel_tuple())
95+
self.channels: ChannelTuple[MiniCircuitsRCSPDTChannel] = self.add_submodule(
96+
"channels", channels.to_channel_tuple()
97+
)
98+
"""Tuple of MiniCircuitsRCSPDTChannel"""
9599

96100
self.connect_message()
97101

src/qcodes/instrument_drivers/rohde_schwarz/ZNB.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,14 +1173,17 @@ def __init__(
11731173
channels = ChannelList(
11741174
self, "VNAChannels", self.CHANNEL_CLASS, snapshotable=True
11751175
)
1176-
self.add_submodule("channels", channels)
1176+
self.channels: ChannelList[RohdeSchwarzZNBChannel] = self.add_submodule(
1177+
"channels", channels
1178+
)
1179+
"""Submodule channels: List of VNA channels."""
11771180
if init_s_params:
11781181
for i in range(1, num_ports + 1):
11791182
for j in range(1, num_ports + 1):
11801183
ch_name = "S" + str(i) + str(j)
11811184
self.add_channel(ch_name)
11821185
self.display_sij_split()
1183-
self.channels.autoscale()
1186+
self.channels.multi_function("autoscale")()
11841187

11851188
self.update_display_on()
11861189
if reset_channels:

src/qcodes/instrument_drivers/tektronix/TPS2012.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import binascii
22
import logging
33
from functools import partial
4-
from typing import Any
4+
from typing import TYPE_CHECKING, Any
55

66
import numpy as np
77
import numpy.typing as npt
@@ -18,6 +18,9 @@
1818
)
1919
from qcodes.parameters import ArrayParameter, Parameter, ParamRawDataType
2020

21+
if TYPE_CHECKING:
22+
from qcodes.instrument.channel import ChannelTuple
23+
2124
log = logging.getLogger(__name__)
2225

2326

@@ -416,7 +419,10 @@ def __init__(
416419
channel = TektronixTPS2012Channel(self, ch_name, ch_num)
417420
channels.append(channel)
418421
self.add_submodule(ch_name, channel)
419-
self.add_submodule("channels", channels.to_channel_tuple())
422+
self.channels: ChannelTuple[TektronixTPS2012Channel] = self.add_submodule(
423+
"channels", channels.to_channel_tuple()
424+
)
425+
"""Tuple of TektronixTPS2012Channel"""
420426

421427
# Necessary settings for parsing the binary curve data
422428
self.visa_handle.encoding = "latin-1"

tests/test_channels.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,98 @@ def test_channel_tuple_names(dci: DummyChannelInstrument) -> None:
753753
assert dci.channels.full_name == "dci_TempSensors"
754754

755755

756+
def test_multi_parameter_returns_multichannel_parameter(
757+
dci: DummyChannelInstrument,
758+
) -> None:
759+
"""Test that multi_parameter returns a MultiChannelInstrumentParameter for valid parameter name."""
760+
temp_multi_param = dci.channels.multi_parameter("temperature")
761+
assert isinstance(temp_multi_param, MultiChannelInstrumentParameter)
762+
763+
temperatures = temp_multi_param.get()
764+
assert len(temperatures) == 6
765+
766+
767+
def test_multi_parameter_invalid_name_raises(dci: DummyChannelInstrument) -> None:
768+
"""Test that multi_parameter raises AttributeError for invalid parameter name."""
769+
with pytest.raises(
770+
AttributeError,
771+
match="'ChannelTuple' object has no parameter 'nonexistent_param'",
772+
):
773+
dci.channels.multi_parameter("nonexistent_param")
774+
775+
776+
def test_multi_parameter_on_empty_channel_tuple_raises(
777+
empty_instrument: Instrument,
778+
) -> None:
779+
"""Test that multi_parameter raises AttributeError on empty channel tuple."""
780+
channels = ChannelTuple(empty_instrument, "channels", chan_type=DummyChannel)
781+
empty_instrument.add_submodule("channels", channels)
782+
783+
with pytest.raises(
784+
AttributeError,
785+
match="'ChannelTuple' object has no parameter 'temperature'",
786+
):
787+
channels.multi_parameter("temperature")
788+
789+
790+
def test_multi_function_returns_callable(dci: DummyChannelInstrument) -> None:
791+
"""Test that multi_function returns a callable for valid function name."""
792+
multi_func = dci.channels.multi_function("log_my_name")
793+
assert callable(multi_func)
794+
795+
796+
def test_multi_function_calls_function_on_all_channels(
797+
dci: DummyChannelInstrument, caplog: LogCaptureFixture
798+
) -> None:
799+
"""Test that the returned callable calls the function on all channels."""
800+
with caplog.at_level(
801+
logging.DEBUG, logger="qcodes.instrument_drivers.mock_instruments"
802+
):
803+
caplog.clear()
804+
multi_func = dci.channels.multi_function("log_my_name")
805+
multi_func()
806+
mssgs = [rec.message for rec in caplog.records]
807+
names = [ch.name.replace("dci_", "") for ch in dci.channels]
808+
assert mssgs == names
809+
810+
811+
def test_multi_function_with_callable_method(
812+
dci: DummyChannelInstrument, mocker: "pytest_mock.MockerFixture"
813+
) -> None:
814+
"""Test that multi_function works with callable methods on channels."""
815+
for channel in dci.channels:
816+
channel.turn_on = mocker.MagicMock(return_value=1)
817+
818+
multi_func = dci.channels.multi_function("turn_on")
819+
result = multi_func("bar")
820+
assert result is None
821+
for channel in dci.channels:
822+
channel.turn_on.assert_called_with("bar") # type: ignore[union-attr]
823+
824+
825+
def test_multi_function_invalid_name_raises(dci: DummyChannelInstrument) -> None:
826+
"""Test that multi_function raises AttributeError for invalid function/callable name."""
827+
with pytest.raises(
828+
AttributeError,
829+
match="'ChannelTuple' object has no callable or function 'nonexistent_func'",
830+
):
831+
dci.channels.multi_function("nonexistent_func")
832+
833+
834+
def test_multi_function_on_empty_channel_tuple_raises(
835+
empty_instrument: Instrument,
836+
) -> None:
837+
"""Test that multi_function raises AttributeError on empty channel tuple."""
838+
channels = ChannelTuple(empty_instrument, "channels", chan_type=DummyChannel)
839+
empty_instrument.add_submodule("channels", channels)
840+
841+
with pytest.raises(
842+
AttributeError,
843+
match="'ChannelTuple' object has no callable or function 'temperature'",
844+
):
845+
channels.multi_function("temperature")
846+
847+
756848
def _verify_multiparam_data(data) -> None:
757849
assert "multi_setpoint_param_this_setpoint_set" in data.arrays.keys()
758850
assert_array_equal(

0 commit comments

Comments
 (0)