diff --git a/docs/explanations/decisions/0003-controller-initialization-on-main-event-loop.md b/docs/explanations/decisions/0003-controller-initialization-on-main-event-loop.md new file mode 100644 index 000000000..f412d2f90 --- /dev/null +++ b/docs/explanations/decisions/0003-controller-initialization-on-main-event-loop.md @@ -0,0 +1,43 @@ +# 3. Controller Initialization on Main Event Loop + +Date: 2024-08-01 + +**Related:** [PR #49](https://github.com/DiamondLightSource/FastCS/pull/49) + +## Status + +Accepted + +## Context + +The Backend accepts a pre-created Mapping with no async initialization hook for controllers. Each backend subclass manually orchestrates initialization steps, leading to inconsistent lifecycle patterns. Controllers need a way to perform async setup before their API is exposed to transports. + +## Decision + +Move initialisation logic into Backend so that it: +- Creates the event loop +- Runs `controller.initialise()` before creating the Mapping +- Creates the Mapping from the initialized controller +- Runs initial tasks including `controller.connect()` +- Delegates to transport-specific implementations + +Controller then has two hooks: `initialise()` for pre-API setup (hardware introspection, dynamic attribute creation) and `connect()` for post-API connection logic. + +## Consequences + +The new design the initialisation of the application and makes the API for writing controllers and transports simpler and more flexible. + +### Migration Pattern + +For controller developers: + +```python +class MyController(Controller): + async def initialise(self) -> None: + # Async setup before API creation (introspect hardware, create attributes) + await self._hardware.connect() + + async def connect(self) -> None: + # Async setup after API creation (establish connections) + await self._hardware.initialize() +``` diff --git a/docs/explanations/decisions/0004-backend-to-transport-refactoring.md b/docs/explanations/decisions/0004-backend-to-transport-refactoring.md new file mode 100644 index 000000000..48ad3b15f --- /dev/null +++ b/docs/explanations/decisions/0004-backend-to-transport-refactoring.md @@ -0,0 +1,55 @@ +# 4. Rename Backend to Transport + +Date: 2024-11-29 + +**Related:** [PR #67](https://github.com/DiamondLightSource/FastCS/pull/67) + +## Status + +Accepted + +## Context + +The original FastCS architecture used the term "backend" ambiguously to describe both the overall framework that managed controllers and the specific communication protocol implementations (EPICS CA, PVA, REST, Tango). This dual usage created confusion: + +- It was unclear whether "backend" referred to the framework itself or the protocol layer +- The terminology didn't clearly differentiate between the abstract framework and the underlying communication mechanisms +- The inheritance pattern made it difficult to compose multiple transports or swap them dynamically + +## Decision + +Rename "backend" to "transport" for all protocol/communication implementations to clearly differentiate them from the framework. + +The term "backend" can now refer to the overall FastCS framework/system, while "transport" specifically refers to protocol implementations (EPICS CA, PVA, REST, GraphQL, Tango). FastCS accepts Transport implementations as plugins, enabling flexible composition and loose coupling. + +Key architectural changes: +- Introduce `TransportAdapter` abstract base class with standardized interface +- Move to composition-based architecture where transports are passed to `FastCS` rather than being subclasses +- Introduce `FastCS` class as the programmatic interface for running controllers with transports +- Add `launch()` function as the primary entry point for initializing controllers + +## Consequences + +### Benefits + +- **Clear Terminology:** The separation between framework (backend) and protocol layer (transport) is now explicit +- **Consistent Architecture:** All transports follow the adapter pattern with a standardized interface +- **Flexible Composition:** Transports can be added, removed, or swapped at runtime +- **Improved Extensibility:** Adding new transport protocols is straightforward with the adapter pattern + +### Migration Pattern + +**Before (Inheritance hierarchy):** +```python +class EpicsBackend(Backend): + def run(self): + # Protocol-specific implementation + +fastcs = FastCS(controller) # Tightly coupled to framework +``` + +**After (Composition with Transport plugins):** +```python +transport = EpicsTransport(controller_api) +fastcs = FastCS(controller, [transport]) +``` diff --git a/docs/explanations/decisions/0005-background-thread-removal.md b/docs/explanations/decisions/0005-background-thread-removal.md new file mode 100644 index 000000000..bb1fa92b7 --- /dev/null +++ b/docs/explanations/decisions/0005-background-thread-removal.md @@ -0,0 +1,85 @@ +# 5. Remove Background Thread from Backend + +Date: 2025-01-24 + +**Related:** [PR #98](https://github.com/DiamondLightSource/FastCS/pull/98) + +## Status + +Accepted + +## Context + +The current FastCS Backend implementation uses `asyncio.run_coroutine_threadsafe()` to execute controller operations on a background event loop thread managed by `AsyncioDispatcher`. This threading model creates several problems: + +- **Thread Safety Complexity:** Managing state across thread boundaries introduced race conditions and required careful synchronization +- **Blocked Main Thread:** Despite using async, the main thread blocked waiting for background thread results via `.result()` +- **Complex Lifecycle Management:** Starting and stopping the background thread added complexity +- **Difficult to Test:** Background threads made tests non-deterministic and harder to debug +- **Unnecessary for Most Transports:** Only EPICS CA softIOC actually needed a background thread; other transports (REST, GraphQL, PVA) could run entirely async + +The system needs a simpler concurrency model that uses native async/await patterns and allows transports to manage their own threading if needed. + +## Decision + +Remove the background thread from Backend, making it fully async, while allowing specific transports to use a background thread if required. Tango does require this because it does not accept an event loop to run on. Backend now accepts an event loop from the caller and uses native async/await throughout. Transports that need threading (like EPICS CA) manage their own threading explicitly. + +Key architectural changes: +- Backend receives event loop from caller (no background dispatcher) +- Initialization uses `loop.run_until_complete()` instead of cross-thread scheduling +- Backend exposes `async serve()` method using native async/await patterns +- Scan tasks use `Task` objects from `loop.create_task()` instead of `Future` objects +- Transports that need threading create their own `AsyncioDispatcher` when needed + +## Consequences + +### Benefits + +- **Simpler Concurrency Model:** Single event loop for most operations, no cross-thread coordination needed +- **True Async/Await:** Native Python async patterns throughout, no blocking `.result()` calls +- **Better Testability:** Deterministic execution, easier to debug, no thread scheduling delays +- **Clearer Responsibility:** Transports explicitly manage threading they need +- **Task-Based API:** Consistent use of `Task` objects with standard cancellation support +- **Composability:** `async serve()` can be composed with other async operations + +### Migration Pattern + +**Before (Background thread in Backend):** +```python +class MyTransport(TransportAdapter): + def __init__(self, controller_api, loop): + self._backend = Backend(controller) # Creates background thread + + def run(self): + self._backend.run() # Synchronous + +# Client code +asyncio.run_coroutine_threadsafe(coro(), self._loop) # Cross-thread scheduling +future.result() # Main thread blocks +``` + +**After (Async-only Backend):** +```python +class MyTransport(Transport): + def connect(self, controller_api, loop): + self._backend = Backend(controller, loop) # Use caller's loop + + async def serve(self): + await self._backend.serve() # Native async + +# Client code +await self._backend.serve() # Direct await, no threading +``` + +**For transports needing threads (EPICS CA):** +```python +class EpicsCATransport(Transport): + def __init__(self): + self._dispatcher = AsyncioDispatcher() # Transport manages its own thread + + def connect(self, controller_api, loop): + self._backend = Backend(controller, self._dispatcher.loop) + + async def serve(self): + await self._backend.serve() # Bridge to threaded environment +``` diff --git a/docs/explanations/decisions/0006-controller-api-abstraction-layer.md b/docs/explanations/decisions/0006-controller-api-abstraction-layer.md new file mode 100644 index 000000000..e372bbe96 --- /dev/null +++ b/docs/explanations/decisions/0006-controller-api-abstraction-layer.md @@ -0,0 +1,67 @@ +# 6. Create ControllerAPI Abstraction Layer + +Date: 2025-03-10 + +**Related:** [PR #87](https://github.com/DiamondLightSource/FastCS/pull/87) + +## Status + +Accepted + +## Context + +Transports currently access `Controller` instances directly to extract attributes, methods, and metadata for serving over their protocols. This tight coupling creates a few problems: + +- **Tight Coupling:** Transports are coupled to internal Controller structure, making evolution difficult +- **Code Duplication:** Every transport re-implemented similar traversal logic for discovering attributes and methods +- **No Encapsulation:** Transports had direct access to mutable controller state +- **No Static View:** No complete, immutable snapshot of controller API after initialization + +## Decision + +Introduce `ControllerAPI` as an abstraction layer that provides transports with a complete, static, read-only representation of a controller's capabilities after initialization. + +All transports now work with `ControllerAPI` instead of direct `Controller` access. A single `create_controller_api()` function handles all API extraction, replaces custom traversal logic in each transport. + +Key architectural changes: +- `ControllerAPI` dataclass represents the complete, hierarchical structure of what a controller exposes +- Separate dictionaries for attributes, command_methods, put_methods, and scan_methods +- `walk_api()` method provides depth-first traversal of the API tree +- Backend creates ControllerAPI once during initialization, before passing to transports + +## Consequences + +### Benefits + +- **Encapsulation:** Transports work with read-only API, cannot modify controller internals +- **Single Source of Truth:** One canonical representation of controller capabilities +- **Reduced Code Duplication:** Traversal and extraction logic written once, used by all transports +- **Separation of Concerns:** Controllers focus on device logic, ControllerAPI handles representation, transports focus on protocol +- **Testability:** Transports can be tested with synthetic ControllerAPIs; controllers tested independently +- **Evolution Independence:** Controller internals can change without affecting transports + +### Migration Pattern + +**Before (Direct Controller access):** +```python +class EpicsCAIOC: + def __init__(self, pv_prefix: str, controller: Controller): + # Each transport traverses controller itself + for attr_name in dir(controller): + attr = getattr(controller, attr_name) + if isinstance(attr, Attribute): + self._create_pv(f"{pv_prefix}{attr_name}", attr) +``` + +**After (ControllerAPI abstraction):** +```python +class EpicsCAIOC: + def __init__(self, pv_prefix: str, controller_api: ControllerAPI): + # Transport receives ready-to-use API structure + for attr_name, attr in controller_api.attributes.items(): + self._create_pv(f"{pv_prefix}{attr_name}", attr) + + # Walk sub-controllers using standard method + for sub_api in controller_api.walk_api(): + # Process sub-controllers with consistent structure +``` diff --git a/docs/explanations/decisions/0007-transport-consolidation.md b/docs/explanations/decisions/0007-transport-consolidation.md new file mode 100644 index 000000000..5248d73ac --- /dev/null +++ b/docs/explanations/decisions/0007-transport-consolidation.md @@ -0,0 +1,71 @@ +# 7. Merge TransportAdapter and TransportOptions into Transport + +Date: 2025-09-29 + +**Related:** [PR #220](https://github.com/DiamondLightSource/FastCS/pull/220) + +## Status + +Accepted + +## Context + +FastCS transports are implemented using two separate classes: `TransportAdapter` for implementation and separate `*Options` classes for configuration. This pattern requires: + +- Two classes per transport +- Pattern matching logic in FastCS to create the right adapter from options +- Inconsistent constructor signatures across transports +- Redundant Options classes that only carry configuration data + +## Decision + +Merge `TransportAdapter` and `*Options` classes into a single `Transport` dataclass that combines configuration and implementation. + +All transports should follow a unified pattern: configuration fields are dataclass attributes, and `connect()` and `serve()` methods handle initialization and execution. FastCS accepts Transport instances directly. + +Key architectural changes: +- All transports use `@dataclass` decorator combining configuration and implementation +- Standardized `connect(controller_api, loop)` method for deferred initialization +- Standardized `async serve()` method for running the transport +- Removed pattern matching logic from FastCS +- Configuration fields are direct attributes (not nested in options object) + +## Consequences + +### Benefits + +- **Reduced API Surface:** 5 classes instead of 10 (one Transport per protocol) +- **Simpler Mental Model:** Configuration and implementation in one place +- **Consistent Interface:** All transports follow same initialization pattern +- **Less Boilerplate:** No pattern matching needed in FastCS +- **Easier Maintenance:** Transport parameters defined once in dataclass fields +- **Better Type Safety:** Consistent constructor signatures across all transports + +### Migration Pattern + +**Before (Options + Adapter pattern):** +```python +# Configuration separate from implementation +@dataclass +class MyOptions: + param1: str + param2: int = 42 + +class MyTransport(TransportAdapter): + def __init__(self, controller_api: ControllerAPI, options: MyOptions): + self._options = options + # Setup using self._options.param1 +``` + +**After (Unified Transport):** +```python +# Configuration and implementation unified +@dataclass +class MyTransport(Transport): + param1: str + param2: int = 42 + + def connect(self, controller_api: ControllerAPI, loop: asyncio.AbstractEventLoop): + self._controller_api = controller_api + # Setup using self.param1 directly +``` diff --git a/docs/explanations/decisions/0008-transport-dependencies-as-optional-extras.md b/docs/explanations/decisions/0008-transport-dependencies-as-optional-extras.md new file mode 100644 index 000000000..a7555f13b --- /dev/null +++ b/docs/explanations/decisions/0008-transport-dependencies-as-optional-extras.md @@ -0,0 +1,68 @@ +# 8. Split Transport Dependencies into Optional Extras + +Date: 2025-09-30 + +**Related:** [PR #221](https://github.com/DiamondLightSource/FastCS/pull/221) + +## Status + +Accepted + +## Context + +Currently all transport dependencies are installed regardless of which transports users actually needed. + +Problems with required dependencies: +- Minimal installations bloated by unused transport dependencies +- Unclear dependency relationships for each transport +- No way to install just the core FastCS functionality + +## Decision + +Split transport dependencies into optional extras in `pyproject.toml`, allowing users to install only what they need. + +The core FastCS package now requires only essential dependencies (pydantic, numpy, ruamel.yaml, IPython). Each transport is available as an optional extra, with convenience groups like `[all]`, `[epics]`, and `[dev]` for common installation patterns. + +Key architectural changes: +- Core dependencies: pydantic, numpy, ruamel.yaml, IPython +- Individual transport extras: `[epicsca]`, `[epicspva]`, `[tango]`, `[graphql]`, `[rest]` +- Convenience groups: `[epics]`, `[all]`, `[dev]`, `[demo]` +- Each transport declares its own dependencies explicitly + +## Consequences + +### Benefits + +- **Minimal Core Installation:** Users can install FastCS core without transport dependencies +- **Explicit Dependency Relationships:** Each transport declares what it needs +- **Flexible Installation:** Users choose exactly what they need: `pip install fastcs[epicspva,rest]` +- **Development Convenience:** `pip install fastcs[dev]` includes everything for development +- **Clear Documentation:** Installation commands are self-documenting + +### Installation Patterns + +**Minimal (core only):** +```bash +pip install fastcs +``` + +**Single transport:** +```bash +pip install fastcs[epicspva] # EPICS PVA +pip install fastcs[rest] # REST API +``` + +**Multiple transports:** +```bash +pip install fastcs[epics,rest] # EPICS CA + PVA + REST +``` + +**All transports:** +```bash +pip install fastcs[all] +``` + +**Development:** +```bash +pip install fastcs[dev] +``` diff --git a/docs/explanations/decisions/0009-handler-to-attribute-io-pattern.md b/docs/explanations/decisions/0009-handler-to-attribute-io-pattern.md new file mode 100644 index 000000000..756224233 --- /dev/null +++ b/docs/explanations/decisions/0009-handler-to-attribute-io-pattern.md @@ -0,0 +1,130 @@ +# 9. Replace Handler with AttributeIO/AttributeIORef Pattern + +Date: 2025-10-03 + +**Related:** [PR #218](https://github.com/DiamondLightSource/FastCS/pull/218) + +## Status + +Accepted + +## Context + +Currently the `Handler` pattern is used to manage attribute I/O operations. This design has several classes: + +- `AttrHandlerR` previously `Updater` - Protocol for reading/updating attribute values +- `AttrHandlerW` previously `Sender` - Protocol for writing/setting attribute values +- `AttrHandlerRW` previously `Handler` - Combined read-write handler +- `SimpleAttrHandler` - Basic implementation for internal parameters + +There are a few limitations with this architecture: + +1. **Handler Instance per Attribute:** Every attribute needed its own Handler instance because that's where the specification connecting the attribute to a unique resource live is defined. This means redundant Handler instances when multiple attributes use the same I/O pattern + +2. **Circular Reference Loop:** The architecture has circular dependencies: + - Controller → Attributes (controller owns attributes) + - Attributes → Handlers (each attribute has a handler) + - Handlers → Controller (handlers need controller reference to communicate with device) + +3. **Tight Coupling to Controllers:** Handlers need direct references to Controllers, coupling I/O logic to the controller structure rather than just to the underlying connections (e.g., hardware interfaces, network connections) + +4. **Mixed Concerns:** Handlers combine resource specification (what to connect to) with I/O behavior (how to read/write), making both harder to reason about + +The system needs a more flexible way to: +- Share a single AttributeIO instance across multiple attributes +- Use lightweight AttributeIORef instances to specify resource connections per-attribute +- Break the circular dependency chain +- Validate that Controllers have exactly one AttributeIO to handle each Attribute +- Separate resource specification from I/O behavior + +## Decision + +Replace the `Handler` pattern with a two-component system: `AttributeIO` for behavior and `AttributeIORef` for configuration. + +Key architectural changes: + +1. **AttributeIORef** - Lightweight resource specification per-attribute: + - Lightweight dataclass specifying resource connection details + - Can be subclassed to add fields like resource names, register addresses, etc. + - Attributes have unique AttributeIORef instances + - Dynamically connected to a single AttributeIO instance at runtime + +2. **AttributeIO** - Shared I/O behavior: + - Single instance per Controller handles multiple Attributes + - Generic class parameterized by data type `T` and reference type `AttributeIORefT` + - Accesses the AttributeIORef from the attribute to know which resource to access + - Only needs to know about connections, not controllers + +3. **Parameterized Attributes:** + - Attributes are now parameterized with `AttributeIORef` types + - `AttrR[T, AttributeIORefT]` - Read-only attribute with typed I/O reference + - `AttrRW[T, AttributeIORefT]` - Read-write attribute with typed I/O reference + - Type system ensures matching between AttributeIO and AttributeIORef + +4. **Initialization Validation:** + - Controller validates at initialization that it has exactly one AttributeIO to handle each Attribute + - Ensures all attributes are properly connected before serving the Controller API + +## Consequences + +### Migration Impact + +Users and developers need to: + +**Before (Handler pattern - one instance per attribute):** +```python +# Temperature controller that communicates via TCP/IP +class TempControllerHandler(AttrHandlerRW): + def __init__(self, register_name: str, controller: TempController): + self.register_name = register_name + self.connection = connection + self.update_period = 0.2 + self.controller = controller + + async def initialise(self, controller): + self.controller_ref = controller # Creates circular dependency + + async def update(self, attr): + # Each attribute needs its own handler instance + query = f"{self.register_name}?" + response = await self.connection.send_query(f"{query}\r\n") + value = float(response.strip()) + await attr.set(value) + + async def put(self, attr, value): + command = f"{self.register_name}={value}" + await self.connection.send_command(f"{command}\r\n") + +controller = TempController() +ramp_rate = AttrRW(Float(), handler=TempControllerHandler("R", controller)) +power = AttrR(Float(), handler=TempControllerHandler("P", controller)) +setpoint = AttrRW(Float(), handler=TempControllerHandler("S", controller)) +``` + +**After (AttributeIO pattern - shared instance):** +```python +@dataclass +class TempControllerIORef(AttributeIORef): + name: str # Register name like "R", "P", "S" + update_period: float = 0.2 + +class TempControllerAttributeIO(AttributeIO[float, TempControllerIORef]): + def __init__(self, connection: IPConnection): + self.connection = connection + + async def update(self, attr: AttrR[float, TempControllerIORef]) -> None: + query = f"{attr.io_ref.name}?" + response = await self.connection.send_query(f"{query}\r\n") + value = float(response.strip()) + await attr.update(value) + + async def send(self, attr: AttrRW[float, TempControllerIORef], value: float) -> None: + command = f"{attr.io_ref.name}={value}" + await self.connection.send_command(f"{command}\r\n") + +connection = IPConnection() +temp_io = TempControllerAttributeIO(connection) +ramp_rate = AttrRW(Float(), io_ref=TempControllerIORef(name="R")) +power = AttrR(Float(), io_ref=TempControllerIORef(name="P")) +setpoint = AttrRW(Float(), io_ref=TempControllerIORef(name="S")) +``` diff --git a/docs/explanations/decisions/0010-subcontroller-removal.md b/docs/explanations/decisions/0010-subcontroller-removal.md new file mode 100644 index 000000000..a0d4e5ade --- /dev/null +++ b/docs/explanations/decisions/0010-subcontroller-removal.md @@ -0,0 +1,68 @@ +# 10. Remove SubController Class + +Date: 2025-10-01 + +**Related:** [PR #222](https://github.com/DiamondLightSource/FastCS/pull/222) + +## Status + +Accepted + +## Context + +FastCS provides two separate classes for building controller hierarchies: `Controller` for top-level controllers and `SubController` for nested components. This has become a purely philosophical distinction and now just adds limitations for now benefit: + +- **Design-Time Commitment:** Developers had to choose class at definition time, before knowing all contexts where components might be used +- **Reduced Reusability:** A component designed as `SubController` couldn't be reused as a top-level controller without changing its base class + +## Decision + +Unify `Controller` and `SubController` into a single `Controller` class that can be used in both top-level and nested contexts. Whether a Controller is "top-level" or "nested" is now determined by how it is used, not by its class. + +Key architectural changes: +- Remove `SubController` class entirely +- Move `root_attribute` property to `Controller` +- Rename `register_sub_controller()` to `add_sub_controller()` for consistency +- Any Controller instance can now be nested in any other Controller + +## Consequences + +### Benefits + +- **Composition over Inheritance:** Hierarchy determined by usage, not class definition +- **Increased Reusability:** Controllers work in any context without refactoring +- **Simpler Mental Model:** One class for all controller use cases +- **Reduced Coupling:** No design-time commitment to hierarchy level +- **Easier Evolution:** Controllers can start standalone and be nested later + +### Migration Pattern + +**Before (Two classes):** +```python +from fastcs import Controller, SubController + +class RampController(SubController): # Forced to use SubController + start = AttrRW(Int()) + end = AttrRW(Int()) + +class TempController(Controller): # Forced to use Controller + def __init__(self): + super().__init__() + self.register_sub_controller("Ramp1", RampController()) +``` + +**After (One class):** +```python +from fastcs import Controller + +class RampController(Controller): # Just use Controller + start = AttrRW(Int()) + end = AttrRW(Int()) + +class TempController(Controller): # Just use Controller + def __init__(self): + super().__init__() + self.add_sub_controller("Ramp1", RampController()) + +# RampController can now be used as a top-level controller or nested +``` diff --git a/docs/explanations/decisions/0011-controller-vector-implementation.md b/docs/explanations/decisions/0011-controller-vector-implementation.md new file mode 100644 index 000000000..677171a7b --- /dev/null +++ b/docs/explanations/decisions/0011-controller-vector-implementation.md @@ -0,0 +1,90 @@ +# 11. Introduce ControllerVector for Indexed Sub-Controllers + +Date: 2025-11-10 + +**Related:** [PR #192](https://github.com/DiamondLightSource/FastCS/pull/192) + +## Status + +Accepted + +## Context + +Many devices have multiple identical components that need individual control: multi-axis motion stages, multi-channel power supplies, camera ROI regions, etc. Before ControllerVector, developers manually registered each indexed controller with string-based names: + +```python +for i in range(num_axes): + motor = MotorController() + self.add_sub_controller(f"Axis{i}", motor) # String-based naming +``` + +This approach lacks collection semantics. Accessing controllers requires string manipulation (`controller.sub_controllers["Axis0"]`) and heuristics to test if an attribute is numerical, rather than natural indexing (`controller.axes[0]`). + +## Decision + +Introduce `ControllerVector`, a specialized controller type for managing collections of indexed sub-controllers with dict-like semantics. + +ControllerVector implements `MutableMapping` with integer-only keys, providing natural indexing, iteration, and length operations. It supports non-contiguous indices and can have shared attributes alongside the indexed sub-controllers. + +Key architectural changes: +- `ControllerVector` implements `__getitem__`, `__setitem__`, `__iter__`, `__len__` +- Keys enforced to be integers only +- Supports sparse indexing: `{1: motor1, 5: motor5, 10: motor10}` +- Can be subclassed to add shared attributes +- Inherits from `BaseController` for full integration with controller hierarchy + +## Consequences + +### Benefits + +- **Natural Collection Semantics:** Dict-like interface provides familiar indexing and iteration +- **Consistency:** Integer-only keys prevent naming inconsistencies +- **Clear Intent:** ControllerVector explicitly signals "collection of identical components" +- **Sparse Collections:** Non-contiguous indices support flexible numbering schemes +- **Type Safety:** Integer indexing enforced by type hints and runtime checks +- **Shared Attributes:** Can add attributes to the vector itself, separate from indexed components + +### Migration Pattern + +**Before (Manual registration):** +```python +class StageController(Controller): + def __init__(self, num_axes: int): + super().__init__() + for i in range(num_axes): + motor = MotorController() + self.add_sub_controller(f"Axis{i}", motor) + + # Access via string keys + first = self.sub_controllers["Axis0"] +``` + +**After (ControllerVector):** +```python +class StageController(Controller): + def __init__(self, num_axes: int): + super().__init__() + self.axes = ControllerVector( + {i: MotorController() for i in range(num_axes)}, + description="Motor axes" + ) + + # Natural dict-like access + first = self.axes[0] + for i, motor in self.axes.items(): + print(f"Motor {i}: {motor}") +``` + +**With Shared Attributes:** +```python +class AxesVector(ControllerVector): + enabled = AttrRW(Bool()) # Shared across all axes + +class StageController(Controller): + def __init__(self, num_axes: int): + super().__init__() + self.axes = AxesVector( + {i: MotorController() for i in range(num_axes)}, + description="Motor axes" + ) +```