HPInc / HP-Digital-Microfluidics

HP Digital Microfluidics Software Platform and Libraries
MIT License
2 stars 0 forks source link

Factor out external components from `Board` #270

Closed EvanKirshenbaum closed 5 months ago

EvanKirshenbaum commented 5 months ago

When I first created the Board class, it had Pads and Wells, and I soon added ExtractionPoints. These are all, arguably, intrinsic to the board. Then came Heaters and Magnets, Pipettors, PowerSupplys, Chillers (and the generalization of TemperatureControls, and Fans. Now I'm thinking about adding Sensors. Most of these are BoardComponents, some (e.g., Pipettors) are SystemComponents.

Each of these needs an attribute/property (either an Optional or a list), and each needs to be able to be set in the __init__() method or added.

What I'd like to do is to simplify Board by creating a tag class ExternalComponent (with a type var EC) and then do something like

class Board(SystemComponent):
    _externals: Final[list[ExternalComponent]]

    @property
    def external_components(self) -> Sequence[ExternalComponent]:
        return self._externals

    def find_externals(self, of_type: Type[EC]) -> Sequence[EC]:
        return [c for c in self._externals if isinstance(c, of_type)]

    def find_external(self, of_type: Type[EC]) -> Optional[EC]:
        all = self.find_externals(of_type)
        return None if len(all) is 0 else all[0]

    def add_externals(self, cpts: Sequence[ExternalComponent]) -> None:
        self._externals.extend(cpts)

    def add_external(self, cpt: ExternalComponent) -> None
        self._externals.append(cpt)

    @property
    def magnets(self) -> Sequence[Magnet]:
        return self.find_externals(Magnet)

    @property
    def pipettor(self) -> Optional[Pipettor]:
        return self.find_external(Pipettor)

etc. Or maybe just let the caller use find_external() and find_externals() (or maybe all_components() and one_component() or something like that.)

I'd also like to simplify Board.__init__() to say that it doesn't take any components as parameters, and it's up to the subclass to add everything. (Which is what they already do, but some of the parameters aren't optional.)

Migrated from internal repository. Originally created by @EvanKirshenbaum on May 20, 2023 at 12:53 PM PDT. Closed on May 22, 2023 at 4:32 PM PDT.
EvanKirshenbaum commented 5 months ago

This issue was referenced by the following commits before migration:

EvanKirshenbaum commented 5 months ago

So, what I settled on is a mix-in class ExternalComponent:

class ExternalComponent: 
    number: int = -1

    @property
    def pads_for_sort(self) -> Sequence[Pad]:
        return ()

    def join_system(self, system: System) -> None: # @UnusedVariable
        ...

    def reset_component(self) -> None:
        me = self
        if isinstance(me, BinaryComponent):
            me.current_state = OnOff.OFF

EC = TypeVar('EC', bound=ExternalComponent)

which is, currently, a base class of TemperatureControl, Magnet, PowerSupply, Fan, and Pipettor.

ExternalComponent is hooked into Board by

class Board(SystemComponent):
    _external_components: Final[list[ExternalComponent]]

    @property
    def external_components(self) -> Sequence[ExternalComponent]:
        return self._external_components

    def find_all(self, of_type: Type[EC]) -> Sequence[EC]:
        return [c for c in self.external_components if isinstance(c, of_type)]

    @overload
    def find_one(self, of_type: type[EC], *, if_missing: ValOrFn[str]) -> EC: ... # @UnusedVariable
    @overload
    def find_one(self, of_type: type[EC], *, if_missing: Missing = MISSING) -> Optional[EC]: ... # @UnusedVariable
    def find_one(self, of_type: type[EC], *,
                 if_missing: MissingOr[ValOrFn[str]] = MISSING) -> Optional[EC]:
        all_found = self.find_all(of_type)
        if len(all_found) > 0:
            return all_found[0]
        if if_missing is MISSING:
            return None
        assert False, ensure_val(if_missing, str)

    @cached_property
    def pipettor(self) -> Pipettor:
        from mpam.pipettor import Pipettor 
        return self.find_one(Pipettor,  # type: ignore [type-abstract]
                             if_missing=lambda: f"{self} doesn't have a registered pipettor.")

    def _add_external(self, cpt_type: type[EC], cpt: EC) -> int:
        known = self.find_all(cpt_type)
        n = len(known)
        cpt.number = n+1
        self._external_components.append(cpt)
        return n

    def _maybe_add_external(self, cpt_type: type[EC], cpt: Optional[EC]) -> None:
        if cpt is not None:
            self._add_external(cpt_type, cpt)

    def _add_externals(self, cpt_type: type[EC], cpts: Sequence[EC], *,
                       order: Optional[RCOrder] = None) -> None:
        if order is None:
            order = self.component_layout
        cpts = self.sorted(cpts, get_pads = lambda c: c.pads_for_sort, order=order)
        for c in cpts:
            self._add_external(cpt_type, c)

    def join_system(self, system: System)->None:
        super().join_system(system)

        for cpt in self.external_components:
            cpt.join_system(system)

    def reset_components(self, of_type: type[ExternalComponent] = ExternalComponent) -> None:
        for cpt in self.find_all(of_type):
            cpt.reset_component()

    def reset_all(self) -> None:
        self.reset_pads()
        self.reset_components()

Other things to note:

Migrated from internal repository. Originally created by @EvanKirshenbaum on May 22, 2023 at 4:03 PM PDT.
EvanKirshenbaum commented 5 months ago

I figured out a way around the spurious errors, although it's perhaps a bit of overkill. What I did was to add parallel class methods to ExternalComponent that turn around and call the methods on Board:

   @classmethod
    def find_all_in(cls: type[EC], board: Board) -> Sequence[EC]:
        return board.find_all(cls)

    @overload
    @classmethod
    def find_one_in(cls: type[EC], board: Board, *, if_missing: ValOrFn[str]) -> EC: ... # @UnusedVariable
    @overload
    @classmethod
    def find_one_in(cls: type[EC], board: Board, *, if_missing: Missing = MISSING) -> Optional[EC]: ... # @UnusedVariable
    @classmethod
    def find_one_in(cls: type[EC], board: Board, *,
                 if_missing: MissingOr[ValOrFn[str]] = MISSING) -> Optional[EC]:
        return board.find_one(cls, if_missing=if_missing)

    @classmethod
    def _add_external_to(cls: type[EC], board: Board, cpt: EC) -> int:
        return board._add_external(cls, cpt)

    @classmethod
    def _maybe_add_external_to(cls: type[EC], board: Board, cpt: Optional[EC]) -> None:
        board._maybe_add_external(cls, cpt)

    @classmethod
    def _add_externals_to(cls: type[EC], board: Board, cpts: Sequence[EC], *,
                          order: Optional[RCOrder] = None) -> None:
        board._add_externals(cls, cpts, order=order)

    @classmethod
    def component_number_in(cls: type[EC], board: Board, n: int) -> EC:
        return cls.find_all_in(board)[n-1]

    @classmethod
    def reset_in(cls: type[EC], board: Board) -> None:
        board.reset_components(cls)

So, while

board.find_one(Pipettor)

gets a Mypy error that needs to be suppressed,

Pipettor.find_one_in(board)

works just fine.

Migrated from internal repository. Originally created by @EvanKirshenbaum on May 22, 2023 at 4:32 PM PDT.