pythoninheritancesubclassabstractionhardware-interface

How can I programmatically decide what class to create an object as?


I want to write code to communicate with encoders of different positioning styles (absolute vs. relative). The idea I have is to create a base class that has things common to all encoders. Then there will be two child classes, one for absolute and one for relative. There will then be child classes of that for each brand. Each class will have unique functions to communicate with.

Sketch of hierarchy:

Sketch of hierarchy

Supposing I had a way to identify each motor by brand and type, how would I programmatically decide what class object to create?


Solution

  • This sounds like an excellent situation for inheritance, as you already hinted, and can be solved pretty quickly that way. I have put together some sample code for you that shows how you could accomplish this.

    There is one point you make that I would like to contend:

    You mention that each class will have unique functions. I actually recommend exactly the opposite, given every class the same core functions, and then after they are initialized, you never need to worry about which one you actually got, because they all adhere to the same interface. This can greatly simplify your future code as you will not need to ever check which class you are using, and your code will be reusable between different motors without any rewriting, just by changing the motor brand and name.

    To answer this question how would I programmatically decide what class object to create? : I recommend a dictionary with brand name and model name tuple as the key, and the appropriately built class definition as the value. If two separate models use the same interface, you can simply map both to the same class definition. I have an example implementation of this below named MOTOR_ENCODER_LOOKUP.

    import enum
    import typing
    
    
    class PositioningType(enum.Enum):
        relative = 'relative'
        absolute = 'absolute'
    
    
    class EncoderBase:
        def __init__(
                self,
                pos_type: PositioningType,
                motor_brand: str,
                motor_model: str,
                address: str
        ):
            self.pos_type = pos_type
            self.motor_brand = motor_brand
            self.motor_model = motor_model
            self.address = address
            self._setup_hardware_interface()
            self._last_commanded_state = None
            self._last_commanded_state = self.get_position()
    
        def move_relative(self, relative_change: float) -> bool:
            raise NotImplementedError
    
        def move_absolute(self, new_position: float) -> bool:
            raise NotImplementedError
    
        def get_position(self) -> float:
            # Do something to get the position from the hardware
            # May want to override this in the motor-specific classes
            return self._last_commanded_state or 0.0
    
        def _setup_hardware_interface(self) -> bool:
            # Whatever specific setup is necessary for each encoder can be
            #   performed in the derived classes
            #   i.e., open a COM port, setup UDP, etc
            raise NotImplementedError
    
        def _send_command(self, commandvalue: float) -> bool:
            raise NotImplementedError
    
        def __repr__(self):
            outstr = ''
            outstr += f'<{type(self).__name__}\n'
            outstr += f'  positioning type: {self.pos_type}\n'
            outstr += f'  motor brand:      {self.motor_brand}\n'
            outstr += f'  motor model:      {self.motor_model}\n'
            outstr += f'  hardware address: {self.address}\n'
            outstr += f'  last position:    {self._last_commanded_state}\n'
            outstr += f'/>'
            return outstr
    
        @property
        def pos_type(self) -> PositioningType:
            return self._pos_type
    
        @pos_type.setter
        def pos_type(self, invalue: PositioningType):
            self._pos_type = invalue
    
        @property
        def motor_brand(self) -> str:
            return self._motor_brand
    
        @motor_brand.setter
        def motor_brand(self, invalue: str):
            self._motor_brand = invalue
    
        @property
        def motor_model(self) -> str:
            return self._motor_model
    
        @motor_model.setter
        def motor_model(self, invalue: str):
            self._motor_model = invalue
    
        @property
        def address(self) -> str:
            return self._address
    
        @address.setter
        def address(self, invalue: str):
            self._address = invalue
    
    
    class AbsoluteEncoder(EncoderBase):
        def __init__(self, motor_brand: str, motor_model: str, address: str):
            super().__init__(PositioningType.absolute, motor_brand, motor_model, address)
    
        def move_relative(self, relative_change: float) -> bool:
            # This is inherently an absolute encoder, so calculate the new absolute
            #   position and send that
            new_position = self.get_position() + relative_change
            return self.move_absolute(new_position)
    
        def move_absolute(self, new_position: float) -> bool:
            # This is already an absolute encoder, so send the command as-is
            success = self._send_command(new_position)
            if success:
                self._last_commanded_state = new_position
            return success
    
    
    class RelativeEncoder(EncoderBase):
        def __init__(self, motor_brand: str, motor_model: str, address: str):
            super().__init__(PositioningType.relative, motor_brand, motor_model, address)
    
        def move_relative(self, relative_change: float) -> bool:
            # This is already a relative encoder, so send the command as-is
            success = self._send_command(relative_change)
            if success:
                self._last_commanded_state += relative_change
            return success
    
        def move_absolute(self, new_position: float) -> bool:
            # This is inherently a relative encoder, so calculate the relative change
            #   and send the relative command
            relative_change = new_position - self.get_position()
            return self.move_relative(relative_change)
    
    
    class EncoderAlphaOne(AbsoluteEncoder):
        def _send_command(self, commandvalue: float) -> bool:
            # do something to send the command
            return True
    
        def _setup_hardware_interface(self) -> bool:
            return True
    
        def get_position(self) -> float:
            # Ask the hardware for its current position since AbsoluteEncoders probably
            #   have that feature
            return self._last_commanded_state or 0.0
    
    
    class EncoderAlphaTwo(RelativeEncoder):
        def _send_command(self, commandvalue: float) -> bool:
            # do something to send the command
            return True
    
        def _setup_hardware_interface(self) -> bool:
            return True
    
    
    class EncoderBetaOne(AbsoluteEncoder):
        def _send_command(self, commandvalue: float) -> bool:
            # do something to send the command
            return True
    
        def _setup_hardware_interface(self) -> bool:
            return True
    
        def get_position(self) -> float:
            # Ask the hardware for its current position since AbsoluteEncoders probably
            #   have that feature
            return self._last_commanded_state or 0.0
    
    
    # Add all your various make/model of encoders here with appropriate classes
    #   and encoder_factory will automatically grab it
    # Each encoder needs to have a class definition written, as shown above
    #   but most of the work is done in one of the parent classes, so it should be quick
    MOTOR_ENCODER_LOOKUP = {
        ('AlphaCompany', 'Model1'): EncoderAlphaOne,
        ('AlphaCompany', 'Model2'): EncoderAlphaTwo,
        ('BetaCompany', 'FirstModel'): EncoderBetaOne
    }
    
    
    # A factory function to go grab the correct class definition and initialize it
    def encoder_factory(motor_brand: str, motor_model: str, address: str):
        return MOTOR_ENCODER_LOOKUP[(motor_brand, motor_model)](
            motor_brand,
            motor_model,
            address
        )
    
    
    def _main():
        # Demonstrate that the functionality basically works
        # Use three separate types of encoder without fussing about which one you have
        e1 = encoder_factory('AlphaCompany', 'Model1', 'COM1')
        e2 = encoder_factory('AlphaCompany', 'Model2', 'COM3')
        e3 = encoder_factory('BetaCompany', 'FirstModel', 'COM4')
        e1.move_relative(25.0)
        e1.move_relative(-5.0)
        e2.move_relative(10.0)
        e2.move_absolute(45.0)
        e3.move_absolute(60.0)
        e3.move_relative(10.0)
        print(e1)
        print(e2)
        print(e3)
    
    
    if __name__ == '__main__':
        _main()
    
    

    Running the above yields:

    <EncoderAlphaOne
      positioning type: PositioningType.absolute
      motor brand:      AlphaCompany
      motor model:      Model1
      hardware address: COM1
      last position:    20.0
    />
    <EncoderAlphaTwo
      positioning type: PositioningType.relative
      motor brand:      AlphaCompany
      motor model:      Model2
      hardware address: COM3
      last position:    45.0
    />
    <EncoderBetaOne
      positioning type: PositioningType.absolute
      motor brand:      BetaCompany
      motor model:      FirstModel
      hardware address: COM4
      last position:    70.0
    />
    
    

    Let me know if you have any follow up questions.