Repository: Qiskit/qiskit

Issue:
InstructionScheduleMap.add incorrectly determines function signature
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Information

- **Qiskit Terra version**: '0.18.0.dev0+98130dd'
- **Python version**: 3.8
- **Operating system**: Windows

### What is the current behavior?

The `InstructionScheduleMap` allows to add callable schedules. This works for normal callables, but fails for callables defined with `partial`.

### Steps to reproduce the problem

A minimal example is:
```
from functools import partial
from qiskit import *
from qiskit.test.mock import FakeAthens
from qiskit.circuit import Gate, Parameter
from qiskit.pulse import Constant, DriveChannel
                          
backend = FakeAthens()
defaults = backend.defaults()
inst_map = defaults.instruction_schedule_map

def build_stage_schedule(duration, name, amp, frequency_offset, channel):
    with pulse.build() as pulse_prog:
        with pulse.frequency_offset(frequency_offset, channel):
            pulse.play(pulse.library.Constant(duration=duration, amp=amp), channel)
    return pulse_prog

frequency=Parameter('f')

def callable_schedule(f):
    schedule = build_stage_schedule(4, 'A', .1, f, DriveChannel(0))
    return schedule

inst_map.add('mygate', [0], callable_schedule, [frequency.name]) # works

def callable_schedule2(f, amplitude):
    schedule = build_stage_schedule(4, 'A', amplitude, f, DriveChannel(0))
    return schedule

inst_map.add('mygate', [0], partial(callable_schedule2, amplitude=.4), [frequency.name]) # fails
````


### What is the expected behavior?

Adding the second callable schedule `partial(callable_schedule2, amplitude=.4)` should have worked.


### Suggested solutions

Modify the line `func_parameters = list(inspect.signature(schedule).parameters.keys())` from the `add` method so that the correct signature is used.





Discussion:

Resolving patch:
diff --git a/qiskit/pulse/instruction_schedule_map.py b/qiskit/pulse/instruction_schedule_map.py
--- a/qiskit/pulse/instruction_schedule_map.py
+++ b/qiskit/pulse/instruction_schedule_map.py
@@ -29,21 +29,15 @@
 import inspect
 import warnings
 from collections import defaultdict
-from copy import deepcopy
-from itertools import zip_longest
 from typing import Callable, Iterable, List, Tuple, Union, Optional, NamedTuple
 
-from qiskit.circuit import ParameterExpression
 from qiskit.circuit.instruction import Instruction
+from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType
 from qiskit.pulse.exceptions import PulseError
 from qiskit.pulse.schedule import Schedule, ScheduleBlock, ParameterizedSchedule
 
-ScheduleArgumentsTuple = NamedTuple('ScheduleArgumentsTuple',
-                                    [('schedule', Union[Callable, Schedule, ScheduleBlock]),
-                                     ('arguments', Tuple[str])])
-ScheduleArgumentsTuple.__doc__ = 'Set of schedule generator and associated argument names.'
-ScheduleArgumentsTuple.schedule.__doc__ = 'Schedule generator function or Schedule.'
-ScheduleArgumentsTuple.arguments.__doc__ = 'Name of parameters to be assigned.'
+Generator = NamedTuple('Generator', [('function', Union[Callable, Schedule, ScheduleBlock]),
+                                     ('signature', inspect.Signature)])
 
 
 class InstructionScheduleMap():
@@ -63,7 +57,7 @@ class InstructionScheduleMap():
     def __init__(self):
         """Initialize a circuit instruction to schedule mapper instance."""
         # The processed and reformatted circuit instruction definitions
-        self._map = defaultdict(lambda: defaultdict(ScheduleArgumentsTuple))
+        self._map = defaultdict(lambda: defaultdict(Generator))
         # A backwards mapping from qubit to supported instructions
         self._qubit_instructions = defaultdict(set)
 
@@ -180,34 +174,41 @@ def get(self,
         """
         instruction = _get_instruction_string(instruction)
         self.assert_has(instruction, qubits)
-        schedule_args_tuple = self._map[instruction][_to_tuple(qubits)]
+        generator = self._map[instruction][_to_tuple(qubits)]
 
-        # Verify parameter-value mapping
-        if len(params) > len(schedule_args_tuple.arguments):
-            raise PulseError('Too many values to bind: {}.'.format(', '.join(map(str, params))))
-        if not all(key in schedule_args_tuple.arguments for key in kwparams):
-            raise PulseError('Parameters not defined: {}'.format(', '.join(kwparams.keys())))
+        _error_message = f'*params={params}, **kwparams={kwparams} do not match with ' \
+            f'the schedule generator signature {generator.signature}.'
 
-        bind_parameters = dict(zip_longest(schedule_args_tuple.arguments, params))
-        bind_parameters.update(kwparams)
+        function = generator.function
+        if callable(function):
+            try:
+                # callables require full binding, but default values can exist.
+                binds = generator.signature.bind(*params, **kwparams)
+                binds.apply_defaults()
+            except TypeError as ex:
+                raise PulseError(_error_message) from ex
+            return function(**binds.arguments)
 
-        sched = schedule_args_tuple.schedule
-
-        # callback function
-        if callable(sched):
-            return sched(**bind_parameters)
-
-        # schedule
-        if sched.is_parameterized():
-            parameter_mapping = dict()
-            for param_obj in sched.parameters:
-                bind_value = bind_parameters[param_obj.name]
-                # if value is not set, keep the parameter unassigned
-                if bind_value is not None:
-                    parameter_mapping[param_obj] = bind_value
-            return deepcopy(sched).assign_parameters(parameter_mapping)
-
-        return sched
+        try:
+            # schedules allow partial binding
+            binds = generator.signature.bind_partial(*params, **kwparams)
+        except TypeError as ex:
+            raise PulseError(_error_message) from ex
+
+        if len(binds.arguments) > 0:
+            if isinstance(function, ParameterizedSchedule):
+                return function.bind_parameters(**binds.arguments)
+
+            value_dict = dict()
+            for param in function.parameters:
+                try:
+                    value_dict[param] = binds.arguments[param.name]
+                except KeyError:
+                    pass
+
+            return function.assign_parameters(value_dict, inplace=False)
+        else:
+            return function
 
     def add(self,
             instruction: Union[str, Instruction],
@@ -235,44 +236,51 @@ def add(self,
         if qubits == ():
             raise PulseError("Cannot add definition {} with no target qubits.".format(instruction))
 
-        # TODO this block will be removed
-        if isinstance(schedule, ParameterizedSchedule):
+        # generate signature
+        if isinstance(schedule, (Schedule, ScheduleBlock)):
+            ordered_names = sorted(list(set(par.name for par in schedule.parameters)))
+            if arguments:
+                if set(arguments) != set(ordered_names):
+                    raise PulseError('Arguments does not match with schedule parameters. '
+                                     f'{set(arguments)} != {schedule.parameters}.')
+                ordered_names = arguments
+
+            parameters = list()
+            for argname in ordered_names:
+                param_signature = inspect.Parameter(
+                    name=argname,
+                    annotation=ParameterValueType,
+                    kind=inspect.Parameter.POSITIONAL_OR_KEYWORD
+                )
+                parameters.append(param_signature)
+            signature = inspect.Signature(parameters=parameters, return_annotation=type(schedule))
+
+        elif isinstance(schedule, ParameterizedSchedule):
+            # TODO remove this
             warnings.warn('ParameterizedSchedule has been deprecated. '
                           'Define Schedule with Parameter objects.', DeprecationWarning)
 
-            def sched_callback(**kwargs):
-                bind_dict = {pname: kwargs[pname] for pname in schedule.parameters}
-                return schedule.bind_parameters(**bind_dict)
-            arguments = tuple(schedule.parameters)
-
-            self._map[instruction][qubits] = ScheduleArgumentsTuple(sched_callback, arguments)
-            self._qubit_instructions[qubits].add(instruction)
+            parameters = list()
+            for argname in schedule.parameters:
+                param_signature = inspect.Parameter(
+                    name=argname,
+                    annotation=ParameterValueType,
+                    kind=inspect.Parameter.POSITIONAL_OR_KEYWORD
+                )
+                parameters.append(param_signature)
+            signature = inspect.Signature(parameters=parameters, return_annotation=Schedule)
+
+        elif callable(schedule):
+            if arguments:
+                warnings.warn('Arguments are overridden by the callback function signature. '
+                              'Input `arguments` are ignored.', UserWarning)
+            signature = inspect.signature(schedule)
 
-            return
-
-        # validation of input data
-        if not (isinstance(schedule, (Schedule, ScheduleBlock)) or callable(schedule)):
-            raise PulseError('Supplied schedule must be either a Schedule, ScheduleBlock or a '
-                             'callable that outputs a schedule.')
-
-        # initialize parameter list
-        if callable(schedule):
-            func_parameters = list(inspect.signature(schedule).parameters.keys())
         else:
-            func_parameters = set(param.name for param in schedule.parameters)
+            raise PulseError('Supplied schedule must be one of the Schedule, ScheduleBlock or a '
+                             'callable that outputs a schedule.')
 
-        if arguments is None:
-            # for backward compatibility
-            arguments = sorted(func_parameters)
-        else:
-            # check parameter list consistency
-            if sorted(func_parameters) != sorted(arguments):
-                str_func_parameters = ', '.join(func_parameters)
-                str_arguments = ', '.join(arguments)
-                raise PulseError('Program signature and specified parameter names do not match '
-                                 '{} != {}'.format(str_func_parameters, str_arguments))
-
-        self._map[instruction][qubits] = ScheduleArgumentsTuple(schedule, tuple(arguments))
+        self._map[instruction][qubits] = Generator(schedule, signature)
         self._qubit_instructions[qubits].add(instruction)
 
     def remove(self,
@@ -320,7 +328,7 @@ def pop(self,
     def get_parameters(self,
                        instruction: Union[str, Instruction],
                        qubits: Union[int, Iterable[int]]
-                       ) -> Tuple[Union[str, ParameterExpression]]:
+                       ) -> Tuple[str]:
         """Return the list of parameters taken by the given instruction on the given qubits.
 
         Args:
@@ -333,7 +341,8 @@ def get_parameters(self,
         instruction = _get_instruction_string(instruction)
 
         self.assert_has(instruction, qubits)
-        return self._map[instruction][_to_tuple(qubits)].arguments
+        signature = self._map[instruction][_to_tuple(qubits)].signature
+        return tuple(signature.parameters.keys())
 
     def __str__(self):
         single_q_insts = "1Q instructions:\n"


Test patch:
diff --git a/test/python/pulse/test_instruction_schedule_map.py b/test/python/pulse/test_instruction_schedule_map.py
--- a/test/python/pulse/test_instruction_schedule_map.py
+++ b/test/python/pulse/test_instruction_schedule_map.py
@@ -18,7 +18,7 @@
 from qiskit.circuit.parameter import Parameter
 from qiskit.circuit.parameterexpression import ParameterExpression
 from qiskit.pulse import (InstructionScheduleMap, Play, PulseError, Schedule, ScheduleBlock,
-                          Waveform, ShiftPhase)
+                          Waveform, ShiftPhase, Constant)
 from qiskit.pulse.channels import DriveChannel
 from qiskit.qobj import PulseQobjInstruction
 from qiskit.qobj.converters import QobjToInstructionConverter
@@ -474,3 +474,33 @@ def test_get_schedule_with_unbound_parameter(self):
         for test_inst, ref_inst in zip(test_sched.instructions, ref_sched.instructions):
             self.assertEqual(test_inst[0], ref_inst[0])
             self.assertAlmostEqual(test_inst[1], ref_inst[1])
+
+    def test_partially_bound_callable(self):
+        """Test register partial function."""
+        import functools
+
+        def callable_schedule(par_b, par_a):
+            sched = Schedule()
+            sched.insert(10, Play(Constant(10, par_b), DriveChannel(0)), inplace=True)
+            sched.insert(20, Play(Constant(10, par_a), DriveChannel(0)), inplace=True)
+            return sched
+
+        ref_sched = Schedule()
+        ref_sched.insert(10, Play(Constant(10, 0.1), DriveChannel(0)), inplace=True)
+        ref_sched.insert(20, Play(Constant(10, 0.2), DriveChannel(0)), inplace=True)
+
+        inst_map = InstructionScheduleMap()
+
+        def test_callable_sched1(par_b):
+            return callable_schedule(par_b, 0.2)
+
+        inst_map.add('my_gate1', (0,), test_callable_sched1, ['par_b'])
+        ret_sched = inst_map.get('my_gate1', (0,), par_b=0.1)
+        self.assertEqual(ret_sched, ref_sched)
+
+        # bind partially
+        test_callable_sched2 = functools.partial(callable_schedule, par_a=0.2)
+
+        inst_map.add('my_gate2', (0,), test_callable_sched2, ['par_b'])
+        ret_sched = inst_map.get('my_gate2', (0,), par_b=0.1)
+        self.assertEqual(ret_sched, ref_sched)