diff --git a/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md new file mode 100644 index 0000000000..a1611987f3 --- /dev/null +++ b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md @@ -0,0 +1,70 @@ +# G6: Domain-blind viewers + +**Head audited:** `openhcs` `main` at `1c1059867` (#1172). **Rules:** [00-RULES.md](00-RULES.md). **Step 3.** Replaces C7. +**Architecture:** [04-ARCHITECTURE.md](04-ARCHITECTURE.md), "Viewers". *Builds:* `DeclaredAxis`, `ViewerSlotFamily` (napari and Fiji leaves), one `ViewerControlAction` family, one `ViewerFamily` class per viewer. *Uses:* G1's `Axis`/`AxisRole`, `AutoRegisterMeta`. + +## What is wrong + +**The viewer processes learn which axes exist from the producer's active axis family instead of from the payload, the placement vocabulary is a flat enum restated by two config enums, and each viewer restates the lifecycle control actions, the viewer identity and its forwarding (IDEN-1, MEMB-2, IMPL-2, IMPL-4).** + +- **Exact-set rejection.** `AxisRoleModeDisplayConfig.role_modes_from_wire` (`core/config.py:281-306`) rebuilds the display config in the viewer process from `AxisFamily.active()` and raises unless the payload's `component_modes` names exactly the viewer process's family. Eight more `AxisFamily.active()` reads run in the viewer process: `viewer_component_system.py:107,127,384` (labels, ordinal normalisation), `napari_viewer_server.py:1024` (partition → LAYER), `:1306,2377` (fractional-Z stack), `napari_streaming_handlers.py:1426` (spatial labels). A payload streamed under any other family is rejected or mislabelled. +- **The wire carries no axis declarations.** `display_config` carries `component_modes` and `component_order` (names only); roles, labels and value kinds are re-derived on the receiving side. +- **Flat placement enum.** zmqruntime `ViewerComponentMode` (`viewer_protocol.py:285`) mixes napari (`stack`, `layer`) and Fiji (`window`, `channel`, `slice`, `frame`) slots; `NapariDimensionMode` and `FijiDimensionMode` (`config.py:309,447`) restate both subsets by hand, and every config default restates the Fiji role mapping (colour→CHANNEL, stack→SLICE, else FRAME, `config.py:491-534`). PolyStore's Fiji window grouping (`receivers/core/window_projection.py:24-165`) and napari route key (`receivers/napari/layer_key.py:51`) re-read the flat enum; only OpenHCS calls them. +- **Labels by role table.** `ViewerAxisLabelStrategy` and its four leaves (`viewer_component_system.py:97-160`) hardcode `Ch`/`Z`/`T` per role instead of reading a declared label. +- **Two control families restate the lifecycle.** `NapariControlMessageAction` (`napari_viewer_server.py:3036-3296`) and `FijiControlMessagePlan` (`fiji_viewer_server.py:1229-1495`) each declare shutdown, force-shutdown, clear-state, process-launch and settle; Fiji spells the first three as string literals. **Bug:** `FijiControlMessageAuthority.response_for` answers an unregistered message with SUCCESS (`fiji_viewer_server.py:1490`), so MCP navigate/viewport/measure requests to Fiji silently do nothing. Fiji carries three `FijiUnsupported*Plan` placeholders that restate the unknown-message error, and its pong advertises no control capabilities. +- **Identity and forwarding.** `ViewerType` (enum, `streaming_config_declarations.py:66`) points at `*ViewerDeclaration` leaves; `StreamingConfigBehaviorMixin` forwards six properties to it (`streaming_config_factory.py:185-209`), and `config_key`/`from_config_key` spell and reverse-search an f-string. `start_viewer`/`stop_viewer` are written twice (`napari_stream_visualizer.py:95-170`, `fiji_stream_visualizer.py:69-112`); `from_display_payload` is written twice (`config.py:412-435, 548-570`). +- **Import reach.** `agent/dto/viewer.py:44` imports `agent/dto/execution.py` for `ExecutionConnectionProjection`; through `zmq_execution_client` that loads 203 openhcs modules, 16 of them `openhcs.microscopes`, plus `core.orchestrator`, into the napari server process. + +## Target + +```python +# openhcs/runtime/viewer_axes.py (imports only core.axes and the wire libraries) +@dataclass(frozen=True) +class DeclaredAxis: # on the wire inside display_config + name: str; label: str; roles: tuple[type[AxisRole], ...]; value_kind: type[AxisValueKind] + @classmethod + def of(cls, axis: type[Axis]) -> DeclaredAxis: ... + +class ViewerSlot(ABC): # one native placement slot + wire_value: ClassVar[str]; roles: ClassVar[tuple[type[AxisRole], ...]] = () +class ViewerSlotFamily(ABC): # per viewer; leaves register by inheritance + default_slot: ClassVar[type[ViewerSlot]] + def slot_for(axis: DeclaredAxis) -> type[ViewerSlot] # first slot owning one of its roles + def named(wire_value) -> type[ViewerSlot]; def choice_enum(name) -> type[Enum] # form boundary only +class NapariSlots(ViewerSlotFamily): Stack (default), Layer +class FijiSlots(ViewerSlotFamily): Channel(ColourAxis), Slice(StackAxis), Frame(TimeAxis, default), Window +``` + +- The display-config wire section carries `declared_axes` (name, label, roles, value kind) beside `component_modes`/`component_order`. The viewer validates `component_modes` and `component_order` against the payload's own declared axes; it never reads `AxisFamily.active()`. Role queries (partition → separate layers, stack → fractional Z and spatial labels, labels, ordinal normalisation) read `DeclaredAxis`. +- `Axis.label` is declared on the axis (default `name.title()`); the microscopy family declares `Ch`, `Z`, `T`. `ViewerAxisLabelStrategy` and its leaves are deleted. +- Per-role mode fields stay (G1); their defaults come from the slot family, an axis with no matching field takes the family's slot for its roles, and `NapariDimensionMode`/`FijiDimensionMode` are views derived from the slot families (same member names, so saved configs load unchanged). +- The viewer process decodes viewer-side settings (`NapariDisplaySettings`, `FijiDisplaySettings`) once through one generic decoder; `from_display_payload` is deleted from `config.py`, and the viewer servers no longer import `openhcs.core.config`. +- zmqruntime: `ViewerComponentMode`, `ViewerComponentModeGroups`, `viewer_component_mode_value` and the mode-grouping helpers are deleted (lockstep PR, version bump). PolyStore: the Fiji window grouping and napari route key move to the OpenHCS viewer modules that call them (lockstep PR, version bump). +- **One control family** (`openhcs/runtime/viewer_control_actions.py`): `ViewerControlAction` keyed by the control message-type wire value, per-viewer registries (`NapariControlAction`, `FijiControlAction`), lifecycle mixins (shutdown, force-shutdown, clear-state, process-launch, settle) written once over a `ViewerServerPort`, one shared `UnknownControlAction` that answers ERROR, and pong capabilities derived from each registry for both viewers. The `FijiUnsupported*` plans are deleted (the shared ERROR answers them). +- **One `ViewerFamily` class per viewer** (`NapariViewer`, `FijiViewer`) owns backend, config key, title, visualizer, slot family and entrypoint; `ViewerType` becomes the boundary view derived from the registry; `ViewerDeclarationABC` and its two leaves are deleted; `StreamingConfigBehaviorMixin` reads the family directly. `start_viewer`/`stop_viewer` move into `ManagedViewerLifecycleMixin` once. +- `ExecutionConnectionProjection` (renamed `ExecutionConnectionFields`, rule 1b) moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`. + +## Guards + +`tests/unit/test_g6_viewer_guards.py`: +1. Importing `openhcs.runtime.napari_viewer_server` and `openhcs.runtime.fiji_viewer_server` (fresh subprocess) loads no `openhcs.microscopes`, no `openhcs.core.orchestrator` and no `openhcs.core.config`. +2. No `AxisFamily` reference in the viewer modules (`runtime/*viewer*`, `napari_*`, `fiji_*`, `viewer_component_system`). +3. No `ViewerComponentMode` anywhere in `openhcs/` or the submodules' `src/`. +4. No `ControlMessagePlan`, `Unsupported*ControlPlan` or `NapariControlMessageAction` names; no `"shutdown"`/`"force_shutdown"`/`"clear_state"` literals in the Fiji server. +5. No `ViewerDeclarationABC`; `agent/dto/viewer.py` imports nothing from `agent.dto.execution`. + +## Tests + +- One family test over both viewers' control registries: lifecycle actions present, unknown message answers ERROR, capabilities derived from the registry. +- One new-case test: a non-microscopy family (`Scene` partition, `Band` colour, `Date` time, no stack) streams a display payload that a viewer decodes against its own declared axes, with Fiji slots C/T/FRAME and labels from declarations, while the process's active family is microscopy. +- Existing viewer tests that assert the exact-set rejection, `ViewerAxisLabelStrategy`, `ViewerComponentMode` members or the `FijiUnsupported*` plans are deleted or rewritten at the family level. + +## New-case experiments + +- *A new domain axis with no viewer role* (for example a `Replicate` with only `DefaultVariable`). Today: `mode_field_for_axis` raises and both viewers reject the payload unless the viewer process's family matches; three label leaves would need a fourth. After: zero edits; it takes each viewer's default slot and its declared label. +- *A new control message on Fiji.* Today: a plan class, plus the unknown path silently succeeds if forgotten. After: one leaf; forgetting it answers ERROR and the capability list omits it. +- *A third viewer.* Today: an enum member, a declaration leaf, a visualizer with its own start/stop, a config with hand-written mode fields and `from_display_payload`, a control family. After: one `ViewerFamily` leaf, one slot family, one settings dataclass and its control leaves. + +## Done when + +The guards pass; the exact-set rejection, `ViewerComponentMode`, `ViewerAxisLabelStrategy`, `FijiControlMessagePlan`, `NapariControlMessageAction`, `ViewerDeclarationABC` and the duplicated start/stop and `from_display_payload` are gone; Fiji answers unknown control messages with ERROR and advertises its registry; the viewer tests touched pass; no viewer process from the worktree remains. diff --git a/docs/source/architecture/streaming_boundary_and_wrappers.rst b/docs/source/architecture/streaming_boundary_and_wrappers.rst index 87e0ee1b1f..2c0d2240c9 100644 --- a/docs/source/architecture/streaming_boundary_and_wrappers.rst +++ b/docs/source/architecture/streaming_boundary_and_wrappers.rst @@ -100,13 +100,19 @@ Viewer startup and readiness ``ManagedViewerLifecycleMixin`` is OpenHCS's application lifecycle boundary; ZMQRuntime's ``ViewerStateManager`` remains the process-wide instance registry. -``ViewerType`` is the singular OpenHCS viewer-family authority. Each member -carries its nominal viewer declaration, while configuration keys, compiled -output keys, presentation, backend selection, and lifecycle construction are -projected from that member. The streaming-config registry is keyed by these -members; ObjectState field names are boundary projections rather than registry -identities. Lowercase viewer names exist only as explicit wire identifiers at -the generic ZMQRuntime boundary. +Each viewer is one ``ViewerFamily`` class (``NapariViewer``, ``FijiViewer``) +declaring its wire name, backend, display settings and lifecycle class; +configuration keys, compiled output keys and titles derive from it. The +streaming-config registry is keyed by these classes, and ``ViewerType`` is the +boundary enum derived from the registry for wire and form fields. + +A stream's display config carries its own axis declarations +(``DeclaredAxis``: name, label, roles, value kind). A viewer lays the stream +out from those declarations and its own ``ViewerSlotFamily`` (napari: stack or +layer; Fiji: C, Z, T or a window), never from the axis family active in the +viewer process, so it shows any domain's axes. Control messages go through one +``ViewerControlAction`` family: every viewer inherits the lifecycle actions, +unknown messages answer ERROR, and pong capabilities derive from the registry. An existing endpoint is reusable only after a typed control reply proves that a compatible viewer is ready. Endpoint files or bound ports are discovery facts, not readiness evidence. diff --git a/external/PolyStore b/external/PolyStore index 3938236ecd..f6af43a9ba 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 3938236ecd1ed09e73c31c89304247b1409eff9b +Subproject commit f6af43a9ba17f605911033637e6e0a6a1e44f5dc diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 29d2ad3196..cccf947ad9 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 29d2ad319642de047410bca00c978a115e3f3452 +Subproject commit cccf947ad9ae7ae21f1055de6f82d36bdf2d2b7c diff --git a/external/python-introspect b/external/python-introspect index c008baa8ae..4bb7d132d7 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit c008baa8aec7a7481b37ea66e80539d25726499c +Subproject commit 4bb7d132d7a789c0ab9eaea556b4e4628b26737b diff --git a/external/zmqruntime b/external/zmqruntime index e1047d0fad..079a1c41d2 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit e1047d0fad9397bde1f5faa0092ce9f5d99904ba +Subproject commit 079a1c41d28c02adce84a3852e4baab817ea4f27 diff --git a/openhcs/agent/dto/execution.py b/openhcs/agent/dto/execution.py index f12196a770..35d23288ac 100644 --- a/openhcs/agent/dto/execution.py +++ b/openhcs/agent/dto/execution.py @@ -35,7 +35,10 @@ AgentResultEnvelope, AgentWarning, ) -from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec +from openhcs.agent.dto.execution_connection import ( + ExecutionConnectionFields, + ExecutionConnectionSpec, +) from openhcs.agent.ui_bridge_identities import ( PlateManagerOrchestratorCodeDocumentIdentity, ) @@ -48,23 +51,6 @@ MAX_EXECUTION_STATUS_TRACEBACK_CHARS = 3000 -@dataclass(frozen=True, kw_only=True) -class ExecutionConnectionProjection: - connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec) - - @property - def host(self) -> str: - return self.connection.host - - @property - def port(self) -> int | None: - return self.connection.port - - @property - def transport_mode(self) -> TransportMode | None: - return self.connection.transport_mode - - class RuntimeServerConnectionToolRequest(AgentCliRequest): """Nominal request type for runtime-server tools using connection fields.""" @@ -120,7 +106,7 @@ class OrchestratorSessionRef(OrchestratorSessionIdentity): @dataclass(frozen=True, slots=True) -class OrchestratorSessionCreationRequest(ExecutionConnectionProjection): +class OrchestratorSessionCreationRequest(ExecutionConnectionFields): """Create an execution session from an in-memory pipeline draft.""" plate_path: str @@ -159,7 +145,7 @@ def from_fields( @dataclass(frozen=True, slots=True) -class PipelineSourceOrchestratorSessionRequest(ExecutionConnectionProjection): +class PipelineSourceOrchestratorSessionRequest(ExecutionConnectionFields): """Create an execution session from pycodified pipeline source.""" plate_path: str @@ -428,7 +414,7 @@ class ArtifactPlanInspection(AgentResultEnvelope): @dataclass(frozen=True, slots=True) -class RuntimeServerInfo(ExecutionConnectionProjection): +class RuntimeServerInfo(ExecutionConnectionFields): schema_version: str reachable: bool ready: bool | None = None @@ -472,7 +458,7 @@ def from_pong( @dataclass(frozen=True, slots=True) -class RuntimeServerScanResult(ExecutionConnectionProjection): +class RuntimeServerScanResult(ExecutionConnectionFields): schema_version: str ports: tuple[int, ...] timeout_ms: int @@ -580,7 +566,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) class RuntimeServerInfoRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request a read-only runtime-server snapshot.""" @@ -724,7 +710,7 @@ class RuntimeBootstrapCloseResult(AgentResultEnvelope): @dataclass(frozen=True, slots=True) -class RuntimeExecutionStatus(ExecutionConnectionProjection): +class RuntimeExecutionStatus(ExecutionConnectionFields): schema_version: str execution_id: str | None status: str @@ -735,7 +721,7 @@ class RuntimeExecutionStatus(ExecutionConnectionProjection): @dataclass(frozen=True, slots=True) class RuntimeServerExecutionStatusRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request bounded execution status from a runtime server.""" @@ -775,7 +761,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) -class RuntimeDebugInspectionResult(ExecutionConnectionProjection): +class RuntimeDebugInspectionResult(ExecutionConnectionFields): """Renderer-independent values visible in one paused debug worker.""" schema_version: str @@ -787,7 +773,7 @@ class RuntimeDebugInspectionResult(ExecutionConnectionProjection): @dataclass(frozen=True, slots=True) class RuntimeDebugInspectionRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request the typed runtime-value view for one paused debug session.""" @@ -839,7 +825,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) class RuntimeDebugCommandRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request one debug-worker command on a paused OpenHCS debug session. @@ -908,7 +894,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) -class RuntimeDebugCommandResult(ExecutionConnectionProjection): +class RuntimeDebugCommandResult(ExecutionConnectionFields): """One debug-worker command's resulting paused-worker status.""" schema_version: str @@ -922,7 +908,7 @@ class RuntimeDebugCommandResult(ExecutionConnectionProjection): @dataclass(frozen=True, slots=True) class RuntimeDebugArtifactExportRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request server-side export of one paused debug worker's artifact. @@ -1013,7 +999,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) -class RuntimeDebugArtifactExportResult(ExecutionConnectionProjection): +class RuntimeDebugArtifactExportResult(ExecutionConnectionFields): """One debug artifact export's resulting exported reference.""" schema_version: str diff --git a/openhcs/agent/dto/execution_connection.py b/openhcs/agent/dto/execution_connection.py index bb29fc2f94..8a63ac6ba5 100644 --- a/openhcs/agent/dto/execution_connection.py +++ b/openhcs/agent/dto/execution_connection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, replace +from dataclasses import asdict, dataclass, field, replace from typing import TYPE_CHECKING from python_introspect import ( @@ -110,3 +110,22 @@ def execution_client( transport_mode=self.transport_mode, config=config, ) + + +@dataclass(frozen=True, kw_only=True) +class ExecutionConnectionFields: + """A request or result that carries one execution connection.""" + + connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec) + + @property + def host(self) -> str: + return self.connection.host + + @property + def port(self) -> int | None: + return self.connection.port + + @property + def transport_mode(self) -> TransportMode | None: + return self.connection.transport_mode diff --git a/openhcs/agent/dto/plate.py b/openhcs/agent/dto/plate.py index cc5ac0bfec..08669d99b0 100644 --- a/openhcs/agent/dto/plate.py +++ b/openhcs/agent/dto/plate.py @@ -21,7 +21,7 @@ PlateFileKind, PlateFileKindSelection, ) -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import NapariViewer, ViewerType from openhcs.core.synthetic_plate_generation import ( SYNTHETIC_PLATE_GENERATION_PROFILE, SyntheticPlateFormat, @@ -390,7 +390,7 @@ class PlateFileStreamRequest: path_contains: str | None = None well: str | None = None limit: int = 1 - viewer_config_key: str = ViewerType.NAPARI.config_key + viewer_config_key: str = NapariViewer.config_key display_config: NapariDisplayConfig | None = None connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec) fresh_viewer: bool = False @@ -409,7 +409,7 @@ def from_fields( path_contains: str | None = None, well: str | None = None, limit: int = 1, - viewer_config_key: str = ViewerType.NAPARI.config_key, + viewer_config_key: str = NapariViewer.config_key, display_config: NapariDisplayConfig | None = None, host: str = "localhost", port: int | None = None, @@ -696,7 +696,7 @@ class SelectedPlateFileStreamRequest(SelectedPlateFileFilterOptions): """Stream files from the plate currently selected in the UI.""" file_paths: tuple[str, ...] = () - viewer_config_key: str = ViewerType.NAPARI.config_key + viewer_config_key: str = NapariViewer.config_key connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec) fresh_viewer: bool = False @@ -712,7 +712,7 @@ def from_fields( path_contains: str | None = None, well: str | None = None, limit: int = 1, - viewer_config_key: str = ViewerType.NAPARI.config_key, + viewer_config_key: str = NapariViewer.config_key, host: str = "localhost", port: int | None = None, transport_mode: TransportMode | None = None, diff --git a/openhcs/agent/dto/ui_bridge.py b/openhcs/agent/dto/ui_bridge.py index 74a8a90195..77d2471137 100644 --- a/openhcs/agent/dto/ui_bridge.py +++ b/openhcs/agent/dto/ui_bridge.py @@ -50,8 +50,8 @@ AgentTimedStatusEnvelope, AgentWarning, ) -from openhcs.agent.dto.execution import ( - ExecutionConnectionProjection, +from openhcs.agent.dto.execution_connection import ( + ExecutionConnectionFields, ExecutionConnectionSpec, ) from openhcs.agent.path_policy import DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR @@ -453,7 +453,7 @@ def from_fields( @dataclass(frozen=True, slots=True) class UiBridgeEndpointIdentity( - ExecutionConnectionProjection, + ExecutionConnectionFields, UiBridgeInstanceIdentity, UiBridgeDescriptorFileRef, ): @@ -462,7 +462,7 @@ class UiBridgeEndpointIdentity( @classmethod def from_value( cls, - value: ExecutionConnectionProjection, + value: ExecutionConnectionFields, ) -> UiBridgeEndpointIdentity: """Project identity from any declared UI bridge endpoint carrier.""" @@ -489,7 +489,7 @@ class UiBridgeDescriptorEnvelope( @dataclass(frozen=True, slots=True) class UiBridgeDescriptorFile( - ExecutionConnectionProjection, + ExecutionConnectionFields, UiBridgeDescriptorEnvelope, UiBridgeDescriptorFileRef, ): @@ -514,7 +514,7 @@ def public_summary(self, status: str) -> UiBridgeDescriptorSummary: @dataclass(frozen=True, slots=True) class UiBridgeDescriptorWirePayload( - ExecutionConnectionProjection, + ExecutionConnectionFields, UiBridgeDescriptorEnvelope, ): """JSON descriptor file payload written by a running UI bridge.""" @@ -523,7 +523,7 @@ class UiBridgeDescriptorWirePayload( @dataclass(frozen=True, slots=True) class UiBridgeDescriptorSummary( AgentTimedStatusEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, UiBridgeApplicationIdentity, UiBridgeInstanceIdentity, UiBridgeDescriptorFileRef, @@ -541,7 +541,7 @@ class UiBridgeCatalog: @dataclass(frozen=True, slots=True) class UiBridgeStatus( - ExecutionConnectionProjection, + ExecutionConnectionFields, UiBridgeInstanceIdentity, UiBridgeDescriptorFileRef, ): diff --git a/openhcs/agent/dto/viewer.py b/openhcs/agent/dto/viewer.py index 1144b62775..91e4f38581 100644 --- a/openhcs/agent/dto/viewer.py +++ b/openhcs/agent/dto/viewer.py @@ -41,8 +41,8 @@ AgentResourceRef, AgentResultEnvelope, ) -from openhcs.agent.dto.execution import ( - ExecutionConnectionProjection, +from openhcs.agent.dto.execution_connection import ( + ExecutionConnectionFields, ExecutionConnectionSpec, ) from openhcs.agent.path_policy import DEFAULT_AGENT_WINDOW_SNAPSHOT_DIR @@ -71,7 +71,7 @@ OpenHCSViewerControlMessageType, ViewerProtocolStatus, ViewerImageColorControlOptions, ViewerNativeImageColorPresentation, ViewerNativeWindowControlOptions, ViewerNativeWindowState, - ViewerLayerRetirementReceipt, + ViewerLayerRetirementResult, ) VIEWER_WINDOW_CONTROL_TIMEOUT_MS_DEFAULT = 5000 @@ -92,13 +92,13 @@ def from_wire_fields( title: str, ) -> Self: return cls( - viewer_type=ViewerType.from_wire_value(viewer_wire_value), + viewer_type=ViewerType(viewer_wire_value), title=title, ) @dataclass(frozen=True, slots=True, kw_only=True) -class ViewerWindowControlRequest(ExecutionConnectionProjection): +class ViewerWindowControlRequest(ExecutionConnectionFields): """Shared control-message request fields for running viewer windows.""" timeout_ms: int = VIEWER_WINDOW_CONTROL_TIMEOUT_MS_DEFAULT @@ -118,7 +118,7 @@ def control_deadline(self) -> OperationDeadline: @classmethod def factory_injected_field_names(cls) -> frozenset[str]: connection_field_names = frozenset( - field.name for field in dataclass_fields(ExecutionConnectionProjection) + field.name for field in dataclass_fields(ExecutionConnectionFields) ) timeout_field_names = frozenset( field.name @@ -1010,7 +1010,7 @@ def require_nonzero_payloads(self) -> bool: @dataclass(frozen=True, slots=True) -class ViewerWindowProbeResult(AgentResultEnvelope, ExecutionConnectionProjection): +class ViewerWindowProbeResult(AgentResultEnvelope, ExecutionConnectionFields): reachable: bool observed: bool = False viewer: ViewerWindowDescriptor | None = None @@ -1168,7 +1168,7 @@ class ViewerWindowSnapshotResult( WindowSnapshotCaptureSpec, ViewerWindowSnapshotErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "snapshot" @@ -1185,7 +1185,7 @@ class ViewerWindowSnapshotResult( class ViewerWindowStateResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "state" @@ -1305,7 +1305,7 @@ class ViewerWindowLayerPayloads(ViewerWindowLayerDescriptor): class ViewerWindowPayloadResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "payload" @@ -1320,7 +1320,7 @@ class ViewerWindowPayloadResult( class ViewerWindowPolylineMeasurementResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "polyline_measurement" observed: bool @@ -1332,7 +1332,7 @@ class ViewerWindowPolylineMeasurementResult( class ViewerWindowRegionMeasurementResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "region_measurement" observed: bool @@ -1344,7 +1344,7 @@ class ViewerWindowRegionMeasurementResult( class ViewerWindowPresentationResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """One reply admission and error lifecycle shared by native presentations.""" @@ -1402,7 +1402,7 @@ class ViewerWindowNativePresentationResult(ViewerWindowPresentationResult): class ViewerWindowImageIntensityResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "image_intensity" @@ -1416,7 +1416,7 @@ class ViewerWindowImageIntensityResult( class ViewerWindowNavigationResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "navigation" @@ -1446,11 +1446,11 @@ class ViewerWindowLayerVisibilityRecord: @dataclass(frozen=True, slots=True, kw_only=True) class ViewerWindowLayerRetirementResult( ViewerWindowPresentationResult, - ViewerLayerRetirementReceipt, + ViewerLayerRetirementResult, ): registry_key: ClassVar[str] = "layer_retirement" response_field = ViewerControlField.RETIREMENT - snapshot_type = ViewerLayerRetirementReceipt + snapshot_type = ViewerLayerRetirementResult observed: bool = field(kw_only=True) @classmethod @@ -1478,7 +1478,7 @@ def admit_request(self, request: ViewerWindowLayerRetirementRequest) -> Self: class ViewerWindowLayerIsolationResult( ViewerWindowObservedErrorResultMixin, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): registry_key: ClassVar[str] = "layer_isolation" @@ -1510,7 +1510,7 @@ class ViewerWindowIntensityPayloadIdentity: @dataclass(frozen=True, slots=True, kw_only=True) class ViewerWindowIntensityWindowResult( AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Typed outcome of one route-global native viewer contrast mutation.""" @@ -1670,7 +1670,7 @@ class ViewerWindowValidationSummaryResult( ViewerWindowValidationErrorResultMixin, ViewerWindowValidationCounters, AgentResultEnvelope, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): observed: bool = False viewer: ViewerWindowDescriptor | None = None @@ -1703,7 +1703,7 @@ def require_nonzero_payloads(self) -> bool: @dataclass(frozen=True, slots=True, kw_only=True) -class ViewerWindowErrorContext(ExecutionConnectionProjection): +class ViewerWindowErrorContext(ExecutionConnectionFields): """Nominal source context for viewer result errors.""" error: AgentError diff --git a/openhcs/agent/services/execution_session_service.py b/openhcs/agent/services/execution_session_service.py index 47645a2a57..8108f6157a 100644 --- a/openhcs/agent/services/execution_session_service.py +++ b/openhcs/agent/services/execution_session_service.py @@ -1530,8 +1530,8 @@ def _viewer_streaming_summaries( summaries.append( ViewerStreamingPlanSummary( config_key=str(config_key), - viewer_type=config.viewer_type, - backend=str(config.backend.value), + viewer_type=config.viewer_family.viewer_type(), + backend=str(config.viewer_family.backend.value), effective_config=effective_config, ) ) diff --git a/openhcs/agent/services/plate_streaming_service.py b/openhcs/agent/services/plate_streaming_service.py index 65c32d725e..90ea1d0703 100644 --- a/openhcs/agent/services/plate_streaming_service.py +++ b/openhcs/agent/services/plate_streaming_service.py @@ -158,7 +158,7 @@ def stream_files( transport_mode=config.transport_mode, persistent=config.persistent, ) - result = replace(result, viewer_type=config.viewer_type, connection=connection) + result = replace(result, viewer_type=config.viewer_family.viewer_type(), connection=connection) if request.result_directory is not None: if request.kind is not PlateFileKind.RESULT or request.well is not None: raise ValueError( @@ -302,7 +302,7 @@ def stream_files( except Exception as exc: return replace( result, - viewer_type=None if config is None else config.viewer_type, + viewer_type=None if config is None else config.viewer_family.viewer_type(), connection=connection, errors=(self._stream_error(exc, plate_path=request.plate_path),), ) diff --git a/openhcs/core/axes.py b/openhcs/core/axes.py index c3b4105a49..fd77d1cd33 100644 --- a/openhcs/core/axes.py +++ b/openhcs/core/axes.py @@ -279,6 +279,8 @@ class Axis(GroupingDeclaration): """Token before this axis's value in plane filenames (variable axes).""" filename_padding: ClassVar[int] = 0 """Zero padding for ordinal values in plane filenames.""" + label: ClassVar[str] + """Short human-facing name, for example a viewer axis label (default: title-cased name).""" metadata_aliases: ClassVar[tuple[str, ...]] """Source-metadata field spellings that carry this axis (default: its name).""" metadata_collection_field: ClassVar[str] @@ -295,6 +297,8 @@ def _validate_declaration(cls) -> None: return # the declaring base itself if "name" not in cls.__dict__: raise TypeError(f"Axis {cls.__qualname__} must declare its boundary name.") + if "label" not in cls.__dict__: + cls.label = cls.name.replace("_", " ").title() kinds = [base for base in cls.__mro__ if AxisValueKind in base.__bases__] if len(kinds) != 1: raise TypeError( diff --git a/openhcs/core/config.py b/openhcs/core/config.py index 9664a941d6..7c2d842f02 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -8,7 +8,6 @@ import logging import inspect -from collections.abc import Mapping from dataclasses import dataclass, field, fields from pathlib import Path from typing import Optional, Union, List, Annotated, ClassVar @@ -45,11 +44,26 @@ TcpPort, TransportMode, ) -from zmqruntime.viewer_protocol import ViewerDisplayConfigWireField, ViewerWireValue +from zmqruntime.viewer_protocol import ViewerWireValue from zmqruntime.transport import get_default_transport_mode from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ( + FijiViewer, + NapariViewer, + ViewerFamily, + ViewerType, +) +from openhcs.runtime.viewer_display import ( + DECLARED_AXES_FIELD, + DeclaredAxes, + DeclaredAxis, + FijiDisplaySettings, + FijiSlots, + NapariDisplaySettings, + NapariSlots, + ViewerSlotFamily, +) from openhcs.core.vfs_protocol import PlateOutputDirectory, PlateOutputFile from openhcs.utils.environment import OpenHCSProcessEnvironment @@ -199,12 +213,13 @@ class GlobalPipelineConfig(AnnotatedDataclassValidationMixin): # (GlobalPipelineConfig → PipelineConfig by removing "Global" prefix) -class AxisRoleModeDisplayConfig: - """Viewer display config whose mode fields are declared per axis role. +class AxisRoleModeDisplayConfig(ViewerDisplayConfigABC): + """Viewer display config with one slot field per axis role. Each mode field names its role in ``metadata["axis_role"]``. An axis takes - the mode of the first mode field whose role it carries, so the wire - projection covers whichever family is active. + the slot of the first mode field whose role it carries, else the slot its + viewer's slot family gives its roles. The config streams the active + family's axis declarations, so the viewer never consults its own family. """ @classmethod @@ -219,320 +234,96 @@ def role_mode_fields(cls) -> tuple[tuple[type[AxisRole], str], ...]: def COMPONENT_ORDER(self) -> tuple[str, ...]: return AxisFamily.active().names() - @classmethod - def mode_field_for_axis(cls, axis: type[Axis]) -> str: - for role, field_name in cls.role_mode_fields(): + def slot_for_axis(self, axis: type[Axis]) -> str: + for role, field_name in self.role_mode_fields(): if issubclass(axis, role): - return field_name - raise TypeError( - f"Axis {axis!r} carries none of the viewer roles of {cls.__name__}: " - f"{[role.__name__ for role, _ in cls.role_mode_fields()]}." - ) + return getattr(self, field_name).value + return self.slot_family.slot_for(DeclaredAxis.of(axis)).wire_value def component_modes(self) -> dict[str, str]: - """Project role mode fields onto the active family's axis names.""" + """The slot of every axis of the active family, by axis name.""" + + return {axis.name: self.slot_for_axis(axis) for axis in AxisFamily.active().axes} + + def display_payload_extra(self) -> dict[str, ViewerWireValue]: + """Stream the axis declarations and this viewer's settings.""" return { - axis.name: getattr(self, self.mode_field_for_axis(axis)).value - for axis in AxisFamily.active().axes + DECLARED_AXES_FIELD: DeclaredAxes.of(AxisFamily.active().axes).to_wire(), + **self.settings_wire_mapping(), } - @classmethod - def role_modes_from_wire( - cls, - component_modes: Mapping[str, ViewerWireValue], - mode_type: type[Enum], - viewer_label: str, - ) -> dict[str, Enum]: - """Decode wire modes by axis name back onto the role mode fields.""" - - if not isinstance(component_modes, Mapping): - raise TypeError(f"{viewer_label} component_modes must be a mapping.") - family = AxisFamily.active() - if set(component_modes) != set(family.names()): - raise ValueError( - f"{viewer_label} component_modes must exactly match " - f"{family.names()!r}; got {tuple(component_modes)!r}." - ) - modes_by_field: dict[str, Enum] = {} - for axis in family.axes: - field_name = cls.mode_field_for_axis(axis) - mode = mode_type(str(component_modes[axis.name])) - if modes_by_field.setdefault(field_name, mode) is not mode: - raise ValueError( - f"{viewer_label} axes sharing {field_name} must share one mode; " - f"{axis.name} has {mode.value}." - ) - return modes_by_field - - -class NapariDimensionMode(Enum): - """How component values are placed in Napari image layers.""" - - LAYER = "layer" # Create a separate Napari layer for each value - STACK = "stack" # Stack values along an axis in one Napari layer - - -class NapariVariableSizeHandling(Enum): - """How to handle images with different sizes in the same layer.""" - - SEPARATE_LAYERS = ( - "separate_layers" # Create separate layers per well (preserves exact data) - ) - PAD_TO_MAX = "pad_to_max" # Pad smaller images to match largest (enables stacking) - -@dataclass(frozen=True) -class NapariDisplayConfig( - AxisRoleModeDisplayConfig, - ViewerDisplayConfigABC, - AnnotatedDataclassValidationMixin, +def role_slot_field( + slot_family: type[ViewerSlotFamily], + slot_choices: type[Enum], + role: type[AxisRole], + description: str, ): - """Map streamed axes and intensity data onto Napari layers.""" + """One per-role slot field whose default is the slot family's slot for the role.""" - colormap: NonBlankString = field( - default="gray", - metadata={ - "description": ( - "Name of a colormap registered in the installed Napari viewer. " - "Napari validates this extensible registry name when displaying " - "the layer." - ) - }, - ) - variable_size_handling: NapariVariableSizeHandling = field( - default=NapariVariableSizeHandling.PAD_TO_MAX, - metadata={ - "description": ( - "How Napari handles streamed images with different spatial " - "dimensions: preserve each shape in separate layers or pad " - "smaller images to the largest shape before stacking." - ) - }, - ) - partition_mode: NapariDimensionMode = field( - default=NapariDimensionMode.STACK, - metadata={ - "axis_role": PartitionAxis, - "description": ( - "Whether parallel-axis values are stacked or use separate layers." - ), - }, - ) - tile_mode: NapariDimensionMode = field( - default=NapariDimensionMode.STACK, - metadata={ - "axis_role": TileAxis, - "description": ( - "Whether tile-axis values are stacked or use separate layers." - ), - }, - ) - colour_mode: NapariDimensionMode = field( - default=NapariDimensionMode.STACK, - metadata={ - "axis_role": ColourAxis, - "description": ( - "Whether colour-axis values are stacked or use separate layers." - ), - }, - ) - stack_mode: NapariDimensionMode = field( - default=NapariDimensionMode.STACK, - metadata={ - "axis_role": StackAxis, - "description": ( - "Whether stack-axis values are stacked or use separate layers." - ), - }, - ) - time_mode: NapariDimensionMode = field( - default=NapariDimensionMode.STACK, - metadata={ - "axis_role": TimeAxis, - "description": ( - "Whether time-axis values are stacked or use separate layers." - ), - }, + return field( + default=slot_choices(slot_family.slot_for_roles((role,)).wire_value), + metadata={"axis_role": role, "description": description}, ) - def display_payload_extra(self) -> dict[str, str]: - """Project Napari-specific display settings onto the wire payload.""" - from polystore.napari_stream import NapariDisplayWireField +NapariDimensionMode = NapariSlots.choice_enum("NapariDimensionMode", __name__) +"""Form choices for Napari slots (layer or stack), derived from ``NapariSlots``.""" - return { - NapariDisplayWireField.COLORMAP.value: self.colormap, - NapariDisplayWireField.VARIABLE_SIZE_HANDLING.value: ( - self.variable_size_handling.value - ), - } - @classmethod - def from_display_payload( - cls, - payload: Mapping[str, ViewerWireValue], - ) -> "NapariDisplayConfig": - """Rehydrate the typed config at the Napari viewer wire boundary.""" +def _napari_slot_field(role: type[AxisRole], noun: str): + return role_slot_field( + NapariSlots, + NapariDimensionMode, + role, + f"Whether {noun}-axis values are stacked or use separate layers.", + ) - from polystore.napari_stream import NapariDisplayWireField - role_modes = cls.role_modes_from_wire( - payload[ViewerDisplayConfigWireField.COMPONENT_MODES.value], - NapariDimensionMode, - "Napari", - ) - colormap = str(payload[NapariDisplayWireField.COLORMAP.value]).strip() - if not colormap: - raise ValueError("Napari colormap name must not be blank.") - return cls( - colormap=colormap, - variable_size_handling=NapariVariableSizeHandling( - str(payload[NapariDisplayWireField.VARIABLE_SIZE_HANDLING.value]) - ), - **role_modes, - ) +@dataclass(frozen=True) +class NapariDisplayConfig(AxisRoleModeDisplayConfig, NapariDisplaySettings): + """Map streamed axes and intensity data onto Napari layers.""" + + partition_mode: NapariDimensionMode = _napari_slot_field(PartitionAxis, "parallel") + tile_mode: NapariDimensionMode = _napari_slot_field(TileAxis, "tile") + colour_mode: NapariDimensionMode = _napari_slot_field(ColourAxis, "colour") + stack_mode: NapariDimensionMode = _napari_slot_field(StackAxis, "stack") + time_mode: NapariDimensionMode = _napari_slot_field(TimeAxis, "time") # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by NapariStreamingConfig, so hide it from UI -NapariDisplayConfig = global_pipeline_config(ui_hidden=True)(NapariDisplayConfig) - +NapariDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_as_none=False)(NapariDisplayConfig) -# ============================================================================ -# Fiji Display Configuration -# ============================================================================ +FijiDimensionMode = FijiSlots.choice_enum("FijiDimensionMode", __name__) +"""Form choices for ImageJ hyperstack slots (C, Z, T or a window), derived from ``FijiSlots``.""" -class FijiDimensionMode(Enum): - """ - How to map OpenHCS dimensions to ImageJ hyperstack dimensions. - - ImageJ hyperstacks have 3 dimensions: Channels (C), Slices (Z), Frames (T). - Each axis role (tile, colour, stack, time, partition) maps to one of these. - - - WINDOW: Create separate windows for each value (like Napari LAYER mode) - - CHANNEL: Map to ImageJ Channel dimension (C) - - SLICE: Map to ImageJ Slice dimension (Z) - - FRAME: Map to ImageJ Frame dimension (T) - """ - WINDOW = "window" # Separate windows (like Napari LAYER mode) - CHANNEL = "channel" # ImageJ Channel dimension (C) - SLICE = "slice" # ImageJ Slice dimension (Z) - FRAME = "frame" # ImageJ Frame dimension (T) +def _fiji_slot_field(role: type[AxisRole], noun: str): + return role_slot_field( + FijiSlots, + FijiDimensionMode, + role, + f"ImageJ dimension used for {noun}-axis values, or WINDOW for separate windows.", + ) @dataclass(frozen=True) -class FijiDisplayConfig( - AxisRoleModeDisplayConfig, - ViewerDisplayConfigABC, - AnnotatedDataclassValidationMixin, -): +class FijiDisplayConfig(AxisRoleModeDisplayConfig, FijiDisplaySettings): """Map streamed axes and intensity data onto Fiji hyperstacks.""" - lut: NonBlankString = field( - default="Grays", - metadata={ - "description": ( - "Name of a lookup table available to the installed Fiji/ImageJ " - "runtime, including plugin-provided LUTs." - ) - }, - ) - auto_contrast: bool = field( - default=True, - metadata={ - "description": ( - "Automatically set Fiji display limits from the streamed image data." - ) - }, - ) - partition_mode: FijiDimensionMode = field( - default=FijiDimensionMode.FRAME, - metadata={ - "axis_role": PartitionAxis, - "description": ( - "ImageJ dimension used for parallel-axis values, or WINDOW for separate windows." - ), - }, - ) - tile_mode: FijiDimensionMode = field( - default=FijiDimensionMode.FRAME, - metadata={ - "axis_role": TileAxis, - "description": ( - "ImageJ dimension used for tile-axis values, or WINDOW for separate windows." - ), - }, - ) - colour_mode: FijiDimensionMode = field( - default=FijiDimensionMode.CHANNEL, - metadata={ - "axis_role": ColourAxis, - "description": ( - "ImageJ dimension used for colour-axis values, or WINDOW for separate windows." - ), - }, - ) - stack_mode: FijiDimensionMode = field( - default=FijiDimensionMode.SLICE, - metadata={ - "axis_role": StackAxis, - "description": ( - "ImageJ dimension used for stack-axis values, or WINDOW for separate windows." - ), - }, - ) - time_mode: FijiDimensionMode = field( - default=FijiDimensionMode.FRAME, - metadata={ - "axis_role": TimeAxis, - "description": ( - "ImageJ dimension used for time-axis values, or WINDOW for separate windows." - ), - }, - ) - - def display_payload_extra(self) -> dict[str, str | bool]: - """Project Fiji-specific display settings onto the wire payload.""" - - from polystore.fiji_stream import FijiDisplayWireField - - return { - FijiDisplayWireField.LUT.value: self.lut, - FijiDisplayWireField.AUTO_CONTRAST.value: self.auto_contrast, - } - - @classmethod - def from_display_payload( - cls, - payload: Mapping[str, ViewerWireValue], - ) -> "FijiDisplayConfig": - """Rehydrate the typed config at the Fiji viewer wire boundary.""" - - from polystore.fiji_stream import FijiDisplayWireField - - role_modes = cls.role_modes_from_wire( - payload[ViewerDisplayConfigWireField.COMPONENT_MODES.value], - FijiDimensionMode, - "Fiji", - ) - lut = str(payload[FijiDisplayWireField.LUT.value]).strip() - if not lut: - raise ValueError("Fiji LUT name must not be blank.") - auto_contrast = payload[FijiDisplayWireField.AUTO_CONTRAST.value] - if not isinstance(auto_contrast, bool): - raise TypeError( - "Fiji auto_contrast must be bool, " - f"got {type(auto_contrast).__name__}." - ) - return cls(lut=lut, auto_contrast=auto_contrast, **role_modes) + partition_mode: FijiDimensionMode = _fiji_slot_field(PartitionAxis, "parallel") + tile_mode: FijiDimensionMode = _fiji_slot_field(TileAxis, "tile") + colour_mode: FijiDimensionMode = _fiji_slot_field(ColourAxis, "colour") + stack_mode: FijiDimensionMode = _fiji_slot_field(StackAxis, "stack") + time_mode: FijiDimensionMode = _fiji_slot_field(TimeAxis, "time") # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by FijiStreamingConfig, so hide it from UI -FijiDisplayConfig = global_pipeline_config(ui_hidden=True)(FijiDisplayConfig) +FijiDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_as_none=False)(FijiDisplayConfig) @abbreviation("wfc") @@ -921,8 +712,9 @@ class StreamingConfig(StreamingDefaults, ABC, metaclass=StreamingConfigMeta): type-specific attribute names. """ - __registry__: ClassVar[dict[ViewerType, type["StreamingConfig"]]] - __registry_key__ = "viewer_type_declaration" + __registry__: ClassVar[dict[type[ViewerFamily], type["StreamingConfig"]]] + __registry_key__ = "viewer_family" + viewer_family: ClassVar[type[ViewerFamily]] show_preview_without_well_filter: ClassVar[bool] = True @property @@ -931,35 +723,10 @@ def port(self) -> TcpPort: """Port for streaming communication. Each streamer type has its own default.""" pass - @property - @abstractmethod - def backend(self) -> Backend: - """Backend enum for this streaming type.""" - pass - @property - @abstractmethod - def viewer_type(self) -> ViewerType: - """Nominal viewer identity for queue tracking and lifecycle behavior.""" - pass - @property - @abstractmethod - def streaming_config_key(self) -> str: - """ObjectState/registry field key for this streaming config.""" - pass - @property - @abstractmethod - def step_plan_output_key(self) -> str: - """Key to use in step_plan for this config's output paths.""" - pass - @property - @abstractmethod - def viewer_title(self) -> str: - """Title shown by this streaming viewer.""" - pass @classmethod @abstractmethod @@ -991,15 +758,18 @@ def create_visualizer( def supported_config_keys(cls) -> tuple[str, ...]: """Registered ObjectState field keys for streaming configs.""" return tuple( - viewer_type.config_key for viewer_type in cls.supported_viewer_types() + ViewerFamily.named(viewer_type).config_key + for viewer_type in cls.supported_viewer_types() ) @classmethod def supported_viewer_types(cls) -> tuple[ViewerType, ...]: - """Return registered viewer identities from their enum owner.""" + """Boundary names of the viewers that have a streaming config.""" return tuple( - viewer_type for viewer_type in ViewerType if viewer_type in cls.__registry__ + viewer_type + for viewer_type in ViewerType + if ViewerFamily.named(viewer_type) in cls.__registry__ ) @classmethod @@ -1007,19 +777,19 @@ def config_type_for_viewer( cls, viewer_type: ViewerType, ) -> type["StreamingConfig"]: - """Return the registered config declaration for one viewer identity.""" + """Return the registered config declaration for one viewer.""" - return cls.__registry__[viewer_type] + return cls.__registry__[ViewerFamily.named(viewer_type)] @classmethod def config_type_for_key(cls, config_key: str) -> type["StreamingConfig"]: """Return the registered streaming config type for an ObjectState field key.""" - return cls.config_type_for_viewer(ViewerType.from_config_key(config_key)) + return cls.__registry__[ViewerFamily.for_config_key(config_key)] @classmethod def display_name_for_config_key(cls, config_key: str) -> str: """Return viewer display text for an ObjectState streaming config field key.""" - return cls.config_type_for_key(config_key)().display_name + return ViewerFamily.for_config_key(config_key).display_name from openhcs.core.streaming_config_factory import ( @@ -1038,7 +808,7 @@ class NapariStreamingConfig( ): """Per-step Napari transport, scope filter, lifetime, and display policy.""" - viewer_type_declaration: ClassVar[ViewerType] = ViewerType.NAPARI + viewer_family = NapariViewer port: TcpPort = 5555 """Napari viewer transport port; choose a free local port when streaming is enabled.""" @@ -1070,7 +840,7 @@ class FijiStreamingConfig( ): """Per-step Fiji transport, scope filter, lifetime, and display policy.""" - viewer_type_declaration: ClassVar[ViewerType] = ViewerType.FIJI + viewer_family = FijiViewer port: TcpPort = 5565 """Fiji viewer transport port; choose a free local port when streaming is enabled.""" diff --git a/openhcs/core/context/processing_context.py b/openhcs/core/context/processing_context.py index 603c881170..de28a77ee0 100644 --- a/openhcs/core/context/processing_context.py +++ b/openhcs/core/context/processing_context.py @@ -34,7 +34,7 @@ WorkspaceSourceProjections, ) from openhcs.core.source_matching import SourceImageSetIdentityPolicy -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ViewerFamily from openhcs.core.axis_filter import StepAxisFilterMap from openhcs.core.steps.function_output_identity import FunctionOutputIdentityCache from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG @@ -50,13 +50,13 @@ class RequiredVisualizer: config: StreamingConfig @property - def key(self) -> tuple[ViewerType, int]: - return (self.config.viewer_type, self.config.port) + def key(self) -> tuple[type[ViewerFamily], int]: + return (self.config.viewer_family, self.config.port) @property def launch_message(self) -> str: return ( - f"Launching {self.config.viewer_type.display_name} viewer " + f"Launching {self.config.viewer_family.display_name} viewer " f"on port {self.config.port}" ) diff --git a/openhcs/core/orchestrator/orchestrator.py b/openhcs/core/orchestrator/orchestrator.py index 947b40fba1..2c84706c1d 100644 --- a/openhcs/core/orchestrator/orchestrator.py +++ b/openhcs/core/orchestrator/orchestrator.py @@ -276,7 +276,7 @@ def get_or_create_visualizer(self, config, vis_config=None): # Streaming configs should be managed by the centralized ViewerStateManager if isinstance(config, StreamingConfig): - key = (config.viewer_type, config.port) + key = (config.viewer_family, config.port) persistence_mode = ViewerPersistenceMode.from_flag(config.persistent) viewer = StreamingViewerLifecycle.get_or_create_visualizer( @@ -301,7 +301,7 @@ def get_or_create_visualizer(self, config, vis_config=None): vis.start_viewer() # Store for compatibility - backend_name = config.backend.name + backend_name = config.viewer_family.backend.name self._visualizers[(backend_name,)] = vis return vis diff --git a/openhcs/core/pipeline/compiler.py b/openhcs/core/pipeline/compiler.py index 81d3f33f1d..2c4b0d9ee3 100644 --- a/openhcs/core/pipeline/compiler.py +++ b/openhcs/core/pipeline/compiler.py @@ -451,7 +451,7 @@ def _collect_streaming_config( for binding in invocation.runtime_parameter_bindings ) ) - config_key = config_obj.streaming_config_key + config_key = config_obj.viewer_family.config_key if session.is_zmq_execution: logger.info( "Streaming resolution: step=%s field=%s defaults_enabled=%r per_stream_enabled=%r effective_enabled=%r", @@ -464,7 +464,7 @@ def _collect_streaming_config( if enabled is not True and not runtime_required: continue - backend_name = config_obj.backend.name + backend_name = config_obj.viewer_family.backend.name required_visualizer = RequiredVisualizer( backend_name=backend_name, config=replace(config_obj, enabled=enabled is True), diff --git a/openhcs/core/steps/function_artifact_materialization.py b/openhcs/core/steps/function_artifact_materialization.py index 112b130c0f..6a2ed7703c 100644 --- a/openhcs/core/steps/function_artifact_materialization.py +++ b/openhcs/core/steps/function_artifact_materialization.py @@ -223,7 +223,7 @@ def streaming_viewer_surfaces( ) return ( { - config.backend.value: config.streaming_viewer_surface(context) + config.viewer_family.backend.value: config.streaming_viewer_surface(context) for config in plan.streaming_configs.values() if step_axis_allows_config( context.step_axis_filters, diff --git a/openhcs/core/steps/function_outputs.py b/openhcs/core/steps/function_outputs.py index b482d2b046..beafb369dd 100644 --- a/openhcs/core/steps/function_outputs.py +++ b/openhcs/core/steps/function_outputs.py @@ -635,7 +635,7 @@ def stream_outputs( context.filemanager.save_batch( stream_batch.data_list, stream_batch.paths, - config_instance.backend.value, + config_instance.viewer_family.backend.value, **stream_backend_kwargs.to_kwargs(), ) diff --git a/openhcs/core/steps/stream_component_semantics.py b/openhcs/core/steps/stream_component_semantics.py index 7a4b67c845..ba06880463 100644 --- a/openhcs/core/steps/stream_component_semantics.py +++ b/openhcs/core/steps/stream_component_semantics.py @@ -28,7 +28,7 @@ ViewerWireValue, ) -from openhcs.core.config import FijiDimensionMode, NapariDimensionMode +from openhcs.runtime.viewer_display import DeclaredAxes, ViewerSlotFamily from openhcs.core.context.processing_context import ProcessingContext from openhcs.core.runtime_image_values import ImagePayloadMetadata from openhcs.core.runtime_plane_projection import RuntimePlaneAxisValueProjection @@ -56,7 +56,7 @@ ViewerComponentMetadataNormalizer, ViewerComponentValueParser, ViewerComponentAxisSemantics, - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerComponentValueDomainPayload, ViewerObjectDisplayConfigInput, @@ -87,10 +87,7 @@ def payload_items_for_display( component_values = item_fields.get( ViewerWireField.PLANE_COMPONENT_VALUES.value, {} ) - scalar_modes = ( - NapariDimensionMode.LAYER.value, - FijiDimensionMode.WINDOW.value, - ) + scalar_modes = ViewerSlotFamily.separating_wire_values() if not any( mode in scalar_modes for component, mode in component_modes.items() @@ -368,7 +365,9 @@ def project( value = self.component_value(metadata, component) if value is not None: projected[component] = value - return ViewerComponentMetadataNormalizer().normalize(projected) + return ViewerComponentMetadataNormalizer( + DeclaredAxes.of(AxisFamily.active().axes) + ).normalize(projected) def component_value( self, @@ -887,7 +886,7 @@ def metadata_items(self) -> StreamComponentDomainMetadataItems: @property def component_axis_semantics(self) -> ViewerComponentAxisSemantics: - return ViewerComponentAxisSemanticsAuthority.from_display_config( + return ViewerComponentAxisSemanticsFactory.from_display_config( display_config=self.display_input, value_domain=ViewerComponentValueDomainPayload.from_component_metadata( component_layout=self.layout, diff --git a/openhcs/core/streaming_config_declarations.py b/openhcs/core/streaming_config_declarations.py index 53e4a304ac..79a7836c0b 100644 --- a/openhcs/core/streaming_config_declarations.py +++ b/openhcs/core/streaming_config_declarations.py @@ -1,12 +1,26 @@ -"""Nominal declarations for OpenHCS streaming viewer families.""" +"""One nominal class per streaming viewer. + +A :class:`ViewerFamily` leaf declares everything that differs between +viewers: its wire name, storage backend, display settings (and through them +its slot family), and its lifecycle implementation. Config keys, titles and +step-plan keys are derived from the wire name. ``ViewerType`` is the boundary +view of the registry for wire and form fields. +""" from __future__ import annotations from abc import ABC, abstractmethod from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar + +from metaclass_registry import AutoRegisterMeta from openhcs.constants.constants import Backend +from openhcs.runtime.viewer_display import ( + FijiDisplaySettings, + NapariDisplaySettings, + ViewerDisplaySettings, +) if TYPE_CHECKING: from polystore.filemanager import FileManager @@ -15,140 +29,115 @@ from openhcs.core.streaming_config_factory import StreamingViewerRuntimeConfig -class ViewerDeclarationABC(ABC): - """Viewer-specific behavior owned by one :class:`ViewerType` member.""" +class ViewerFamily(ABC, metaclass=AutoRegisterMeta): + """One streaming viewer; leaves register by their wire name.""" - @property - @abstractmethod - def backend(self) -> Backend: - """Return the storage backend used by this viewer family.""" + __registry_key__ = "wire_value" + __skip_if_no_key__ = True + __registry__: ClassVar[dict[str, type["ViewerFamily"]]] + + wire_value: ClassVar[str | None] = None + backend: ClassVar[Backend] + display_settings: ClassVar[type[ViewerDisplaySettings]] + + display_name: ClassVar[str] + config_key: ClassVar[str] + step_plan_output_key: ClassVar[str] + title: ClassVar[str] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if cls.__dict__.get("wire_value") is None: + return + cls.display_name = cls.__name__.removesuffix("Viewer") + cls.config_key = f"{cls.wire_value}_streaming_config" + cls.step_plan_output_key = f"{cls.wire_value}_streaming_paths" + cls.title = f"OpenHCS {cls.display_name} Visualization" + + def __new__(cls, *args: object, **kwargs: object): + raise TypeError(f"{cls.__qualname__} is a viewer declaration, not a value type.") + @classmethod @abstractmethod - def visualizer_type(self) -> type["ExecutionVisualizerABC"]: - """Load and return this viewer's concrete lifecycle implementation.""" + def visualizer_type(cls) -> type["ExecutionVisualizerABC"]: + """Load this viewer's lifecycle implementation.""" + @classmethod def create_visualizer( - self, + cls, *, filemanager: "FileManager", runtime_config: "StreamingViewerRuntimeConfig", ) -> "ExecutionVisualizerABC": - """Construct this viewer's lifecycle implementation.""" - - return self.visualizer_type()( + return cls.visualizer_type()( filemanager=filemanager, runtime_config=runtime_config, ) - -class NapariViewerDeclaration(ViewerDeclarationABC): - """Napari streaming and lifecycle integration.""" - - @property - def backend(self) -> Backend: - return Backend.NAPARI_STREAM - - def visualizer_type(self) -> type["ExecutionVisualizerABC"]: - from openhcs.runtime.napari_stream_visualizer import NapariStreamVisualizer - - return NapariStreamVisualizer - - -class FijiViewerDeclaration(ViewerDeclarationABC): - """Fiji streaming and lifecycle integration.""" - - @property - def backend(self) -> Backend: - return Backend.FIJI_STREAM - - def visualizer_type(self) -> type["ExecutionVisualizerABC"]: - from openhcs.runtime.fiji_stream_visualizer import FijiStreamVisualizer - - return FijiStreamVisualizer - - -class ViewerType(Enum): - """Streaming viewer identities carrying their nominal leaf declarations.""" - - FIJI = ("fiji", FijiViewerDeclaration) - NAPARI = ("napari", NapariViewerDeclaration) - - def __new__( - cls, - wire_value: str, - declaration_type: type[ViewerDeclarationABC], - ) -> "ViewerType": - member = object.__new__(cls) - member._value_ = wire_value - member._declaration_type = declaration_type - return member - - @property - def declaration(self) -> ViewerDeclarationABC: - """Return the nominal implementation owned by this viewer member.""" - - return self._declaration_type() - - @property - def wire_value(self) -> str: - """Return the stable external identifier for transport boundaries.""" - - return self.value + @classmethod + def families(cls) -> tuple[type["ViewerFamily"], ...]: + return tuple(ViewerFamily.__registry__.values()) @classmethod - def from_wire_value(cls, value: str) -> "ViewerType": - """Resolve one external viewer identifier at the transport boundary.""" + def named(cls, wire_value: object) -> type["ViewerFamily"]: + """Decode one viewer wire name (a ``ViewerType`` member or its value).""" - return cls(value) + key = wire_value.value if isinstance(wire_value, Enum) else wire_value + if key not in ViewerFamily.__registry__: + raise ValueError( + f"Unknown viewer {wire_value!r}; viewers are " + f"{sorted(ViewerFamily.__registry__)!r}." + ) + return ViewerFamily.__registry__[key] - @property - def config_key(self) -> str: - """Return the ObjectState field projected from this viewer identity.""" + @classmethod + def for_config_key(cls, config_key: str) -> type["ViewerFamily"]: + """Decode an ObjectState streaming-config field key.""" - return f"{self.wire_value}_streaming_config" + for family in cls.families(): + if family.config_key == config_key: + return family + raise ValueError(f"Unknown viewer streaming config key: {config_key!r}") @classmethod - def from_config_key(cls, config_key: str) -> "ViewerType": - """Resolve an ObjectState field key at its configuration boundary.""" + def viewer_type(cls) -> "ViewerType": + """This viewer's boundary enum member.""" - matches = tuple(member for member in cls if member.config_key == config_key) - if len(matches) != 1: - raise ValueError(f"Unknown viewer streaming config key: {config_key!r}") - return matches[0] + return ViewerType(cls.wire_value) - @property - def display_name(self) -> str: - """Return the human-facing viewer name.""" - return self.name.title() +class NapariViewer(ViewerFamily): + wire_value = "napari" + backend = Backend.NAPARI_STREAM + display_settings = NapariDisplaySettings - @property - def step_plan_output_key(self) -> str: - """Return the compiled step-plan output key for this viewer.""" + @classmethod + def visualizer_type(cls) -> type["ExecutionVisualizerABC"]: + from openhcs.runtime.napari_stream_visualizer import NapariStreamVisualizer - return f"{self.wire_value}_streaming_paths" + return NapariStreamVisualizer - @property - def title(self) -> str: - """Return the managed viewer window title.""" - return f"OpenHCS {self.display_name} Visualization" +class FijiViewer(ViewerFamily): + wire_value = "fiji" + backend = Backend.FIJI_STREAM + display_settings = FijiDisplaySettings - @property - def backend(self) -> Backend: - """Return the backend declared by this viewer's nominal leaf.""" + @classmethod + def visualizer_type(cls) -> type["ExecutionVisualizerABC"]: + from openhcs.runtime.fiji_stream_visualizer import FijiStreamVisualizer - return self.declaration.backend + return FijiStreamVisualizer - def create_visualizer( - self, - *, - filemanager: "FileManager", - runtime_config: "StreamingViewerRuntimeConfig", - ) -> "ExecutionVisualizerABC": - """Construct the lifecycle implementation owned by this member.""" - return self.declaration.create_visualizer( - filemanager=filemanager, - runtime_config=runtime_config, - ) +ViewerType = Enum( + "ViewerType", + [ + (family.display_name.upper(), family.wire_value) + for family in sorted(ViewerFamily.families(), key=lambda family: family.wire_value) + ], + module=__name__, +) +"""Boundary view of the viewer registry, for wire fields and form choices.""" +ViewerType.__doc__ = "Streaming viewer names at wire and form boundaries." +ViewerType.family = property(lambda member: ViewerFamily.named(member.value)) diff --git a/openhcs/core/streaming_config_factory.py b/openhcs/core/streaming_config_factory.py index 5c5d6736e4..3d3ed6c2a2 100644 --- a/openhcs/core/streaming_config_factory.py +++ b/openhcs/core/streaming_config_factory.py @@ -24,8 +24,7 @@ from zmqruntime.config import TransportMode, ZMQConfig from zmqruntime.viewer_protocol import ViewerTransportEndpoint -from openhcs.constants.constants import Backend -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ViewerFamily from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG if TYPE_CHECKING: @@ -148,7 +147,7 @@ class StreamingViewerRuntimeConfig: transport_endpoint: ViewerTransportEndpoint persistent: bool - viewer_type: ViewerType + viewer_family: type[ViewerFamily] transport_config: ZMQConfig = OPENHCS_ZMQ_CONFIG display_enabled: bool = True scope_accent_color: str | None = None @@ -160,7 +159,7 @@ class StreamingViewerRuntimeConfig: class StreamingConfigBehaviorMixin: """Shared implementation for concrete viewer streaming configs.""" - viewer_type_declaration: ClassVar[ViewerType] + viewer_family: ClassVar[type[ViewerFamily]] transport_mode: TransportMode def with_display_config(self, display: ViewerDisplayConfigABC | None): @@ -175,36 +174,12 @@ def with_display_config(self, display: ViewerDisplayConfigABC | None): def port_from_config(cls, config) -> int | None: streaming_config = DataclassFieldAccess.raw_value( config, - cls.viewer_type_declaration.config_key, + cls.viewer_family.config_key, ) if streaming_config is None: return None return streaming_config.port - @property - def backend(self) -> Backend: - return self.viewer_type.backend - - @property - def viewer_type(self) -> ViewerType: - return type(self).viewer_type_declaration - - @property - def streaming_config_key(self) -> str: - return self.viewer_type.config_key - - @property - def display_name(self) -> str: - return self.viewer_type.display_name - - @property - def step_plan_output_key(self) -> str: - return self.viewer_type.step_plan_output_key - - @property - def viewer_title(self) -> str: - return self.viewer_type.title - def viewer_runtime_config( self, transport_config: ZMQConfig = OPENHCS_ZMQ_CONFIG, @@ -218,7 +193,7 @@ def viewer_runtime_config( transport_config=transport_config, persistent=self.persistent, display_enabled=self.enabled, - viewer_type=self.viewer_type, + viewer_family=self.viewer_family, scope_accent_color=self.scope_accent_color, process_launch=self.viewer_process_launch_config(), ) @@ -257,7 +232,7 @@ def create_visualizer( visualizer_config=None, transport_config: ZMQConfig = OPENHCS_ZMQ_CONFIG, ): - return self.viewer_type.create_visualizer( + return self.viewer_family.create_visualizer( filemanager=filemanager, runtime_config=self.viewer_runtime_config(transport_config), ) diff --git a/openhcs/core/viewer_streaming_service.py b/openhcs/core/viewer_streaming_service.py index d74c68f9b1..d995841b6e 100644 --- a/openhcs/core/viewer_streaming_service.py +++ b/openhcs/core/viewer_streaming_service.py @@ -208,19 +208,19 @@ def get_or_create_visualizer( from openhcs.runtime.viewer_protocol import ( ViewerGraphicalSessionUnavailableError, ViewerLaunchContext, - ViewerProcessLaunchAdmission, + ViewerProcessLaunchReuseCheck, ) resolved_launch_context = ( launch_context or ViewerLaunchContext.inherited_graphical_session() ) registry = GlobalQueueTrackerRegistry() - registry.get_or_create_tracker(config.port, config.viewer_type.wire_value) + registry.get_or_create_tracker(config.port, config.viewer_family.wire_value) manager = ViewerStateManager.get_instance() if fresh: manager.release_viewer( - config.viewer_type.wire_value, + config.viewer_family.wire_value, config.port, stop=True, force=True, @@ -242,12 +242,12 @@ def create_viewer() -> ManagedViewerLifecycleMixin: try: viewer, _created = get_or_create_viewer( - viewer_type=config.viewer_type.wire_value, + viewer_type=config.viewer_family.wire_value, port=config.port, factory=create_viewer, wait_for_ready=True, ready_timeout=ready_timeout, - reuse_admission=ViewerProcessLaunchAdmission( + reuse_admission=ViewerProcessLaunchReuseCheck( config.viewer_process_launch_config() ), ) @@ -580,14 +580,14 @@ def _wait_for_viewer_ready( # Update queued images for UI display via manager. The QueueTracker # will later update counts precisely as images are sent/acked. manager.update_queued_images( - config.viewer_type.wire_value, + config.viewer_family.wire_value, viewer.port, num_items, ) if not is_already_running: logger.info( - f"Waiting for {config.display_name} viewer on port {viewer.port} to become ready" + f"Waiting for {config.viewer_family.display_name} viewer on port {viewer.port} to become ready" ) if not viewer.runtime_endpoint.wait_ready( @@ -596,15 +596,15 @@ def _wait_for_viewer_ready( ): # Clear queued count for UI if startup failed manager.update_queued_images( - config.viewer_type.wire_value, + config.viewer_family.wire_value, viewer.port, 0, ) raise RuntimeError( - f"{config.display_name} viewer on port {viewer.port} failed to become ready" + f"{config.viewer_family.display_name} viewer on port {viewer.port} failed to become ready" ) - logger.info(f"{config.display_name} viewer on port {viewer.port} is ready") + logger.info(f"{config.viewer_family.display_name} viewer on port {viewer.port} is ready") @staticmethod def _require_viewer_settled(request: ViewerStreamingContext) -> None: @@ -614,7 +614,7 @@ def _require_viewer_settled(request: ViewerStreamingContext) -> None: return raise RuntimeError( "Failed to settle streamed updates for " - f"{request.config.display_name} viewer on port {request.viewer.port}." + f"{request.config.viewer_family.display_name} viewer on port {request.viewer.port}." ) def stream_images_async( @@ -625,7 +625,7 @@ def stream_images_async( Uses chunked streaming to prevent file descriptor exhaustion. """ - display_name = request.config.display_name + display_name = request.config.viewer_family.display_name def _worker(): try: @@ -637,7 +637,7 @@ def _worker(): spawn_thread_with_context( _worker, - name=f"stream_images_{request.config.viewer_type.wire_value}", + name=f"stream_images_{request.config.viewer_family.wire_value}", ) logger.info( f"Started streaming {len(request.filenames)} images to {display_name}" @@ -648,8 +648,8 @@ def stream_images( request: ImageStreamingRequest, ) -> ViewerStreamingResult: """Load and stream images to a viewer before returning.""" - backend_enum = request.config.backend - display_name = request.config.display_name + backend_enum = request.config.viewer_family.backend + display_name = request.config.viewer_family.display_name messages: list[str] = [] self._wait_for_viewer_ready( @@ -762,7 +762,7 @@ def stream_images( messages.append(message) request.status_callback(message) return ViewerStreamingResult( - viewer_type=request.config.viewer_type, + viewer_type=request.config.viewer_family.viewer_type(), port=request.viewer.port, display_name=display_name, payload_kind="image", @@ -777,7 +777,7 @@ def stream_rois_async( request: RoiStreamingRequest, ) -> None: """Load and stream ROI files to viewer in background thread.""" - display_name = request.config.display_name + display_name = request.config.viewer_family.display_name def _worker(): try: @@ -789,7 +789,7 @@ def _worker(): spawn_thread_with_context( _worker, - name=f"stream_rois_{request.config.viewer_type.wire_value}", + name=f"stream_rois_{request.config.viewer_family.wire_value}", ) def stream_rois( @@ -799,14 +799,14 @@ def stream_rois( """Load and stream ROI files to a viewer before returning.""" from polystore.roi import load_rois_from_zip - backend_enum = request.config.backend - display_name = request.config.display_name + backend_enum = request.config.viewer_family.backend + display_name = request.config.viewer_family.display_name messages: list[str] = [] total = len(request.roi_filenames) if total == 0: return ViewerStreamingResult( - viewer_type=request.config.viewer_type, + viewer_type=request.config.viewer_family.viewer_type(), port=request.viewer.port, display_name=display_name, payload_kind="rois", @@ -861,7 +861,7 @@ def stream_rois( messages.append(message) request.status_callback(message) return ViewerStreamingResult( - viewer_type=request.config.viewer_type, + viewer_type=request.config.viewer_family.viewer_type(), port=request.viewer.port, display_name=display_name, payload_kind="rois", @@ -976,7 +976,7 @@ def stream_rois( messages.append(message) request.status_callback(message) return ViewerStreamingResult( - viewer_type=request.config.viewer_type, + viewer_type=request.config.viewer_family.viewer_type(), port=request.viewer.port, display_name=display_name, payload_kind="rois", diff --git a/openhcs/demo/basic_pipeline.py b/openhcs/demo/basic_pipeline.py index 96bc35f2a9..c7afd2c03f 100644 --- a/openhcs/demo/basic_pipeline.py +++ b/openhcs/demo/basic_pipeline.py @@ -14,14 +14,9 @@ LazyStepMaterializationConfig, LazyStepWellFilterConfig, LazyProcessingConfig, - NapariVariableSizeHandling, PipelineConfig, ) from openhcs.core.steps.function_step import FunctionStep -from openhcs.processing.backends.analysis.cell_counting_cpu import ( - DetectionMethod, - count_cells_single_channel, -) from openhcs.processing.backends.assemblers.assemble_stack_cpu import assemble_stack_cpu from openhcs.processing.backends.pos_gen.ashlar_main_cpu import ( ashlar_compute_tile_positions_cpu, diff --git a/openhcs/demo/synthetic_plate_pipeline.py b/openhcs/demo/synthetic_plate_pipeline.py index e7ee69275c..7c7689728f 100644 --- a/openhcs/demo/synthetic_plate_pipeline.py +++ b/openhcs/demo/synthetic_plate_pipeline.py @@ -11,9 +11,9 @@ from openhcs.core.config import ( LazyNapariStreamingConfig, LazyProcessingConfig, - NapariVariableSizeHandling, PipelineConfig, ) +from openhcs.runtime.viewer_display import NapariVariableSizeHandling from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.cell_counting_cpu import ( DetectionMethod, diff --git a/openhcs/domains/microscopy/axes.py b/openhcs/domains/microscopy/axes.py index 2a4e1c1221..07b7fff8d7 100644 --- a/openhcs/domains/microscopy/axes.py +++ b/openhcs/domains/microscopy/axes.py @@ -41,12 +41,14 @@ class Site(Axis, TileAxis, DefaultVariable, OrdinalValued): class Channel(Axis, ColourAxis, DefaultGroupBy, OrdinalValued): name = "channel" + label = "Ch" filename_prefix = "w" metadata_aliases = ("channel", "channelnumber") metadata_fallback = ImageSetOrdinal() class ZIndex(Axis, StackAxis, OrdinalValued): name = "z_index" + label = "Z" filename_prefix = "z" filename_padding = 3 metadata_aliases = ("zindex", "z", "zplane", "zslice", "plane", "slice") @@ -54,6 +56,7 @@ class ZIndex(Axis, StackAxis, OrdinalValued): class Timepoint(Axis, TimeAxis, OrdinalValued): name = "timepoint" + label = "T" filename_prefix = "t" filename_padding = 3 metadata_aliases = ("timepoint", "time", "framenumber", "frame") diff --git a/openhcs/pyqt_gui/services/ui_bridge_live_overview.py b/openhcs/pyqt_gui/services/ui_bridge_live_overview.py index 4c20e10081..0e7b583550 100644 --- a/openhcs/pyqt_gui/services/ui_bridge_live_overview.py +++ b/openhcs/pyqt_gui/services/ui_bridge_live_overview.py @@ -23,7 +23,7 @@ from openhcs.agent.ui_bridge_identities import ( UiLiveOverviewStateSurfaceIdentityDeclaration, ) -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ViewerFamily from objectstate.object_state import ObjectStateRegistry from openhcs.pyqt_gui.services.ui_bridge_contracts import ( UiBridgeSnapshotProviderABC, @@ -108,7 +108,7 @@ def _overview_item(viewer: ViewerInstance) -> UiLiveOverviewItem: detail_parts = (*detail_parts, f"error={viewer.error_message}") return UiLiveOverviewItem( label=( - f"{ViewerType.from_wire_value(viewer.viewer_type).display_name} viewer" + f"{ViewerFamily.named(viewer.viewer_type).display_name} viewer" ), status=viewer.state.name.lower(), detail=" ".join(detail_parts), diff --git a/openhcs/pyqt_gui/widgets/image_browser.py b/openhcs/pyqt_gui/widgets/image_browser.py index ae7976e46b..6d16ca6484 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -1292,14 +1292,14 @@ def _stream_images_to_viewer(self, filenames: list, config_key: str): config=config, status_callback=self._status_update_signal.emit, error_callback=lambda e: self._show_streaming_error( - config.display_name, + config.viewer_family.display_name, e, ), filenames=tuple(filenames), read_backend=read_backend, ) ) - logger.info(f"Streaming {len(filenames)} images to {config.display_name}...") + logger.info(f"Streaming {len(filenames)} images to {config.viewer_family.display_name}...") def _show_streaming_error(self, viewer_name: str, error_msg: str): """Show streaming error in UI thread.""" @@ -1326,14 +1326,14 @@ def _stream_rois_to_viewer(self, roi_filenames: list, config_key: str): config=config, status_callback=self._status_update_signal.emit, error_callback=lambda e: self._show_streaming_error( - config.display_name, + config.viewer_family.display_name, e, ), roi_filenames=tuple(roi_filenames), ) ) logger.info( - f"Streaming {len(roi_filenames)} ROI files to {config.display_name}..." + f"Streaming {len(roi_filenames)} ROI files to {config.viewer_family.display_name}..." ) def cleanup(self): diff --git a/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py b/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py index e7046d8bb5..852ce265c3 100644 --- a/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py +++ b/openhcs/pyqt_gui/widgets/shared/server_browser/live_tree_sync.py @@ -74,10 +74,10 @@ def _sync_launching_viewers(self, scanned_ports: set[int]) -> set[int]: existing_item = self._find_item_by_port(port) launching_info = LaunchingViewerServerInfo( endpoint_port=port, - viewer_type=ViewerType.from_wire_value(viewer.viewer_type), + viewer_type=ViewerType(viewer.viewer_type), queued_images=viewer.queued_images, ) - viewer_type = launching_info.viewer_type.display_name + viewer_type = launching_info.viewer_type.family.display_name queued = launching_info.queued_images info_text = f"{queued} images queued" if queued > 0 else "Starting..." diff --git a/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py b/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py index 56e802a0e9..a8aeb7c0fd 100644 --- a/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py +++ b/openhcs/pyqt_gui/widgets/shared/services/pipeline_editor_workflows.py @@ -479,7 +479,7 @@ def show_snapshot(self, notification) -> None: def handle_artifact_open_request(self, request) -> None: self.editor.status_message.emit( "Debug artifact viewer request queued for " - f"{request.viewer_type.display_name}: {request.artifact_ref.name}" + f"{request.viewer_type.family.display_name}: {request.artifact_ref.name}" ) def handle_artifact_export_request(self, request) -> None: diff --git a/openhcs/pyqt_gui/windows/debug_inspector_window.py b/openhcs/pyqt_gui/windows/debug_inspector_window.py index 1501b23c58..31d9bc3a1c 100644 --- a/openhcs/pyqt_gui/windows/debug_inspector_window.py +++ b/openhcs/pyqt_gui/windows/debug_inspector_window.py @@ -227,7 +227,7 @@ def _artifact_actions_widget(self, snapshot: DebugSnapshot) -> QGroupBox | None: ) row.addWidget(export_button) for viewer_type in actions_model.viewer_types: - button = QPushButton(viewer_type.display_name) + button = QPushButton(viewer_type.family.display_name) button.clicked.connect( lambda _=False, ref=artifact_ref, target=viewer_type: ( self.request_open_artifact(ref, target) diff --git a/openhcs/runtime/fiji_stream_visualizer.py b/openhcs/runtime/fiji_stream_visualizer.py index bf1fc21183..02d05aff10 100644 --- a/openhcs/runtime/fiji_stream_visualizer.py +++ b/openhcs/runtime/fiji_stream_visualizer.py @@ -13,7 +13,7 @@ from polystore.filemanager import FileManager -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import FijiViewer from openhcs.core.streaming_config_factory import StreamingViewerRuntimeConfig from openhcs.runtime.viewer_protocol import ( DetachedViewerPythonArguments, @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) FIJI_VIEWER_ENTRYPOINT = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.FIJI, + viewer_family=FijiViewer, module_name="openhcs.runtime.fiji_viewer_server", function_name="fiji_viewer_server_process", extra_imports=("from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG",), @@ -67,58 +67,3 @@ def detached_server_arguments( DetachedViewerPythonExpression.symbol("OPENHCS_ZMQ_CONFIG"), DetachedViewerPythonExpression.literal(self.process_launch.listen_host), ) - - def start_viewer(self, async_mode: bool = False) -> None: - """Start Fiji viewer server process.""" - with self._lock: - port = self.required_port - self.prepare_fresh_viewer_start() - - if self.lifecycle_state.is_active: - logger.warning("Fiji viewer is already running.") - return - - logger.info( - f"🔬 FIJI VISUALIZER: Starting Fiji viewer server on port {port} (persistent={self.persistent})" - ) - - # ALL viewers (persistent and non-persistent) should be detached subprocess - # so they don't block parent process exit. The difference is only whether - # we terminate them during cleanup. - logger.info( - f"🔬 FIJI VISUALIZER: Creating {self.persistence_label} Fiji viewer (detached)" - ) - self.process = self.launch_detached_viewer() - - if self.owned_viewer_process_is_alive(): - self.lifecycle_state.mark_owned_process() - logger.info( - "🔬 FIJI VISUALIZER: Fiji viewer process started " - f"(PID: {self.process_pid_label})" - ) - else: - logger.error("🔬 FIJI VISUALIZER: Failed to start Fiji viewer process") - - def stop_viewer(self) -> None: - """Stop Fiji viewer server (only if not persistent).""" - with self._lock: - if not self.persistent: - logger.info("🔬 FIJI VISUALIZER: Stopping non-persistent Fiji viewer") - - if self.process: - killed = self.terminate_owned_viewer_process() - if killed: - logger.warning("🔬 FIJI VISUALIZER: Force killing Fiji viewer") - - self.lifecycle_state.mark_stopped() - else: - logger.info("🔬 FIJI VISUALIZER: Keeping persistent Fiji viewer alive") - # DON'T mark stopped for persistent viewers. - # The process is still alive and should be reusable - - def is_viewer_running(self) -> bool: - """Check if Fiji viewer process is running.""" - return self.is_running - - def stop(self, timeout: float = 5.0): - self.stop_viewer() diff --git a/openhcs/runtime/fiji_viewer_server.py b/openhcs/runtime/fiji_viewer_server.py index 82f602b53f..5ae570cc1d 100644 --- a/openhcs/runtime/fiji_viewer_server.py +++ b/openhcs/runtime/fiji_viewer_server.py @@ -8,14 +8,12 @@ import logging import threading import time -from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field, replace from zmqruntime.messages import ImageTransferIdentity from typing import ClassVar, TypeAlias import numpy as np -from metaclass_registry import AutoRegisterMeta from polystore.imagej_runtime import FIJI_IMAGEJ_RUNTIME from polystore.streaming import StreamingSharedMemoryAuthority from polystore.streaming.receivers.core import ( @@ -25,12 +23,10 @@ ) from polystore.streaming_constants import StreamingDataType from zmqruntime.config import TransportMode, ZMQConfig -from zmqruntime.streaming import StreamingVisualizerServer from zmqruntime.viewer_protocol import ViewerWireField -from openhcs.core.config import FijiDisplayConfig from openhcs.core.runtime_plane_projection import RuntimePlaneAxis -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import FijiViewer from openhcs.core.streaming_config_factory import ViewerProcessLaunchConfig from openhcs.runtime.fiji_macro_runtime import ( FijiMacroExecutionRequest, @@ -44,24 +40,25 @@ ViewerComponentMetadataPayload, ViewerComponentNameMetadata, ViewerComponentValueDomainPayload, - ViewerDimensionValueAuthority, + ViewerDimensionValues, ViewerDisplayBatchContext, ViewerMappingDisplayConfigInput, ViewerStreamingDataTypeHandler, ViewerStreamingDataTypeHandlerMeta, ) +from openhcs.runtime.viewer_control_actions import ( + ViewerControlAction, + ViewerServerPort, + control_reply, +) +from openhcs.runtime.viewer_display import DeclaredAxes, FijiDisplaySettings, FijiSlots from openhcs.runtime.viewer_protocol import ( FijiPayloadKind, OpenHCSViewerServerABC, - OpenHCSViewerControlMessageType, ViewerBatchContextWireField, ViewerBatchMessageType, ViewerBatchWireField, ViewerComponentValueOrdering, - ViewerControlMessageType, - ViewerControlField, - ViewerControlReplyHeader, - ViewerControlReplyPayload, ViewerControlResponseField, ViewerProtocolStatus, ViewerServerLaunchRequest, @@ -86,7 +83,7 @@ @dataclass(frozen=True, slots=True) -class FijiDisplayItemContext(ViewerDisplayBatchContext[FijiDisplayConfig]): +class FijiDisplayItemContext(ViewerDisplayBatchContext[FijiDisplaySettings]): """Shared Fiji display context for item batches and deferred processing.""" @@ -363,19 +360,20 @@ def path(self) -> str: return str(value) +@dataclass(frozen=True, slots=True) class FijiPayloadLocalPlaneExpander: """Project one ordered payload-local plane axis into scalar Fiji items.""" - @classmethod + declared_axes: DeclaredAxes + def expand_items( - cls, + self, items: Sequence[FijiWireItem], ) -> list[FijiWireItem]: - return [projected for item in items for projected in cls.expand_item(item)] + return [projected for item in items for projected in self.expand_item(item)] - @classmethod - def expand_item(cls, item: FijiWireItem) -> tuple[FijiWireItem, ...]: - domain = cls._plane_component_domain(item) + def expand_item(self, item: FijiWireItem) -> tuple[FijiWireItem, ...]: + domain = self._plane_component_domain(item) if not domain.entries: return (item,) if len(domain.entries) != 1: @@ -383,14 +381,14 @@ def expand_item(cls, item: FijiWireItem) -> tuple[FijiWireItem, ...]: "Fiji payload-local plane projection requires exactly one " "component declaration." ) - cls._require_plane_axis(item) + self._require_plane_axis(item) entry = domain.entries[0] if not entry.values: raise ValueError( "Fiji payload-local plane projection requires at least one " f"coordinate for component {entry.component!r}." ) - cls._require_consistent_scalar_metadata(item, entry.component, entry.values) + self._require_consistent_scalar_metadata(item, entry.component, entry.values) if ViewerWireField.DATA.value not in item.payload: if len(entry.values) != 1: @@ -399,7 +397,7 @@ def expand_item(cls, item: FijiWireItem) -> tuple[FijiWireItem, ...]: "without an image payload." ) return ( - cls._project_item( + self._project_item( item, component=entry.component, value=entry.values[0], @@ -419,7 +417,7 @@ def expand_item(cls, item: FijiWireItem) -> tuple[FijiWireItem, ...]: f"leading image axis: {len(entry.values)} != {data.shape[0]}." ) return tuple( - cls._project_item( + self._project_item( item, component=entry.component, value=value, @@ -428,9 +426,8 @@ def expand_item(cls, item: FijiWireItem) -> tuple[FijiWireItem, ...]: for index, value in enumerate(entry.values) ) - @staticmethod def _plane_component_domain( - item: FijiWireItem, + self, item: FijiWireItem ) -> ViewerComponentValueDomainPayload: raw_domain = item.payload.get(ViewerWireField.PLANE_COMPONENT_VALUES.value) if raw_domain is None: @@ -439,6 +436,7 @@ def _plane_component_domain( raise TypeError("Fiji item plane_component_values field must be a mapping.") return ViewerComponentValueDomainPayload.from_ordered_wire_mapping( raw_domain, + declared_axes=self.declared_axes, context="Fiji image plane component values", ) @@ -456,15 +454,15 @@ def _require_plane_axis(item: FijiWireItem) -> RuntimePlaneAxis: f"Unsupported Fiji payload-local plane axis {raw_axis!r}." ) from error - @staticmethod def _require_consistent_scalar_metadata( + self, item: FijiWireItem, component: str, values: Sequence[ComponentValue], ) -> None: if component not in item.metadata: return - normalized = ViewerComponentMetadataNormalizer().normalize_value( + normalized = ViewerComponentMetadataNormalizer(self.declared_axes).normalize_value( component, item.metadata[component], ) @@ -545,7 +543,7 @@ def data_for(self, key: FijiCoordinateKey) -> np.ndarray: class FijiPlaneGeometry: - """Canonical 2D plane extraction and spatial padding for Fiji stacks.""" + """2D plane extraction and spatial padding for Fiji stacks.""" @staticmethod def extract_2d_plane(np_data: np.ndarray) -> np.ndarray: @@ -774,7 +772,7 @@ def collect( if not components: return cls(name=name, components=components, values=[()]) values = { - ViewerDimensionValueAuthority.value_tuple(item.metadata, components) + ViewerDimensionValues.value_tuple(item.metadata, components) for item in items } return cls( @@ -794,7 +792,7 @@ def collect_from_images( if not components: return cls(name=name, components=components, values=[()]) values = { - ViewerDimensionValueAuthority.value_tuple(image.metadata, components) + ViewerDimensionValues.value_tuple(image.metadata, components) for image in images } return cls( @@ -812,17 +810,17 @@ def with_values(self, values: FijiDimensionValues) -> "FijiDimensionAxis": def merge_values(self, values: FijiDimensionValues) -> "FijiDimensionAxis": return self.with_values( - ViewerDimensionValueAuthority.merge( + ViewerDimensionValues.merge( self.values, values, ) ) def value_tuple(self, metadata: Mapping[str, ComponentValue]) -> tuple: - return ViewerDimensionValueAuthority.value_tuple(metadata, self.components) + return ViewerDimensionValues.value_tuple(metadata, self.components) def index(self, metadata: Mapping[str, ComponentValue]) -> int: - return ViewerDimensionValueAuthority.index( + return ViewerDimensionValues.index( metadata, self.components, self.values, @@ -1207,297 +1205,32 @@ def fixed_labels(self) -> FijiFixedLabels: return self.projection.fixed_window_labels[self.window_key] -@dataclass(frozen=True, slots=True) -class FijiControlMessageResponse(ViewerControlReplyPayload): - """Result of handling one Fiji control message.""" - - shutdown_requested: bool = False - - -@dataclass(frozen=True, slots=True) -class FijiControlRequestContext: - """Managed ImageJ runtime state available to nominal control plans.""" - - windows: FijiWindowRegistry - imagej_runtime: object - settlement: FijiBatchSettlementState - process_launch: ViewerProcessLaunchConfig = field( - default_factory=ViewerProcessLaunchConfig - ) - - -class FijiControlMessagePlan(ABC, metaclass=AutoRegisterMeta): - """Executable behavior for one Fiji control message type.""" +class FijiControlAction(ViewerControlAction, family_root=True): + """Fiji control actions, answered on the server's control thread.""" - __registry__: ClassVar[dict[str, type["FijiControlMessagePlan"]]] = {} - __registry_key__ = "wire_value" - __skip_if_no_key__ = True - wire_value: ClassVar[str | None] = None - - @classmethod - def for_message_type( - cls, message_type: str | None - ) -> "FijiControlMessagePlan | None": - if message_type is None: - return None - plan_type = cls.__registry__.get(message_type) - if plan_type is None: - return None - return plan_type() - - @abstractmethod - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - """Return the response for one Fiji viewer control request.""" - - -class FijiShutdownControlPlan(FijiControlMessagePlan): - """Acknowledge shutdown and ask the server loop to stop.""" - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del context, payload - logger.info( - "🔬 FIJI SERVER: %s requested, will close after sending acknowledgment", - self.wire_value, - ) - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - response_type="shutdown_ack", - message="Fiji viewer shutting down", - ), - shutdown_requested=True, - ) - - -class FijiGracefulShutdownControlPlan(FijiShutdownControlPlan): - """Registered graceful Fiji shutdown control plan.""" - - wire_value = "shutdown" - - -class FijiForceShutdownControlPlan(FijiShutdownControlPlan): - """Registered force Fiji shutdown control plan.""" - - wire_value = "force_shutdown" - - -class FijiClearStateControlPlan(FijiControlMessagePlan): - """Clear Fiji dimension/window metadata without shutting down.""" - - wire_value = "clear_state" - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del payload - windows = context.windows - logger.info( - "🔬 FIJI SERVER: Clearing dimension values (had %d windows)", - windows.count_with_dimensions(), - ) - context.settlement.reset() - windows.clear_dimensions_and_labels() - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - response_type="clear_state_ack", - message="Dimension values cleared", - ), - ) - - -class FijiProcessLaunchControlPlan(FijiControlMessagePlan): - """Project the server-owned launch declaration through the common boundary.""" - - wire_value = OpenHCSViewerControlMessageType.PROCESS_LAUNCH.value - - def response( - self, context: FijiControlRequestContext, payload: object | None - ) -> FijiControlMessageResponse: - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, response_type="process_launch_ack" - ), - fields={ - ViewerControlField.PROCESS_LAUNCH.value: - context.process_launch.to_wire_mapping(), - }, - ) - - -class FijiSettleControlPlan(FijiControlMessagePlan): - """Report exact queued and active Fiji display-batch progress.""" - - wire_value = ViewerControlMessageType.SETTLE.value - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del payload - progress = context.settlement.progress() - failed = progress.phase is ViewerSettlePhase.FAILED - failure = context.settlement.failure_message() - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR if failed else ViewerProtocolStatus.SUCCESS, - response_type="settle_ack", - message=( - f"Fiji viewer settlement failed: {failure}" - if failed - else ( - "Fiji viewer settlement progress: " - f"{progress.completed_update_count}/" - f"{progress.total_update_count}." - ) - ), - ), - fields=progress.to_wire_mapping(), - ) - - -@dataclass(frozen=True, slots=True) -class FijiUnsupportedStateControlPlan(FijiControlMessagePlan): - """Fail loudly for viewer-state polling until Fiji has a state projector.""" - - wire_value = ViewerControlMessageType.STATE.value - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del context, payload - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type="state_ack", - message=( - "Fiji live viewer state polling is not implemented. " - "Use Napari state polling or add a Fiji state projection " - "before requesting layers, axes, labels, or payload summaries." - ), - ), - ) - - -@dataclass(frozen=True, slots=True) -class FijiUnsupportedPayloadsControlPlan(FijiControlMessagePlan): - """Fail loudly for live payload extraction until Fiji has a state projector.""" - - wire_value = ViewerControlMessageType.PAYLOADS.value - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del context, payload - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type="payloads_ack", - message=( - "Fiji live viewer payload extraction is not implemented. " - "Add a Fiji state and payload projection before requesting " - "per-layer images, labels, shapes, or axis-coordinate payloads." - ), - ), - ) - - -class FijiUnsupportedIntensityWindowControlPlan(FijiControlMessagePlan): - """Fail closed because Fiji lacks routed native layer contrast authority.""" - - wire_value = ViewerControlMessageType.APPLY_INTENSITY_WINDOW.value - - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: - del context, payload - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type="intensity_window_ack", - message=( - "Viewer intensity-window control is supported only by Napari; " - "Fiji has no routed native image-layer contrast authority." - ), - ), - ) - - -class FijiRunMacroControlPlan(FijiControlMessagePlan): +class FijiRunMacroControlAction(FijiControlAction): """Execute an ImageJ macro inside the managed PyImageJ process.""" - wire_value = FijiMacroExecutionRequest.message_type + message_type = FijiMacroExecutionRequest.message_type - def response( - self, - context: FijiControlRequestContext, - payload: object | None, - ) -> FijiControlMessageResponse: + def handle(self, server, message: Mapping[str, FijiWireValue]) -> dict[str, object]: + payload = message.get(ViewerControlResponseField.PAYLOAD.value) try: if not isinstance(payload, FijiMacroExecutionRequest): raise TypeError( "Fiji macro control payload must be a FijiMacroExecutionRequest." ) - outputs = payload.execute(context.imagej_runtime) + outputs = payload.execute(server.ij) except Exception as error: logger.exception("Managed Fiji macro execution failed") - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - message=str(error), - ) - ) - return FijiControlMessageResponse( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - ), + return control_reply(ViewerProtocolStatus.ERROR, message=str(error)) + return control_reply( + ViewerProtocolStatus.SUCCESS, payload=FijiMacroExecutionResponse(outputs), ) -@dataclass(frozen=True, slots=True) -class FijiControlMessageAuthority: - """Handle Fiji control messages without leaking control literals into server.""" - - context: FijiControlRequestContext - - def response_for( - self, - message: Mapping[str, FijiWireValue], - ) -> FijiControlMessageResponse: - plan = FijiControlMessagePlan.for_message_type( - None - if ViewerControlResponseField.TYPE.value not in message - else str(message[ViewerControlResponseField.TYPE.value]) - ) - if plan is None: - return FijiControlMessageResponse( - ViewerControlReplyHeader(ViewerProtocolStatus.SUCCESS) - ) - return plan.response( - self.context, - message.get(ViewerControlResponseField.PAYLOAD.value), - ) - - @dataclass(frozen=True, slots=True) class FijiPayloadHandlerRequest(FijiDisplayItemContext): """Typed execution request for one Fiji payload handler.""" @@ -1541,12 +1274,13 @@ def batch_message(self) -> "FijiBatchMessage": display_config_payload = fields.required_mapping( ViewerBatchWireField.DISPLAY_CONFIG ) - display_config = FijiDisplayConfig.from_display_payload(display_config_payload) + display_config = FijiDisplaySettings.from_display_payload(display_config_payload) component_axis_semantics = fields.component_axis_semantics( ViewerMappingDisplayConfigInput(display_config_payload), context="Fiji component value domain", ) component_names_metadata = fields.required_component_names_metadata( + declared_axes=component_axis_semantics.layout.declared_axes, context="Fiji batch component-name metadata", ) return FijiBatchMessage( @@ -1556,6 +1290,7 @@ def batch_message(self) -> "FijiBatchMessage": ViewerBatchContextWireField.IMAGES_DIR ), store=component_names_metadata.store, + declared_axes=component_names_metadata.declared_axes, entries=component_axis_semantics.entries, layout=component_axis_semantics.layout, ) @@ -1572,6 +1307,7 @@ def processing_context(self) -> "FijiBatchProcessingContext": viewer_display_config=self.viewer_display_config, images_dir=self.images_dir, store=self.store, + declared_axes=self.declared_axes, entries=self.entries, layout=self.layout, ) @@ -1654,6 +1390,7 @@ def dispatch( viewer_display_config=request.viewer_display_config, coordinates=coordinates, store=request.store, + declared_axes=request.declared_axes, entries=request.entries, layout=request.layout, work_unit_completed=work_unit_completed, @@ -1674,19 +1411,17 @@ def from_items( items: Sequence[FijiWireItem], component_axis_semantics: ViewerComponentAxisSemantics, ) -> "FijiWindowItemProjection": - projected_items = FijiPayloadLocalPlaneExpander.expand_items(items) - projection = component_axis_semantics.layout.group_window_payload_providers( - projected_items - ) + projected_items = FijiPayloadLocalPlaneExpander( + component_axis_semantics.layout.declared_axes + ).expand_items(items) + layout = component_axis_semantics.layout + projection = layout.group_window_payload_providers(projected_items) return cls( window_components=projection.window_components, - channel_components=projection.channel_components, - slice_components=projection.slice_components, - frame_components=projection.frame_components, coordinate_components=FijiHyperstackCoordinateComponents( - channel=projection.channel_components, - z_axis_components=projection.slice_components, - frame=projection.frame_components, + channel=layout.components_in(FijiSlots.HyperstackChannel), + z_axis_components=layout.components_in(FijiSlots.HyperstackSlice), + frame=layout.components_in(FijiSlots.HyperstackFrame), ), windows=projection.windows, fixed_window_labels=projection.fixed_window_labels, @@ -1708,7 +1443,7 @@ def log_summary(self) -> None: @dataclass(slots=True) -class FijiBatchProcessingAuthority: +class FijiBatchDispatcher: """Own Fiji batch ingestion, debouncing, and window dispatch.""" server: "FijiViewerServer" @@ -1760,7 +1495,7 @@ def process_items_with_context( items: list[FijiWireItem], context: Mapping[str, FijiBatchProcessingContext], ) -> None: - """Batch-engine callback that unpacks context into canonical arguments.""" + """Batch-engine callback that unpacks context into arguments.""" if not items: return logger.info(f"🔄 FIJI SERVER: Processing debounced batch of {len(items)} items") @@ -1843,6 +1578,7 @@ def process_items( viewer_display_config=batch_context.viewer_display_config, projection=projection, store=batch_context.store, + declared_axes=batch_context.declared_axes, entries=batch_context.entries, layout=batch_context.layout, ), @@ -1937,7 +1673,7 @@ class FijiViewerServerLaunchConfig(ViewerServerLaunchRequest): """Nominal launch configuration for a Fiji viewer server process.""" fiji_viewer_title: str - fiji_display_config: FijiDisplayConfig | None + fiji_display_config: FijiDisplaySettings | None display_enabled: bool = True zmq_config: ZMQConfig | None = None @@ -1963,7 +1699,7 @@ def is_supported_headless_fallback(self) -> bool: ) -class FijiViewerServer(OpenHCSViewerServerABC): +class FijiViewerServer(ViewerServerPort, OpenHCSViewerServerABC): """ ZMQ server for Fiji viewer that receives images from clients. @@ -1972,7 +1708,7 @@ class FijiViewerServer(OpenHCSViewerServerABC): Displays images via PyImageJ. """ - _server_type = ViewerType.FIJI.wire_value + _server_type = FijiViewer.wire_value # Debouncing configuration DEBOUNCE_DELAY_MS = 500 # Collect items for 500ms before processing @@ -1995,7 +1731,7 @@ def __init__(self, launch_config: FijiViewerServerLaunchConfig): # REP socket forces workers to wait for acknowledgment before closing shared memory super().__init__( launch_config.port, - viewer_type=ViewerType.FIJI.wire_value, + viewer_type=FijiViewer.wire_value, host=launch_config.process_launch.listen_host, log_file_path=launch_config.log_file_path, data_socket_type=zmq.REP, @@ -2008,7 +1744,7 @@ def __init__(self, launch_config: FijiViewerServerLaunchConfig): self.ij = None # PyImageJ instance self._shutdown_requested = False self.windows = FijiWindowRegistry() - self.batch_processor = FijiBatchProcessingAuthority(self) + self.batch_processor = FijiBatchDispatcher(self) def _wait_for_swing_ui_ready(self, timeout: float = 5.0) -> bool: """Wait for Java Swing UI to be fully initialized. @@ -2131,22 +1867,30 @@ def stop(self) -> None: FIJI_IMAGEJ_RUNTIME.shutdown(gateway, sj) - def handle_control_message( - self, - message: Mapping[str, FijiWireValue], - ) -> dict[str, FijiWireScalar]: - """Handle control messages beyond ping/pong.""" - response = FijiControlMessageAuthority( - FijiControlRequestContext( - self.windows, - self.ij, - self.batch_processor.settlement, - self.launch_config.process_launch, - ) - ).response_for(message) - if response.shutdown_requested: - self._shutdown_requested = True - return response.to_wire_mapping() + control_actions = FijiControlAction + viewer_display_name = "Fiji" + + @property + def process_launch(self) -> ViewerProcessLaunchConfig: + return self.launch_config.process_launch + + def shut_down_after_reply(self) -> None: + logger.info("🔬 FIJI SERVER: shutdown requested, closing after acknowledgement") + self._shutdown_requested = True + + def clear_stream_state(self) -> None: + logger.info( + "🔬 FIJI SERVER: Clearing dimension values (had %d windows)", + self.windows.count_with_dimensions(), + ) + self.batch_processor.settlement.reset() + self.windows.clear_dimensions_and_labels() + + def settle_progress(self) -> ViewerSettleProgress: + return self.batch_processor.settlement.progress() + + def settle_failure_message(self) -> str | None: + return self.batch_processor.settlement.failure_message() def handle_data_message(self, message: Mapping[str, FijiWireValue]): """Handle incoming image data - called by process_messages().""" @@ -2162,7 +1906,7 @@ def _add_slices_to_existing_hyperstack( new_images: list[FijiImagePayload], window_key: str, coordinates: FijiHyperstackCoordinates, - display_config: FijiDisplayConfig, + display_config: FijiDisplaySettings, component_names_metadata: ViewerComponentNameMetadata, work_unit_completed: Callable[[], None] | None = None, ): @@ -2331,7 +2075,7 @@ def _build_single_hyperstack( self, window_key: str, images: list[FijiImagePayload], - display_config: FijiDisplayConfig, + display_config: FijiDisplaySettings, coordinates: FijiHyperstackCoordinates, component_names_metadata: ViewerComponentNameMetadata | None = None, work_unit_completed: Callable[[], None] | None = None, @@ -2811,7 +2555,7 @@ def _build_new_hyperstack( all_images: list[FijiImagePayload], window_key: str, coordinates: FijiHyperstackCoordinates, - display_config: FijiDisplayConfig, + display_config: FijiDisplaySettings, is_new: bool, preserved_display_ranges: FijiDisplayRanges = None, component_names_metadata: ViewerComponentNameMetadata | None = None, @@ -3145,7 +2889,7 @@ def handle(self, request: FijiPayloadHandlerRequest) -> None: def fiji_viewer_server_process( port: int, viewer_title: str, - display_config: FijiDisplayConfig | None, + display_config: FijiDisplaySettings | None, log_file_path: str = None, display_enabled: bool = True, transport_mode: TransportMode = TransportMode.IPC, @@ -3160,7 +2904,7 @@ def fiji_viewer_server_process( Args: port: ZMQ port to listen on viewer_title: Title for the Fiji viewer window - display_config: FijiDisplayConfig instance + display_config: FijiDisplaySettings instance log_file_path: Path to log file (for client discovery via ping/pong) transport_mode: ZMQ transport mode (IPC or TCP) zmq_config: ZMQ configuration object (optional, uses default if None) diff --git a/openhcs/runtime/napari_orthogonal_widget.py b/openhcs/runtime/napari_orthogonal_widget.py index c1309477be..25885db699 100644 --- a/openhcs/runtime/napari_orthogonal_widget.py +++ b/openhcs/runtime/napari_orthogonal_widget.py @@ -90,13 +90,13 @@ def read_native_state(self, *_args) -> None: def apply_plane(self) -> None: from openhcs.runtime.napari_viewer_server import ( - NapariNavigationControlMessageAction, + NapariNavigationControlAction, ) route, pair = self.routes.currentData(), self.planes.currentData() if route is None or pair is None: return - reply = NapariNavigationControlMessageAction().handle( + reply = NapariNavigationControlAction().handle( self.server, { "payload": ViewerNavigationControlOptions( diff --git a/openhcs/runtime/napari_stream_visualizer.py b/openhcs/runtime/napari_stream_visualizer.py index c7cd97eb15..5e2be2b120 100644 --- a/openhcs/runtime/napari_stream_visualizer.py +++ b/openhcs/runtime/napari_stream_visualizer.py @@ -14,14 +14,11 @@ from __future__ import annotations import logging -import threading from pathlib import Path -from typing import Optional -import numpy as np from polystore.filemanager import FileManager -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import NapariViewer from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, ) @@ -45,7 +42,7 @@ logger = logging.getLogger(__name__) NAPARI_VIEWER_ENTRYPOINT = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="openhcs.runtime.napari_viewer_server", function_name="run_napari_viewer_process", ) @@ -60,6 +57,7 @@ class NapariStreamVisualizer(ManagedViewerLifecycleMixin): viewer_process_label = "Napari" detached_server_entrypoint = NAPARI_VIEWER_ENTRYPOINT + starts_in_background = True def __init__( self, @@ -92,96 +90,3 @@ def detached_server_arguments( DetachedViewerPythonExpression.literal(self.process_launch.listen_host), ) - def start_viewer(self, async_mode: bool = True): - """ - Starts the Napari viewer in a separate process. - - Args: - async_mode: If True, start viewer asynchronously in background thread. - If False, wait for viewer to be ready before returning. - """ - if async_mode: - # Start viewer asynchronously in background thread - thread = threading.Thread(target=self._start_viewer_sync, daemon=True) - thread.start() - logger.info( - f"🔬 VISUALIZER: Starting napari viewer asynchronously on port {self.required_port}" - ) - else: - # Legacy synchronous mode - self._start_viewer_sync() - - def _start_viewer_sync(self): - """Internal synchronous viewer startup (called by start_viewer).""" - with self._lock: - port = self.required_port - self.prepare_fresh_viewer_start() - - if self.lifecycle_state.is_active: - logger.warning("Napari viewer is already running.") - return - - # Port is already set in __init__ - logger.info(f"🔬 VISUALIZER: Starting napari viewer process on port {port}") - - # ALL viewers (persistent and non-persistent) should be detached subprocess - # so they don't block parent process exit. The difference is only whether - # we terminate them during cleanup. - logger.info( - f"🔬 VISUALIZER: Creating {self.persistence_label} napari viewer (detached)" - ) - self.process = self.launch_detached_viewer() - - process_alive = self.owned_viewer_process_is_alive() - - if process_alive: - self.lifecycle_state.mark_owned_process() - logger.info( - f"🔬 VISUALIZER: Napari viewer process started successfully (PID: {self.process_pid_label})" - ) - else: - logger.error("🔬 VISUALIZER: Failed to start napari viewer process") - - def send_image_data( - self, step_id: str, image_data: np.ndarray, axis_id: str = "unknown" - ): - """ - DISABLED: This method bypasses component-aware stacking. - All visualization must go through the streaming backend. - """ - raise RuntimeError( - f"send_image_data() is disabled. Use streaming backend for component-aware display. " - f"step_id: {step_id}, axis_id: {axis_id}, shape: {image_data.shape}" - ) - - def stop_viewer(self): - """Stop the napari viewer process (only if not persistent).""" - with self._lock: - if not self.persistent: - logger.info("🔬 VISUALIZER: Stopping non-persistent napari viewer") - if self.process: - killed = self.terminate_owned_viewer_process() - if killed: - logger.warning( - "🔬 VISUALIZER: Force killing napari viewer process" - ) - self.lifecycle_state.mark_stopped() - else: - logger.info("🔬 VISUALIZER: Keeping persistent napari viewer alive") - # DON'T set is_running = False for persistent viewers! - # The process is still alive and should be reusable - - def visualize_path( - self, step_id: str, path: str, backend: str, axis_id: Optional[str] = None - ): - """ - DISABLED: This method bypasses component-aware stacking. - All visualization must go through the streaming backend. - """ - raise RuntimeError( - f"visualize_path() is disabled. Use streaming backend for component-aware display. " - f"Path: {path}, step_id: {step_id}, axis_id: {axis_id}" - ) - - def stop(self, timeout: float = 5.0): - self.stop_viewer() diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index 784df86bfe..28c735e55b 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -18,11 +18,11 @@ from napari.utils.transforms import Affine from polystore.streaming.identity import StreamProducerIdentity from polystore.streaming_constants import StreamingDataType -from zmqruntime.viewer_protocol import ViewerComponentMode, ViewerWireField +from zmqruntime.viewer_protocol import ViewerWireField -from openhcs.core.axes import AxisFamily, StackAxis +from openhcs.core.axes import StackAxis from openhcs.core.artifacts import ObjectArtifactSubjectBinding -from openhcs.core.config import NapariDisplayConfig +from openhcs.runtime.viewer_display import NapariDisplaySettings, NapariSlots from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata, ROIPlaneMetadata from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, @@ -34,7 +34,7 @@ ComponentValues, ViewerComponentAxisSemantics, ViewerComponentLayout, - ViewerComponentCoordinateAuthority, + ViewerComponentCoordinates, ViewerComponentValueDomainPayload, ViewerRouteComponentValueTracker, ViewerLayerAxisProjection, @@ -267,7 +267,7 @@ class NapariPendingLayerUpdate(ViewerComponentAxisSemantics): timer: NapariTimerHandle data_type: StreamingDataType - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings items: list[NapariStreamLayerItem] = field(default_factory=list) @classmethod @@ -277,7 +277,7 @@ def from_semantics( timer: NapariTimerHandle, data_type: StreamingDataType, semantics: ViewerComponentAxisSemantics, - display_config: NapariDisplayConfig, + display_config: NapariDisplaySettings, items: list[NapariStreamLayerItem] | None = None, ) -> "NapariPendingLayerUpdate": return cls( @@ -312,7 +312,7 @@ def advance() -> None: class NapariLayerSettlementState: """Own one incremental drain of queued Napari layer updates.""" - # None is an admitted cycle awaiting the Qt intake barrier. An empty tuple + # None is an accepted cycle awaiting the Qt intake barrier. An empty tuple # is different: Qt has bound the cycle and found no pending route updates. updates: tuple[tuple[str, NapariPendingLayerUpdate], ...] | None requested: bool = True @@ -706,7 +706,7 @@ def shape_component_values( return self.item_component_values(item, plane_indices) -class NapariAggregateAxisBindingAuthority: +class NapariAggregateAxisBindingBuilder: """Bind payload-local axes through their exact declared component domains.""" @classmethod @@ -717,9 +717,7 @@ def bindings( ) -> NapariAggregateAxisBindingSet: if not items: return NapariAggregateAxisBindingSet() - axis_components = component_axis_semantics.layout.components_for_mode( - ViewerComponentMode.STACK - ) + axis_components = component_axis_semantics.layout.components_in(NapariSlots.Stack) declared_component_values = component_axis_semantics.required_component_values( axis_components ) @@ -1052,7 +1050,7 @@ class NapariLayerSelectionSnapshot: replacing_active_layer: bool -class NapariLayerSelectionAuthority: +class NapariLayerSelection: """Preserve user layer selection across automatic streaming updates.""" @classmethod @@ -1097,7 +1095,7 @@ def _set_active_layer( viewer.layers.selection.active = layer -class NapariLayerUpdateAuthority: +class NapariLayerUpdates: """Owns create-or-replace mechanics for Napari streaming layers.""" def create_or_update( @@ -1111,7 +1109,7 @@ def create_or_update( data: LayerData, layer_kwargs: Mapping[str, LayerKwargValue], ) -> NapariLayerHandle: - selection = NapariLayerSelectionAuthority.capture( + selection = NapariLayerSelection.capture( viewer, self._existing_layer(viewer=viewer, layers=layers, route_key=route_key), ) @@ -1147,14 +1145,14 @@ def mount( route_key=route_key, ) if selection is None: - selection = NapariLayerSelectionAuthority.capture(viewer, existing_layer) + selection = NapariLayerSelection.capture(viewer, existing_layer) if existing_layer is not None: viewer.layers.remove(existing_layer) layers.pop(route_key, None) layers[route_key] = layer if layer not in viewer.layers: viewer.add_layer(layer) - NapariLayerSelectionAuthority.restore( + NapariLayerSelection.restore( viewer, selection, layer, @@ -1182,7 +1180,7 @@ class NapariDimensionLayerState: labels: DimensionLabelMap scalar_labels: tuple[str, ...] = () presentation: "NapariAxisPresentation | None" = None - display_config: NapariDisplayConfig = field(default_factory=NapariDisplayConfig) + display_config: NapariDisplaySettings = field(default_factory=NapariDisplaySettings) @classmethod def empty(cls) -> "NapariDimensionLayerState": @@ -1293,7 +1291,7 @@ def __post_init__(self) -> None: def display_axis_components(self) -> tuple[str, ...]: """Return the declaration-owned shared stack slots for this presentation.""" - return self.layout.components_for_mode(ViewerComponentMode.STACK) + return self.layout.components_in(NapariSlots.Stack) @property def projected_display_axis_indices(self) -> tuple[int, ...]: @@ -1424,7 +1422,7 @@ def spatial_axis_labels(self) -> tuple[str, ...]: return tuple( axis for axis in ( - *(stack.name for stack in AxisFamily.active().with_role(StackAxis)), + *self.layout.names_with_role(StackAxis), "y", "x", ) @@ -1483,7 +1481,7 @@ def preserve_native_axes( source_value = values[source_index] if source_value not in replacement_values[name]: continue - index = ViewerComponentCoordinateAuthority.value_index( + index = ViewerComponentCoordinates.value_index( value=source_value, component_values=replacement_values, component=name, context="rematerialized native source frame", ) @@ -1779,7 +1777,7 @@ def shared_component_values( for route, state in self.mounted_dimension_states() if route != replacement_route and state.presentation is not None), ) - axes = layout.components_for_mode(ViewerComponentMode.STACK) + axes = layout.components_in(NapariSlots.Stack) return tracker.shared_values_for( axes, replacement_route=replacement_route, @@ -1860,12 +1858,12 @@ def begin_settlement(self) -> NapariLayerSettlementState: """Bind queued updates on Qt after the accepted intake barrier.""" with self._settlement_lock: - settlement = self.admit_settlement() + settlement = self.request_settlement() if settlement.awaiting_updates: settlement.bind_updates(self.drain_pending_updates()) return settlement - def admit_settlement( + def request_settlement( self, *, requested: bool = True ) -> NapariLayerSettlementState: """Track intake or request its settlement, without touching Qt timers.""" @@ -1901,7 +1899,7 @@ def reset_settlement(self, *, accepting_stream: bool = False) -> None: and self.layer_settlement is not None and self.layer_settlement.awaiting_updates ): - return # Intake continues until Qt binds this admitted cycle. + return # Intake continues until Qt binds this accepted cycle. if self.layer_settlement is not None: self.layer_settlement.require_retirement_boundary() self.layer_update_errors.pop(None, None) @@ -2071,7 +2069,7 @@ def get_or_create( return self.processors[layer_key] -class NapariShapeLabelAuthority: +class NapariShapeLabels: """Own validation and allocation of streamed ROI object identities.""" MAX_LABEL: ClassVar[int] = int(np.iinfo(np.uint32).max) @@ -2091,7 +2089,7 @@ def declared_label(shape_dict: ShapePayloadMap) -> int | None: "Napari ROI shape metadata label must be a positive integer, " f"got {label!r}." ) - if label > NapariShapeLabelAuthority.MAX_LABEL: + if label > NapariShapeLabels.MAX_LABEL: raise ValueError( "Napari ROI shape metadata label exceeds the uint32 ROI-label " f"domain: {label!r}." @@ -2123,10 +2121,10 @@ def for_items( cls, layer_items: Sequence[NapariStreamLayerItem], ) -> "NapariShapeLabelAllocator": - return cls(NapariShapeLabelAuthority.declared_labels(layer_items)) + return cls(NapariShapeLabels.declared_labels(layer_items)) def label_for(self, shape_dict: ShapePayloadMap) -> int: - declared_label = NapariShapeLabelAuthority.declared_label(shape_dict) + declared_label = NapariShapeLabels.declared_label(shape_dict) if declared_label is not None: return declared_label while self.next_fallback_label in self.reserved_labels: diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index b3a4915324..0ccce21643 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -59,13 +59,9 @@ ControlRequestHeader, ImageTransferIdentity, ControlMessageType, - EndpointControlCapability, - ResponseType, ) -from zmqruntime.streaming import StreamingVisualizerServer from zmqruntime.transport import remove_ipc_socket from zmqruntime.viewer_protocol import ( - ViewerComponentMode, ViewerWireField, ViewerNativeLayerTransform, ViewerImageIntensityControlOptions, @@ -73,7 +69,7 @@ ViewerSourceSpatialDomainPayload, ) -from openhcs.core.axes import AxisFamily, StackAxis +from openhcs.core.axes import PartitionAxis, StackAxis from openhcs.agent.dto.viewer import ( ViewerWindowDescriptor, ViewerWindowLayerRetirementRequest, ) @@ -84,8 +80,9 @@ from openhcs.core.roi_point_metadata import ROIFractionalZ from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata from openhcs.core.artifacts import ObjectArtifactSubjectBinding -from openhcs.core.config import ( - NapariDisplayConfig, +from openhcs.runtime.viewer_display import ( + NapariDisplaySettings, + NapariSlots, NapariVariableSizeHandling, ) from metaclass_registry.strategies import EnumKeyedStrategyMixin @@ -93,13 +90,17 @@ ImagePayloadMetadata, ) from openhcs.core.source_spatial_domain import SourceSpatialDomain -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import NapariViewer from openhcs.core.streaming_config_factory import ViewerProcessLaunchConfig +from openhcs.runtime.viewer_control_actions import ( + ViewerControlAction, + ViewerServerPort, +) from openhcs.runtime.napari_streaming_handlers import ( DimensionLabelMap, LayerData, LayerKwargValue, - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariAggregateAxisBindingSet, NapariAxisPresentation, NapariBatchProcessorStore, @@ -112,7 +113,7 @@ NapariLayerHandle, NapariLayerRouteStateStore, NapariLayerSettlementState, - NapariLayerUpdateAuthority, + NapariLayerUpdates, NapariPendingLayerUpdate, NapariShapeColorProjection, NapariShapeLayerPayload, @@ -128,19 +129,17 @@ ComponentValues, ViewerBatchPayloadFields, ViewerComponentAxisSemantics, - ViewerComponentAxisSemanticsAuthority, - ViewerComponentCoordinateAuthority, + ViewerComponentAxisSemanticsFactory, + ViewerComponentCoordinates, ViewerComponentLayout, - ViewerComponentMetadataNormalizer, ViewerComponentMetadataPayload, ViewerComponentNameMetadata, ViewerComponentValueDomainPayload, ViewerDisplayBatchContext, ViewerLayerAxisProjection, - ViewerLayerAxisProjectionRequestAuthority, + ViewerLayerAxisProjectionRequestBuilder, ViewerLayerAxisProjector, ViewerMappingDisplayConfigInput, - ViewerObjectDisplayConfigInput, ViewerRouteComponentValueTracker, ViewerStreamingDataTypeHandler, ViewerStreamingDataTypeHandlerMeta, @@ -151,10 +150,10 @@ ViewerPolylineControlOptions, ViewerRegionControlOptions, ViewerRoutedImageControlOptions, - ViewerPointCoordinateAuthority, + ViewerPointCoordinates, ViewerIntensityWindowControlOptions, ViewerNativeDimensions, - ViewerResultElementCoordinateAuthority, + ViewerResultElementCoordinates, ) from openhcs.runtime.viewer_measurements import ( NativeImageMeasurement, @@ -167,7 +166,6 @@ OpenHCSViewerControlMessageType, ViewerBatchMessageType, ViewerBatchWireField, - ViewerComponentValueOrdering, ViewerControlField, ViewerImageColorControlOptions, ViewerNativeImageColorPresentation, @@ -182,7 +180,7 @@ ViewerLayerField, ViewerLayerIsolationControlOptions, ViewerLayerIsolationField, - ViewerLayerRetirementReceipt, + ViewerLayerRetirementResult, ViewerNavigationControlOptions, ViewerPayloadField, ViewerPayloadProjectionOptions, @@ -218,7 +216,7 @@ logger = logging.getLogger(__name__) -_NAPARI_LAYER_UPDATES = NapariLayerUpdateAuthority() +_NAPARI_LAYER_UPDATES = NapariLayerUpdates() DEFAULT_DIRECT_IMAGE_PATH = "" DEFAULT_IMAGE_DATA_TYPE = "image" _DEFAULT_WINDOW_SCREEN_FRACTION = 0.9 @@ -432,7 +430,7 @@ def value( @dataclass(frozen=True) -class NapariBatchPayload(ViewerDisplayBatchContext[NapariDisplayConfig]): +class NapariBatchPayload(ViewerDisplayBatchContext[NapariDisplaySettings]): """Typed view of an incoming batch message.""" images: NapariBatchImagePayloads @@ -452,15 +450,17 @@ def from_json_payload( context="Napari component value domain", ) component_names_metadata = fields.optional_component_names_metadata( + declared_axes=component_axis_semantics.layout.declared_axes, context="Napari component-name metadata", ) return cls( msg_type=fields.message_type(), images=fields.required_mapping_items(ViewerBatchWireField.IMAGES), - viewer_display_config=NapariDisplayConfig.from_display_payload( + viewer_display_config=NapariDisplaySettings.from_display_payload( display_config_payload ), store=component_names_metadata.store, + declared_axes=component_names_metadata.declared_axes, entries=component_axis_semantics.entries, layout=component_axis_semantics.layout, ) @@ -492,7 +492,7 @@ def dispatch_to(self, server: "NapariViewerServer") -> None: ) for item in self.items: - settlement = server.layer_route_state.admit_settlement(requested=False) + settlement = server.layer_route_state.request_settlement(requested=False) with settlement.intake_item(): server._process_loaded_image(item) @@ -513,21 +513,21 @@ def from_wire_mapping(cls, message: Mapping[str, object]) -> NapariAcceptedContr from openhcs.agent.dto.viewer import ViewerWindowControlRequest deadline = ControlRequestHeader.admit_observation(message) - admitted = dict(message) - del admitted[MessageFields.OBSERVATION_BUDGET_SECONDS] - del admitted[MessageFields.OBSERVATION_OPERATION] - payload = admitted.get(ViewerControlResponseField.PAYLOAD.value) + accepted = dict(message) + del accepted[MessageFields.OBSERVATION_BUDGET_SECONDS] + del accepted[MessageFields.OBSERVATION_OPERATION] + payload = accepted.get(ViewerControlResponseField.PAYLOAD.value) if isinstance(payload, ViewerWindowControlRequest): validate_annotated_dataclass(payload) if payload.operation_deadline is not None: raise ValueError("A control payload cannot carry a sender-host deadline.") - admitted[ViewerControlResponseField.PAYLOAD.value] = replace( + accepted[ViewerControlResponseField.PAYLOAD.value] = replace( payload, operation_deadline=deadline, ) - return cls(admitted, observation_deadline=deadline) + return cls(accepted, observation_deadline=deadline) def observe(self, server, stop_event) -> bytes: - """Release reply custody on expiry, without cancelling native custody.""" + """Release reply ownership on expiry, without cancelling native ownership.""" deadline = self.observation_deadline while True: if self.response.done(): @@ -572,10 +572,10 @@ def fail_observation(self, server, failure) -> None: ) def dispatch_to(self, server) -> None: - """Keep action selection and synchronous admission in reply custody.""" + """Keep action selection and synchronous acceptance in reply ownership.""" try: message_type = self.message.get(ViewerControlResponseField.TYPE.value) - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( message_type if isinstance(message_type, str) else None ) action.dispatch(server, self) @@ -645,14 +645,14 @@ class NapariStreamLayerContext(ViewerComponentAxisSemantics): address: NapariStreamLayerAddress image_metadata: ImagePayloadMetadata plane_component_domain: ViewerComponentValueDomainPayload - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings @classmethod def from_payload_map( cls, payload: PayloadMap, layer_axis_projection_semantics: ViewerComponentAxisSemantics, - display_config: NapariDisplayConfig, + display_config: NapariDisplaySettings, ) -> "NapariStreamLayerContext": data_type_value = payload.optional(ViewerWireField.DATA_TYPE) if data_type_value is None: @@ -690,6 +690,7 @@ def from_payload_map( ), plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( payload.optional_mapping(ViewerWireField.PLANE_COMPONENT_VALUES), + declared_axes=layer_axis_projection_semantics.layout.declared_axes, context="Napari image plane component values", ), display_config=display_config, @@ -701,14 +702,12 @@ def layer_route( payload_layout_role: "NapariImagePayloadLayoutRole | None", layer_route_state: "NapariLayerRouteStateStore", ) -> "NapariLayerRoute": - component_info = _COMPONENT_METADATA_NORMALIZER.normalize( - self.address.components - ) + component_info = self.layout.normalizer().normalize(self.address.components) component_layout = self.layout base_layer_key = build_route_key( producer_identity=self.producer, component_info=component_info, - display_layout=component_layout, + layer_components=component_layout.components_in(NapariSlots.Layer), data_type=self.address.stream_layer_data_type, ) layer_key = ( @@ -716,8 +715,8 @@ def layer_route( if payload_layout_role is None else payload_layout_role.route_key(base_layer_key) ) - layer_title = NapariLayerTitleAuthority.disambiguate( - title=NapariLayerTitleAuthority.title( + layer_title = NapariLayerTitles.disambiguate( + title=NapariLayerTitles.title( producer=self.producer, stream_layer_data_type=self.address.stream_layer_data_type, component_info=component_info, @@ -765,7 +764,7 @@ def from_payload( cls, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, - display_config: NapariDisplayConfig, + display_config: NapariDisplaySettings, ) -> "NapariImagePayload": payload = PayloadMap(image_info, "Napari image message") stream_layer_context = NapariStreamLayerContext.from_payload_map( @@ -827,7 +826,6 @@ def direct_data(self) -> LayerData: ) -_COMPONENT_METADATA_NORMALIZER = ViewerComponentMetadataNormalizer() _ACK_ERROR = ViewerProtocolStatus.ERROR.value _ACK_SUCCESS = ViewerProtocolStatus.SUCCESS.value NAPARI_SETTLEMENT_UPDATE_YIELD_MS = 10 @@ -924,7 +922,7 @@ class NapariLayerRoute(ViewerComponentAxisSemantics): layer_title: str component_info: ComponentMap item_address: NapariStreamLayerAddress - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings payload_layout_role: NapariImagePayloadLayoutRole | None = None image_metadata: ImagePayloadMetadata = field(default_factory=ImagePayloadMetadata) plane_component_domain: ViewerComponentValueDomainPayload = field( @@ -1012,7 +1010,7 @@ def materialize_layer_items( class NapariSeparateLayersDisplayStrategy(NapariVariableSizeDisplayStrategy): - """Project the well axis into distinct routes and preserve native shapes.""" + """Give each partition-axis value its own route and keep native shapes.""" variable_size_handling = NapariVariableSizeHandling.SEPARATE_LAYERS @@ -1023,24 +1021,26 @@ def routing_context( if context.address.stream_layer_data_type is not StreamingDataType.IMAGE: return context - well_component = AxisFamily.active().partition_axis().name - ViewerComponentCoordinateAuthority.required_value( - context.address.components, - well_component, - context="Napari separate-layer routing", - ) - if well_component in context.layout.components_for_mode( - ViewerComponentMode.LAYER - ): + partition_components = context.layout.names_with_role(PartitionAxis) + for component in partition_components: + ViewerComponentCoordinates.required_value( + context.address.components, + component, + context="Napari separate-layer routing", + ) + layered = context.layout.components_in(NapariSlots.Layer) + if all(component in layered for component in partition_components): return context - - component_modes = dict(context.layout.component_modes) - component_modes[well_component] = ViewerComponentMode.LAYER.value return replace( context, - layout=ViewerComponentLayout.from_parts( - component_modes=component_modes, - component_order=context.layout.component_order, + layout=context.layout.with_modes( + { + **context.layout.component_modes, + **{ + component: NapariSlots.Layer.wire_value + for component in partition_components + }, + } ), ) @@ -1267,6 +1267,7 @@ def _upsert_item( def _build_nd_points( layer_items: list[NapariStreamLayerItem], axis_projection: ViewerLayerAxisProjection, + stack_components: tuple[str, ...], ): """ Build nD points by prepending stack component indices to 2D point coordinates. @@ -1304,10 +1305,11 @@ def _build_nd_points( z_axis_index = None if fractional_z is not None: try: + (stack_component,) = stack_components z_axis_index = axis_projection.projected_axis_components.index( - AxisFamily.active().one(StackAxis).name + stack_component ) - except (ValueError, LookupError) as exc: + except ValueError as exc: raise ValueError( "Fractional-Z point ROI requires a projected stack axis." ) from exc @@ -1437,7 +1439,7 @@ def _build_nd_image_array( return stacked_array -class NapariLayerTitleAuthority: +class NapariLayerTitles: """Build visible titles from producer identity and layer-routed components.""" @classmethod @@ -1451,10 +1453,8 @@ def title( payload_layout_role: NapariImagePayloadLayoutRole | None = None, ) -> str: parts = [StreamProducerDisplayNameAuthority.output_label(producer)] - for component in component_layout.components_for_mode( - ViewerComponentMode.LAYER - ): - value = ViewerComponentCoordinateAuthority.required_value( + for component in component_layout.components_in(NapariSlots.Layer): + value = ViewerComponentCoordinates.required_value( component_info, component, context="Napari layer title", @@ -1512,7 +1512,7 @@ def apply( self, presentation: NapariAxisPresentation, *, - display_config: NapariDisplayConfig | None = None, + display_config: NapariDisplaySettings | None = None, ) -> tuple[str, ...] | None: self._validate_payload_axis_labels(presentation.payload_axis_labels) axis_projection = presentation.projection @@ -1859,7 +1859,7 @@ class NapariLayerDisplayRequest: pipeline: "NapariLayerDisplayPipeline" items: list[NapariStreamLayerItem] presentation: NapariAxisPresentation - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings def create_or_update_layer( self, @@ -1948,7 +1948,7 @@ def requires_rematerialization(self) -> bool: @contextmanager def preserve_native_presentation(self): - """Only changed geometry/coordinates need remount selection admission. + """Only changed geometry/coordinates need remount selection acceptance. Untouched routes may legitimately hold hidden, off-slice native rows. Their unchanged membership is not a new rematerialized selection. @@ -2007,8 +2007,8 @@ class NapariLayerDisplayHandler( """Executable display handler for one Napari stream data type.""" title_suffix: ClassVar[str] = "" - result_coordinate_authority: ClassVar[type[ViewerResultElementCoordinateAuthority]] = ( - ViewerResultElementCoordinateAuthority + result_coordinate_authority: ClassVar[type[ViewerResultElementCoordinates]] = ( + ViewerResultElementCoordinates ) @contextmanager @@ -2356,8 +2356,8 @@ class NapariPointsLayerDisplayHandler( streaming_data_type: ClassVar[StreamingDataType] = StreamingDataType.POINTS title_suffix: ClassVar[str] = "points" - result_coordinate_authority: ClassVar[type[ViewerResultElementCoordinateAuthority]] = ( - ViewerPointCoordinateAuthority + result_coordinate_authority: ClassVar[type[ViewerResultElementCoordinates]] = ( + ViewerPointCoordinates ) def geometric_component_values( @@ -2376,10 +2376,10 @@ def geometric_component_values( ) if not coordinates: return {} - stack_axes = AxisFamily.active().with_role(StackAxis) - z_component = stack_axes[0].name if stack_axes else None - if z_component not in component_axis_semantics.layout.components_for_mode( - ViewerComponentMode.STACK + stack_axes = component_axis_semantics.layout.names_with_role(StackAxis) + z_component = stack_axes[0] if stack_axes else None + if z_component not in component_axis_semantics.layout.components_in( + NapariSlots.Stack ): raise ValueError( "Fractional-Z point ROI requires a projected stack axis." @@ -2390,7 +2390,7 @@ def geometric_component_values( ] represented: set[ComponentValue] = set() for item, fractional_z in coordinates: - anchor = ViewerComponentCoordinateAuthority.required_value( + anchor = ViewerComponentCoordinates.required_value( item.address.components, z_component, context="Napari fractional-Z point", @@ -2423,6 +2423,7 @@ def handle(self, request: NapariLayerDisplayRequest) -> None: points_data, properties = _build_nd_points( request.items, presentation.projection, + presentation.layout.names_with_role(StackAxis), ) points_data = presentation.align_coordinates(points_data) axis_labels = presentation.axis_labels @@ -2474,7 +2475,8 @@ def native_frame_applied(self, *, include_hidden: bool = False) -> bool: viewer = self.server.require_viewer() return self._native_frame_mutation_depth == 0 and all( layer.loaded and ( - not layer.visible or layer._slice_input == layer._make_slice_input(viewer.dims) + not layer.visible or layer._slice_input + == layer._slicing_state.make_slice_input(viewer.dims) ) for layer in viewer.layers if layer.visible or include_hidden ) @@ -2497,7 +2499,7 @@ def same_native_data_objects(cls, layer, originals) -> bool: def native_frame_completion(self, *, include_hidden: bool = False) -> Future[None]: """Observe QtViewer's original response consumer without polling. - set_data follows response application and loaded-request-ID admission. + set_data follows response application and loaded-request-ID acceptance. No geometry, coordinates or loaded state are copied into this owner. """ frame: Future[None] = Future() @@ -2541,11 +2543,11 @@ def native_frame_mutation(self): """Commit resident streaming-array frame changes in one Qt turn. This local native context does not alter napari settings or ordinary - asynchronous viewer navigation. Admission first awaits existing native + asynchronous viewer navigation. Acceptance first awaits existing native work, so an older async response cannot overwrite the committed frame. """ if not self.native_frame_applied(include_hidden=True): - raise RuntimeError("Native frame mutation requires applied slice admission.") + raise RuntimeError("Native frame mutation requires applied slice acceptance.") with self.server.require_viewer()._layer_slicer.force_sync(): self._native_frame_mutation_depth += 1 try: @@ -2592,7 +2594,7 @@ def display_axis_projection( ).geometric_component_values(layer_items, component_axis_semantics) projection_request = ( - ViewerLayerAxisProjectionRequestAuthority.from_component_axis_semantics( + ViewerLayerAxisProjectionRequestBuilder.from_component_axis_semantics( route_key=layer_key, component_axis_semantics=component_axis_semantics, layer_items=layer_items, @@ -2642,7 +2644,7 @@ def reconcile_mounted_axis_projections( if display_layout is not None: layout = layout.with_shared_stack_axes((display_layout,)) axis_projection_semantics = state.presentation.for_display_layout(layout) - aggregate_axis_bindings = NapariAggregateAxisBindingAuthority.bindings( + aggregate_axis_bindings = NapariAggregateAxisBindingBuilder.bindings( items, axis_projection_semantics, ) @@ -2760,7 +2762,7 @@ def _execute_scheduled_applied_update( return try: frame.result() - settlement = self.server.layer_route_state.admit_settlement(requested=False) + settlement = self.server.layer_route_state.request_settlement(requested=False) with self.native_frame_mutation(), settlement.debounced_work_unit(layer_key): work = self._work_for_update( layer_key=layer_key, @@ -2857,7 +2859,7 @@ def settlement_progress(self) -> ViewerSettleProgress: """Begin or observe an incremental Qt-driven settlement cycle.""" with self.server.layer_route_state.mutation_boundary(): - settlement = self.server.layer_route_state.admit_settlement() + settlement = self.server.layer_route_state.request_settlement() # Stream acceptance uses the same boundary. No cycle can report an # empty completion while an accepted batch remains to be projected. if ( @@ -2962,6 +2964,7 @@ def display_layer_batch( ) -> NapariLayerDisplayWork: if component_names_metadata: self.server.component_name_metadata.merge(component_names_metadata) + self.server.component_name_metadata.declare(display_payload.layout.declared_axes) if not items: raise ValueError(f"Napari display batch for {layer_key!r} has no items.") @@ -2975,13 +2978,11 @@ def display_layer_batch( f"{item.address.stream_layer_data_type!r}." ) - aggregate_axis_bindings = NapariAggregateAxisBindingAuthority.bindings( + aggregate_axis_bindings = NapariAggregateAxisBindingBuilder.bindings( items, display_payload, ) - display_layout = ViewerObjectDisplayConfigInput( - display_payload.display_config - ).layout() + display_layout = display_payload.layout.declared_layout() display_layout = self.server.layer_route_state.shared_display_layout(display_layout) projection_semantics = display_payload.for_display_layout(display_layout) preview_values = self.server.layer_route_state.shared_component_values( @@ -3035,59 +3036,20 @@ class NapariMessageTypeBase(ABC): message_type: ClassVar[str | None] = None -class NapariControlMessageAction(NapariMessageTypeBase, metaclass=AutoRegisterMeta): - """Registered handler for one Napari control message type.""" - - __registry__: ClassVar[dict[str, type["NapariControlMessageAction"]]] = {} +class NapariControlAction(ViewerControlAction, family_root=True): + """Napari control actions: completed on the Qt thread unless they answer + on the transport thread.""" def dispatch(self, server, request: NapariAcceptedControlRequest) -> None: """Recovery and lifecycle actions do not depend on slice completion.""" request.complete_from(server, self.handle, server, request.message) - def validate_admission(self, request: NapariAcceptedControlRequest) -> None: - """Action-specific admission uses the already decoded request.""" + def validate_request(self, request: NapariAcceptedControlRequest) -> None: + """Fail a request whose observation deadline already passed.""" request.observation_deadline.remaining_seconds() - @classmethod - def for_message_type(cls, message_type: str | None) -> "NapariControlMessageAction": - if message_type in cls.__registry__: - return cls.__registry__[message_type]() - return NapariUnknownControlMessageAction() - - @classmethod - def endpoint_control_capabilities( - cls, - ) -> frozenset[EndpointControlCapability]: - """Derive generic endpoint lifecycle capabilities from registered actions.""" - - registered_message_types = frozenset(cls.__registry__) - return frozenset( - capability - for capability in EndpointControlCapability - if capability is EndpointControlCapability.PING - or capability.value in registered_message_types - ) - @abstractmethod - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - """Handle a control message and return the control reply.""" - - def transport_thread_response( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object] | None: - """Return a socket-thread-safe reply, or defer this action to Qt.""" - - del server, message - return None - - -class NapariAppliedFrameControlMessageAction(NapariControlMessageAction): +class NapariAppliedFrameControlAction(NapariControlAction): """Native-frame consumers await the original Qt response application.""" def dispatch(self, server, request: NapariAcceptedControlRequest) -> None: @@ -3099,7 +3061,7 @@ def dispatch_applied_frame(self, server, request: NapariAcceptedControlRequest) request.complete_from(server, self.handle, server, request.message) -class NapariResidentFrameMutationControlMessageAction(NapariAppliedFrameControlMessageAction): +class NapariResidentFrameMutationControlAction(NapariAppliedFrameControlAction): """Commit preloaded streaming arrays atomically, not ordinary async UI navigation.""" def dispatch(self, server, request: NapariAcceptedControlRequest) -> None: @@ -3115,187 +3077,6 @@ def mutate(): request.complete_from(server, mutate) -class NapariShutdownControlMessageAction(NapariControlMessageAction): - """Shared shutdown behavior for graceful and force shutdown requests.""" - - message_type = None - - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - del message - logger.info("🔬 NAPARI SERVER: %s requested, closing viewer", self.message_type) - server.request_shutdown() - if server.viewer is not None: - from qtpy import QtCore - - QtCore.QTimer.singleShot(100, server.viewer.close) - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - response_type=ResponseType.SHUTDOWN_ACK.value, - message="Napari viewer shutting down", - ) - ).to_wire_mapping() - - -class NapariGracefulShutdownControlMessageAction(NapariShutdownControlMessageAction): - """Registered graceful shutdown action.""" - - message_type = ControlMessageType.SHUTDOWN.value - - -class NapariForceShutdownControlMessageAction(NapariShutdownControlMessageAction): - """Registered force shutdown action.""" - - message_type = ControlMessageType.FORCE_SHUTDOWN.value - - -class NapariClearStateControlMessageAction(NapariControlMessageAction): - """Registered action that clears accumulated streaming state.""" - - message_type = ViewerControlMessageType.CLEAR_STATE.value - - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - del message - logger.info( - "🔬 NAPARI SERVER: Clearing component groups (had %d groups)", - len(server.component_groups), - ) - server.clear_accumulated_stream_state() - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - response_type="clear_state_ack", - message="Component groups cleared", - ) - ).to_wire_mapping() - - -class NapariProcessLaunchControlMessageAction(NapariControlMessageAction): - """Report immutable process-global settings without entering the Qt thread.""" - - message_type = OpenHCSViewerControlMessageType.PROCESS_LAUNCH.value - - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - del message - response = ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.SUCCESS, - response_type="process_launch_ack", - ) - ).to_wire_mapping() - response[ViewerControlField.PROCESS_LAUNCH.value] = ( - server.process_launch.to_wire_mapping() - ) - return response - - def transport_thread_response( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - return self.handle(server, message) - - -class NapariSettleControlMessageAction(NapariControlMessageAction): - """Registered action that drains queued debounced layer updates.""" - - message_type = ViewerControlMessageType.SETTLE.value - - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - del message - unavailable = self._unavailable_response(server) - if unavailable is not None: - return unavailable - return self._progress_response( - server, - server.display_pipeline.settlement_progress(), - ) - - def transport_thread_response( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object] | None: - """Admit or observe settlement without waiting for Qt rendering.""" - - del message - unavailable = self._unavailable_response(server) - if unavailable is not None: - return unavailable - with server.layer_route_state.mutation_boundary(): - progress = server.layer_route_state.admit_settlement().progress() - return self._progress_response(server, progress) - - @staticmethod - def _unavailable_response( - server: "NapariViewerServer", - ) -> dict[str, object] | None: - if server.transport_failure is not None: - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type="settle_ack", - message=( - "Viewer transport failed before settlement: " - f"{server.transport_failure}" - ), - ) - ).to_wire_mapping() - if server.viewer is None: - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type="settle_ack", - message="Napari viewer is not available.", - ) - ).to_wire_mapping() - return None - - @staticmethod - def _progress_response( - server: "NapariViewerServer", - progress: ViewerSettleProgress, - ) -> dict[str, object]: - failed = progress.phase is ViewerSettlePhase.FAILED - failure = server.layer_route_state.update_failure_message() - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ( - ViewerProtocolStatus.ERROR - if failed - else ViewerProtocolStatus.SUCCESS - ), - response_type="settle_ack", - message=( - f"Viewer settlement failed: {failure}" - if failed - else ( - "Viewer settlement progress: " - f"{progress.completed_update_count}/" - f"{progress.total_update_count}." - ) - ), - ), - fields=progress.to_wire_mapping(), - ).to_wire_mapping() - - @dataclass(frozen=True, slots=True) class ShapeCoordinateBounds: """Aggregate YX coordinate bounds for Napari shape payload state.""" @@ -3473,7 +3254,7 @@ def members_for(self, subject_id: object) -> tuple[int, ...]: return self.member_indices_by_subject_id.get(subject_id, ()) -class NapariResultSelectionGroupAuthority: +class NapariResultSelectionGroups: """Resolve object-owned ROI groups from framework-declared feature metadata.""" @staticmethod @@ -3561,7 +3342,7 @@ def index( ) -class NapariResultElementSelectionAuthority: +class NapariResultElementSelections: """Own native feature-row selection without layer-kind or assay dispatch.""" @classmethod @@ -3656,7 +3437,7 @@ def displayed_indices( for data_index in indices: cls.require_data_index(layer, data_index) native_layer = cast(napari.layers.Shapes | napari.layers.Points, layer) - displayed = set(native_layer._indices_view) + displayed = set(native_layer._view_indices) return tuple(index for index in indices if index in displayed) @@ -3696,7 +3477,7 @@ def preserve_selection( matching, or assumption that an old table index still names that member. """ layer = self.server.layer_route_state.layer(route_key) - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) identities = self._element_identities(layer, state) selected = frozenset(identities[index] for index in state.selected_data_indices) binding = self._group_indices.get(layer) @@ -3707,7 +3488,7 @@ def preserve_selection( try: yield target = self.server.layer_route_state.layer(route_key) - target_state = NapariResultElementSelectionAuthority.state(target) + target_state = NapariResultElementSelections.state(target) target_identities = self._element_identities(target, target_state) indices = tuple( index for index, identity in enumerate(target_identities) @@ -3729,7 +3510,7 @@ def preserve_selection( self._observed_indices.pop(layer, None) self._group_indices.pop(layer, None) self.bind(target, binding.binding if binding is not None else None) - observed = NapariResultElementSelectionAuthority.select_indices(target, displayed) + observed = NapariResultElementSelections.select_indices(target, displayed) if bound: self._observed_indices[target] = observed.selected_data_indices finally: @@ -3743,7 +3524,7 @@ def _element_identities( ) -> tuple[object, ...]: if state.feature_row_count == 0: return () - identities = NapariResultSelectionGroupAuthority.feature_values( + identities = NapariResultSelectionGroups.feature_values( layer, NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE, ) if identities is None or len(identities) != state.feature_row_count: @@ -3758,15 +3539,15 @@ def select( data_index: int, ) -> NapariResultElementSelectionState: """Select exactly the bound object members and verify native readback.""" - NapariResultElementSelectionAuthority.require_data_index(layer, data_index) + NapariResultElementSelections.require_data_index(layer, data_index) linked = self._synchronize_linked_group(layer, data_index) for candidate, member_indices in linked: - observed = NapariResultElementSelectionAuthority.state(candidate) + observed = NapariResultElementSelections.state(candidate) if observed.selected_data_indices != member_indices: raise RuntimeError( - "Napari did not retain the canonical linked result selection." + "Napari did not retain the linked result selection." ) - return NapariResultElementSelectionAuthority.state(layer) + return NapariResultElementSelections.state(layer) @staticmethod def _highlight_settings(): @@ -3877,7 +3658,7 @@ def result_group_color( ) -> tuple[float, float, float, float]: """Return the native edge color for the selected object-owned ROI group.""" - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) if not state.selected_data_indices: raise ValueError("Bound OpenHCS result layer has no selected ROI group.") colors = np.asarray( @@ -3899,13 +3680,13 @@ def set_result_group_color( ) -> None: """Recolor every native ROI member of the selected object subject.""" - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) if not state.selected_data_indices: raise ValueError("Bound OpenHCS result layer has no selected ROI group.") rgba = self._normalize_rgba(color, context="result group") linked = self._linked_group_members(layer, state.selected_data_indices[0]) for candidate, member_indices in linked: - candidate_state = NapariResultElementSelectionAuthority.state(candidate) + candidate_state = NapariResultElementSelections.state(candidate) result_layer = cast(NapariShapesLayerHandle, candidate) colors = np.asarray(result_layer.edge_color, dtype=float) if colors.ndim == 1: @@ -3943,7 +3724,7 @@ def refresh_highlights(self) -> None: self._refreshing_highlights = True try: for layer in tuple(self._callbacks): - state = NapariResultElementSelectionAuthority.state( + state = NapariResultElementSelections.state( cast(NapariLayerHandle, layer) ) if state.selected_data_indices: @@ -3959,16 +3740,16 @@ def bind( """Refresh derived group lookup and bind native selection exactly once.""" result_layer = cast(NapariShapesLayerHandle, layer) - NapariResultElementSelectionAuthority.state(layer) + NapariResultElementSelections.state(layer) resolved_group_binding = ( group_binding if group_binding is not None - else NapariResultSelectionGroupAuthority.declared_binding(layer) + else NapariResultSelectionGroups.declared_binding(layer) ) if resolved_group_binding is None: self._group_indices.pop(layer, None) else: - self._group_indices[layer] = NapariResultSelectionGroupAuthority.index( + self._group_indices[layer] = NapariResultSelectionGroups.index( layer, resolved_group_binding, ) @@ -3984,14 +3765,14 @@ def on_highlight(_event: object = None) -> None: result_layer.events.highlight.connect(on_highlight) self._callbacks[layer] = on_highlight - self._observed_indices[layer] = NapariResultElementSelectionAuthority.state( + self._observed_indices[layer] = NapariResultElementSelections.state( layer ).selected_data_indices def _queue_selection(self, layer: NapariLayerHandle) -> None: if self._synchronizing_group_selection: return - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) previous_indices = self._observed_indices.get(layer, ()) self._observed_indices[layer] = state.selected_data_indices if state.selected_data_indices == previous_indices: @@ -4048,7 +3829,7 @@ def displayed_result_indices(self, layer, data_indices): if not layer.visible: layer._slice_dims(self.server.require_viewer().dims) layer._refresh_sync(data_displayed=True, force=True) - return NapariResultElementSelectionAuthority.displayed_indices(layer, data_indices) + return NapariResultElementSelections.displayed_indices(layer, data_indices) def _synchronize_linked_group( self, @@ -4068,7 +3849,7 @@ def _synchronize_linked_group( self._synchronizing_group_selection = True try: for candidate, member_indices in linked: - observed = NapariResultElementSelectionAuthority.select_indices( + observed = NapariResultElementSelections.select_indices( candidate, member_indices, ) self._observed_indices[candidate] = observed.selected_data_indices @@ -4084,7 +3865,7 @@ def select_result_element( ) -> tuple[tuple[NapariLayerHandle, tuple[int, ...]], ...]: """Select the complete declared result subject containing one row.""" - NapariResultElementSelectionAuthority.require_data_index(layer, data_index) + NapariResultElementSelections.require_data_index(layer, data_index) return self._synchronize_linked_group(layer, data_index) def _apply_selection( @@ -4118,7 +3899,7 @@ def _apply_resident_selection( if layer is None or layer not in self.server.viewer.layers: return native_layer = cast(NapariLayerHandle, layer) - state = NapariResultElementSelectionAuthority.state(native_layer) + state = NapariResultElementSelections.state(native_layer) if data_index not in state.selected_data_indices: return @@ -4138,7 +3919,7 @@ def _apply_resident_selection( ) return try: - navigation = NapariNavigationControlMessageAction() + navigation = NapariNavigationControlAction() axis_indices = navigation.result_element_axis_indices( self.server, native_layer, @@ -4252,7 +4033,7 @@ def sync_layer_color(_event: object = None) -> None: def sync_group_color(_event: object = None) -> None: layer = active_result_layer() - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) has_group = layer is not None and bool(state.selected_data_indices) group_color_button.setEnabled(has_group) if layer is None or not has_group: @@ -4310,7 +4091,7 @@ def choose_group_color() -> None: if layer is None: sync_group_color() return - state = NapariResultElementSelectionAuthority.state(layer) + state = NapariResultElementSelections.state(layer) if not state.selected_data_indices: sync_group_color() return @@ -4377,7 +4158,7 @@ def wire_envelope( ViewerControlResponseField.TYPE.value: response_type, ViewerControlResponseField.STATUS.value: _ACK_SUCCESS, ViewerControlField.VIEWER.value: { - ViewerDescriptorField.TYPE.value: ViewerType.NAPARI.wire_value, + ViewerDescriptorField.TYPE.value: NapariViewer.wire_value, ViewerDescriptorField.TITLE.value: self.server.napari_window_title, }, ViewerControlField.LAYER_COUNT.value: len(layers), @@ -4421,7 +4202,7 @@ def layer_state_for(self, route_key: str) -> dict[str, NapariWireValue]: if layer is not None: layer_visible = bool(layer.visible) layer_selected = layer is self.viewer.layers.selection.active - result_selection = NapariResultElementSelectionAuthority.state(layer) + result_selection = NapariResultElementSelections.state(layer) component_values = self.component_values_for(dimension_state, item_tuple) payload_summaries = self.payload_summaries_for(dimension_state, item_tuple) producer_identities = tuple( @@ -5216,7 +4997,7 @@ def wire_value(cls, value: NapariWireValue) -> NapariWireValue: return str(value) -class NapariStateControlMessageAction(NapariAppliedFrameControlMessageAction): +class NapariStateControlAction(NapariAppliedFrameControlAction): """Registered action that reports live layer and axis state.""" message_type = ViewerControlMessageType.STATE.value @@ -5247,7 +5028,7 @@ def handle( ).to_wire_mapping() -class NapariPayloadsControlMessageAction(NapariControlMessageAction): +class NapariPayloadsControlAction(NapariControlAction): """Registered action that reports live payload records by layer and axis.""" message_type = ViewerControlMessageType.PAYLOADS.value @@ -5279,7 +5060,7 @@ def handle( ).to_wire_mapping() -class NapariMountedRouteControlMessageAction(NapariAppliedFrameControlMessageAction): +class NapariMountedRouteControlAction(NapariAppliedFrameControlAction): """Shared exact mounted-route boundary for routed native commands.""" def _mounted_layer( @@ -5358,8 +5139,8 @@ def matched_image_records( return tuple(records) -class NapariFeatureMeasurementControlMessageAction( - NapariMountedRouteControlMessageAction +class NapariFeatureMeasurementControlAction( + NapariMountedRouteControlAction ): """Read-only bounded measurement; executes on the owning Qt thread.""" @@ -5492,8 +5273,8 @@ def world(point: tuple[float, float]) -> tuple[float, ...]: return NativeImageMeasurement(data, origin, world), coordinates -class NapariPolylineMeasurementControlMessageAction( - NapariFeatureMeasurementControlMessageAction +class NapariPolylineMeasurementControlAction( + NapariFeatureMeasurementControlAction ): message_type = OpenHCSViewerControlMessageType.MEASURE_POLYLINE.value request_type = ViewerPolylineControlOptions @@ -5504,8 +5285,8 @@ def measure( return plane.polyline(request) -class NapariRegionMeasurementControlMessageAction( - NapariFeatureMeasurementControlMessageAction +class NapariRegionMeasurementControlAction( + NapariFeatureMeasurementControlAction ): message_type = OpenHCSViewerControlMessageType.MEASURE_REGION.value request_type = ViewerRegionControlOptions @@ -5516,8 +5297,8 @@ def measure( return plane.region(request) -class NapariPresentationControlMessageAction(NapariAppliedFrameControlMessageAction): - """Shared nominal admission, native apply/readback and reply lifecycle.""" +class NapariPresentationControlAction(NapariAppliedFrameControlAction): + """Shared nominal acceptance, native apply/readback and reply lifecycle.""" request_type: ClassVar[type] response_field: ClassVar[ViewerControlField] @@ -5548,7 +5329,7 @@ def handle( ).to_wire_mapping() -class NapariViewportControlMessageAction(NapariPresentationControlMessageAction): +class NapariViewportControlAction(NapariPresentationControlAction): message_type = ViewerControlMessageType.VIEWPORT.value request_type = ViewerNativeViewportPresentation response_field = ViewerControlField.NATIVE_VIEWPORT @@ -5561,8 +5342,8 @@ def apply_presentation(self, server, request): return control.snapshot() -class NapariImageColorControlMessageAction( - NapariPresentationControlMessageAction, NapariMountedRouteControlMessageAction, +class NapariImageColorControlAction( + NapariPresentationControlAction, NapariMountedRouteControlAction, ): """Independent exact-route and native reply capabilities compose by C3 MRO.""" @@ -5581,7 +5362,7 @@ def apply_presentation(self, server, request): return ViewerNativeImageColorPresentation(layer.colormap.name, layer.blending) -class NapariWindowPresentationControlMessageAction(NapariPresentationControlMessageAction): +class NapariWindowPresentationControlAction(NapariPresentationControlAction): message_type = OpenHCSViewerControlMessageType.WINDOW_PRESENTATION.value request_type = ViewerNativeWindowControlOptions response_field = ViewerControlField.NATIVE_WINDOW @@ -5592,7 +5373,7 @@ def apply_presentation(self, server, request): return control.snapshot() -class NapariImageIntensityControlMessageAction(NapariMountedRouteControlMessageAction): +class NapariImageIntensityControlAction(NapariMountedRouteControlAction): """Apply a typed native image presentation on the owning Qt thread.""" message_type = ViewerControlMessageType.IMAGE_INTENSITY.value @@ -5667,7 +5448,7 @@ def handle( ).to_wire_mapping() -class NapariIntensityWindowControlMessageAction(NapariMountedRouteControlMessageAction): +class NapariIntensityWindowControlAction(NapariMountedRouteControlAction): """Apply one percentile window derived from native routed image payloads.""" message_type = ViewerControlMessageType.APPLY_INTENSITY_WINDOW.value @@ -5871,8 +5652,8 @@ def changes_viewer_state(self, server: "NapariViewerServer") -> bool: ) -class NapariNavigationControlMessageAction( - NapariResidentFrameMutationControlMessageAction, NapariMountedRouteControlMessageAction, +class NapariNavigationControlAction( + NapariResidentFrameMutationControlAction, NapariMountedRouteControlAction, ): """Registered action that selects viewer layers and semantic axis indices.""" @@ -5975,7 +5756,7 @@ def prepare( server, layer, request, viewer_order[-2:] ) if request.data_index is not None: - NapariResultElementSelectionAuthority.require_data_index( + NapariResultElementSelections.require_data_index( layer, request.data_index, ) @@ -6141,7 +5922,7 @@ def result_element_axis_indices( ) -> dict[str, int]: """Derive one result element's route-local slice from native geometry.""" - NapariResultElementSelectionAuthority.require_data_index(layer, data_index) + NapariResultElementSelections.require_data_index(layer, data_index) dimension_state = server.layer_route_state.dimension_state_for(route_key) if not dimension_state.axis_labels: return {} @@ -6260,7 +6041,7 @@ def _validate_local_axis_index( ) -class NapariLayerRetirementControlMessageAction(NapariResidentFrameMutationControlMessageAction): +class NapariLayerRetirementControlAction(NapariResidentFrameMutationControlAction): """Qt-owned registered action; the coordinator owns the retirement recipe.""" message_type = OpenHCSViewerControlMessageType.RETIRE_LAYERS.value @@ -6271,14 +6052,14 @@ def handle(self, server, message) -> dict[str, object]: if not isinstance(request, ViewerWindowLayerRetirementRequest): raise TypeError("Retirement requires ViewerWindowLayerRetirementRequest.") retired = _NAPARI_COMPONENT_DISPLAY_COORDINATOR.retire_layers(server, request) - receipt = ViewerLayerRetirementReceipt( + record = ViewerLayerRetirementResult( applied=True, retired_route_keys=retired, remaining_route_keys=tuple(server.layer_route_state.layers), ) response = ViewerControlReplyHeader( ViewerProtocolStatus.SUCCESS, response_type="layer_retirement_ack", ).to_wire_mapping() - response[ViewerControlField.RETIREMENT.value] = receipt.to_wire_mapping() + response[ViewerControlField.RETIREMENT.value] = record.to_wire_mapping() return response except Exception as error: return ViewerControlReplyHeader( @@ -6287,7 +6068,7 @@ def handle(self, server, message) -> dict[str, object]: ).to_wire_mapping() -class NapariLayerIsolationControlMessageAction(NapariResidentFrameMutationControlMessageAction): +class NapariLayerIsolationControlAction(NapariResidentFrameMutationControlAction): """Apply one atomic visibility and selection projection to mounted layers.""" message_type = ViewerControlMessageType.ISOLATE_LAYERS.value @@ -6367,7 +6148,7 @@ def _apply( if missing_route_keys: return 0, missing_route_keys - navigation_action = NapariNavigationControlMessageAction() + navigation_action = NapariNavigationControlAction() prepared_navigations = tuple( navigation_action.prepare(server, request.navigation_for(route_key)) for route_key, _ in mounted_layers @@ -6385,8 +6166,8 @@ def _apply( return changed_route_count, () -class NapariScreenshotControlMessageAction( - NapariAppliedFrameControlMessageAction, ViewerWindowSnapshotService +class NapariScreenshotControlAction( + NapariAppliedFrameControlAction, ViewerWindowSnapshotService ): """Registered action that captures the Napari Qt window.""" @@ -6404,12 +6185,12 @@ def handle( server: "NapariViewerServer", message: Mapping[str, object], ) -> dict[str, object]: - """Snapshot capture requires the admitted local request budget.""" + """Snapshot capture requires the accepted local request budget.""" raise RuntimeError("Snapshots require accepted-request dispatch, including immediate capture.") def dispatch_applied_frame(self, server, request: NapariAcceptedControlRequest) -> None: # Bind the original native response inputs and their source objects at - # capture admission. A later, valid but different frame is not this one. + # capture acceptance. A later, valid but different frame is not this one. viewer = server.require_viewer() native_frame = tuple( (layer, server.display_pipeline.native_data_objects(layer), layer._slice_input, layer._last_slice_id) @@ -6425,9 +6206,9 @@ def dispatch_applied_frame(self, server, request: NapariAcceptedControlRequest) partial(request.fail_observation, server), ) - def validate_admission(self, request: NapariAcceptedControlRequest) -> None: + def validate_request(self, request: NapariAcceptedControlRequest) -> None: self.capture_spec(request.message) - super().validate_admission(request) + super().validate_request(request) @staticmethod def capture_spec(message): @@ -6448,7 +6229,7 @@ def capture_spec(message): @staticmethod def snapshot_descriptor(server) -> ViewerWindowDescriptor: return ViewerWindowDescriptor( - viewer_type=ViewerType.NAPARI, title=server.napari_window_title + viewer_type=NapariViewer.viewer_type(), title=server.napari_window_title ) @classmethod @@ -6465,7 +6246,7 @@ def snapshot_request(cls, server, message, operation_deadline: OperationDeadline return QtWindowSnapshotRequest( widget=server.viewer.window.qt_viewer.window(), capture=capture_spec, - subject_id=f"{ViewerType.NAPARI.wire_value}_{server.port}", + subject_id=f"{NapariViewer.wire_value}_{server.port}", title=server.napari_window_title, operation_deadline=operation_deadline, render_owner=OpenGLWidgetSnapshotRenderOwner( @@ -6499,27 +6280,6 @@ def _native_reply(server, response, dimensions): return response -class NapariUnknownControlMessageAction(NapariControlMessageAction): - """Return a protocol error for an unregistered control message type.""" - - message_type = None - - def handle( - self, - server: "NapariViewerServer", - message: Mapping[str, object], - ) -> dict[str, object]: - del server - requested_type = message.get(ViewerControlResponseField.TYPE.value) - return ViewerControlReplyPayload( - ViewerControlReplyHeader( - ViewerProtocolStatus.ERROR, - response_type=ResponseType.ERROR.value, - message=f"Unsupported Napari control message: {requested_type!r}.", - ) - ).to_wire_mapping() - - class NapariStreamMessageHandler(NapariMessageTypeBase, metaclass=AutoRegisterMeta): """Registered handler for one Napari stream message type.""" @@ -6739,11 +6499,11 @@ def _response_payload(self, request_payload: bytes) -> bytes: if msg_type == ControlMessageType.PING.value: return self.server.control_response_payload(message) try: - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( msg_type if isinstance(msg_type, str) else None ) request = NapariAcceptedControlRequest.from_wire_mapping(message) - action.validate_admission(request) + action.validate_request(request) transport_response = action.transport_thread_response(self.server, request.message) except Exception as error: return self.server.serialize_control_response( @@ -6756,7 +6516,7 @@ def _response_payload(self, request_payload: bytes) -> bytes: return request.observe(self.server, self._stop_event) -class NapariViewerServer(OpenHCSViewerServerABC): +class NapariViewerServer(ViewerServerPort, OpenHCSViewerServerABC): """ ZMQ server for Napari viewer that receives images from clients. @@ -6764,17 +6524,35 @@ class NapariViewerServer(OpenHCSViewerServerABC): Uses a REP socket so each streamed payload retains acknowledgement semantics. """ - _server_type = ViewerType.NAPARI.wire_value + _server_type = NapariViewer.wire_value - def _create_pong_response(self): - """Advertise the lifecycle actions registered by this viewer server.""" + control_actions = NapariControlAction + viewer_display_name = "Napari" - return replace( - super()._create_pong_response(), - control_capabilities=( - NapariControlMessageAction.endpoint_control_capabilities() - ), - ) + def shut_down_after_reply(self) -> None: + logger.info("🔬 NAPARI SERVER: shutdown requested, closing viewer") + self.request_shutdown() + if self.viewer is not None: + from qtpy import QtCore + + QtCore.QTimer.singleShot(100, self.viewer.close) + + def settle_progress(self) -> ViewerSettleProgress: + return self.display_pipeline.settlement_progress() + + def settle_progress_on_transport_thread(self) -> ViewerSettleProgress: + with self.layer_route_state.mutation_boundary(): + return self.layer_route_state.request_settlement().progress() + + def settle_failure_message(self) -> str | None: + return self.layer_route_state.update_failure_message() + + def settle_unavailable_message(self) -> str | None: + if self.transport_failure is not None: + return f"Viewer transport failed before settlement: {self.transport_failure}" + if self.viewer is None: + return "Napari viewer is not available." + return None def __init__(self, request: NapariViewerServerRequest): """ @@ -6787,7 +6565,7 @@ def __init__(self, request: NapariViewerServerRequest): # REP socket forces workers to wait for acknowledgment before closing shared memory super().__init__( request.port, - viewer_type=ViewerType.NAPARI.wire_value, + viewer_type=NapariViewer.wire_value, host=request.process_launch.listen_host, log_file_path=request.log_file_path, data_socket_type=zmq.REP, @@ -6980,7 +6758,7 @@ def display_layer_batch( component_names_metadata=component_names_metadata, ) - def clear_accumulated_stream_state(self) -> None: + def clear_stream_state(self) -> None: """Cancel intake work without orphaning inspectable mounted payloads. A pipeline execution is not a native-layer removal. Mounted raw and @@ -7014,26 +6792,6 @@ def clear_accumulated_stream_state(self) -> None: debounce_policy=self.layer_batch_processor_debounce_policy, ) - def handle_control_message( - self, - message: Mapping[str, object], - ) -> dict[str, object]: - """ - Handle control messages beyond ping/pong. - - Supported message types: - - shutdown: Graceful shutdown (closes viewer) - - force_shutdown: Force shutdown (same as shutdown for Napari) - - clear_state: Clear accumulated component groups (for new pipeline runs) - """ - msg_type = message.get(ViewerControlResponseField.TYPE.value) - if not isinstance(msg_type, str): - msg_type = None - return NapariControlMessageAction.for_message_type(msg_type).handle( - self, - message, - ) - def display_image(self, image_data: np.ndarray, metadata: ComponentMap) -> None: """Display a single image payload (best-effort helper).""" image_info = { @@ -7049,8 +6807,8 @@ def display_image(self, image_data: np.ndarray, metadata: ComponentMap) -> None: } self._process_single_image( image_info, - ViewerComponentAxisSemanticsAuthority.empty(), - NapariDisplayConfig(), + ViewerComponentAxisSemanticsFactory.empty(), + NapariDisplaySettings(), ) def accept_stream_message(self, message: bytes) -> NapariStreamMessageReply: @@ -7071,11 +6829,11 @@ def accept_stream_message(self, message: bytes) -> NapariStreamMessageReply: accepted_batch = NapariStreamMessageHandler.for_message_type( msg_type ).accept(self, data) - # Receipt certifies ownership, not display completion. Invalidate a + # Record certifies ownership, not display completion. Invalidate a # prior terminal settlement before acknowledging this new work. with self.layer_route_state.mutation_boundary(): self.layer_route_state.reset_settlement(accepting_stream=True) - self.layer_route_state.admit_settlement(requested=False) + self.layer_route_state.request_settlement(requested=False) self.accepted_stream_batches.put(accepted_batch) return NapariStreamMessageReply.success(msg_type) except Exception as error: @@ -7121,7 +6879,7 @@ def _accept_single_image( self, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, - display_config: NapariDisplayConfig, + display_config: NapariDisplaySettings, ) -> NapariAcceptedStreamItem: """Materialize one payload without reading or mutating Qt state.""" @@ -7137,7 +6895,7 @@ def _process_single_image( self, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, - display_config: NapariDisplayConfig, + display_config: NapariDisplaySettings, ) -> None: """Materialize and display one direct payload on the Qt thread.""" diff --git a/openhcs/runtime/viewer_component_system.py b/openhcs/runtime/viewer_component_system.py index 8e070ac986..2325870642 100644 --- a/openhcs/runtime/viewer_component_system.py +++ b/openhcs/runtime/viewer_component_system.py @@ -19,23 +19,20 @@ ViewerBatchContextWireField, ViewerBatchMessageType, ViewerBatchWireField, - ViewerComponentMode, ViewerDisplayConfigWireField, ViewerWireMapping, ViewerWireValue, ) from polystore.streaming.viewer_transport import ViewerDisplayConfigABC -from openhcs.runtime.viewer_protocol import ViewerComponentValueOrdering -from openhcs.core.axes import ( - AxisFamily, - AxisRoleKeyedStrategyMixin, - ColourAxis, - OrdinalValued, - PartitionAxis, - StackAxis, - TimeAxis, +from openhcs.core.axes import PartitionAxis +from openhcs.runtime.viewer_display import ( + DeclaredAxes, + FijiSlots, + NapariSlots, + ViewerSlot, ) +from openhcs.runtime.viewer_protocol import ViewerComponentValueOrdering ComponentValue: TypeAlias = str | int | float | bool | tuple | None ComponentWireValue: TypeAlias = ComponentValue | Sequence[ComponentValue] @@ -94,72 +91,6 @@ def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict): return super().__new__(mcs, name, bases, attrs) -class ViewerAxisLabelStrategy(AxisRoleKeyedStrategyMixin): - """Viewer label spelling owned by one axis role. - - Axes whose roles declare no leaf use their boundary name. - """ - - abbreviation: ClassVar[str | None] = None - - @classmethod - def for_component(cls, component: str) -> type["ViewerAxisLabelStrategy"]: - family = AxisFamily.active() - if component not in family.names(): - return cls - axis = family.named(component) - matches = tuple( - strategy - for strategy in cls.role_strategy_types() - if issubclass(axis, strategy.implements_role) - ) - if len(matches) > 1: - raise LookupError( - f"Axis {axis!r} has several viewer label roles: " - f"{[strategy.__qualname__ for strategy in matches]}." - ) - return matches[0] if matches else cls - - @classmethod - def abbreviate(cls, component: str) -> str: - if cls.abbreviation is not None: - return cls.abbreviation - if component in AxisFamily.active().names(): - return component.title() - return component - - @classmethod - def named_label(cls, component: str, value: ComponentValue, name: str) -> str: - return f"{component.title()} {value}: {name}" - - -class ColourAxisViewerLabel(ViewerAxisLabelStrategy): - implements_role = ColourAxis - abbreviation = "Ch" - - @classmethod - def named_label(cls, component: str, value: ComponentValue, name: str) -> str: - return f"{cls.abbreviation}{value}: {name}" - - -class StackAxisViewerLabel(ViewerAxisLabelStrategy): - implements_role = StackAxis - abbreviation = "Z" - - -class TimeAxisViewerLabel(ViewerAxisLabelStrategy): - implements_role = TimeAxis - abbreviation = "T" - - -class PartitionAxisViewerLabel(ViewerAxisLabelStrategy): - implements_role = PartitionAxis - - @classmethod - def named_label(cls, component: str, value: ComponentValue, name: str) -> str: - return str(name) - - class ViewerStreamingDataTypeHandler( ABC, Generic[HandlerRequestT], @@ -229,6 +160,9 @@ def layout(self) -> "ViewerComponentLayout": return ViewerComponentLayout.from_parts( component_modes=self.object_display_config.component_modes(), component_order=self.object_display_config.COMPONENT_ORDER, + declared_axes=DeclaredAxes.from_display_payload( + self.object_display_config.display_payload_extra() + ), ) @@ -275,6 +209,7 @@ def layout( return ViewerComponentLayout.from_parts( component_modes=component_modes, component_order=component_order, + declared_axes=DeclaredAxes.from_display_payload(mapping_display_config), ) @staticmethod @@ -294,7 +229,14 @@ def _required_value( @dataclass(frozen=True, slots=True) class ViewerComponentLayout(ViewerBatchDisplayPayload): - """Normalized component-mode layout shared by viewer receivers.""" + """The slot of each declared axis, and the axes one stream addresses. + + ``component_modes`` gives every declared axis its slot wire value; + ``component_order`` is the declared-order subset this stream addresses. + Both are checked against the stream's own ``declared_axes``. + """ + + declared_axes: DeclaredAxes = field(default_factory=DeclaredAxes) @classmethod def from_parts( @@ -302,13 +244,24 @@ def from_parts( *, component_modes: Mapping[str, DisplayModeValue], component_order: Sequence[DisplayComponentName], + declared_axes: DeclaredAxes, ) -> "ViewerComponentLayout": order = tuple(str(component) for component in component_order) modes = { - component: cls._mode_value(component_modes[component]) - for component in order + str(component): cls._mode_value(mode) + for component, mode in component_modes.items() } - return cls(component_modes=modes, component_order=order) + declared_axes.require_names(tuple(modes), context="Viewer display modes") + unplaced = tuple(component for component in order if component not in modes) + if unplaced: + raise ValueError( + f"Viewer display order {order!r} has axes without a slot: {unplaced!r}." + ) + return cls(component_modes=modes, component_order=order, declared_axes=declared_axes) + + @classmethod + def empty(cls) -> "ViewerComponentLayout": + return cls(component_modes={}, component_order=()) @staticmethod def _mode_value(mode: DisplayModeValue) -> str: @@ -316,12 +269,49 @@ def _mode_value(mode: DisplayModeValue) -> str: return str(mode.value) return str(mode) + def components_in(self, slot: type[ViewerSlot]) -> tuple[str, ...]: + """Addressed axes placed in ``slot``, in declared order.""" + + return tuple( + component + for component in self.component_order + if self.component_modes[component] == slot.wire_value + ) + + def names_with_role(self, role) -> tuple[str, ...]: + return self.declared_axes.names_with_role(role) + + def with_modes(self, component_modes: Mapping[str, DisplayModeValue]) -> "ViewerComponentLayout": + return self.from_parts( + component_modes=component_modes, + component_order=self.component_order, + declared_axes=self.declared_axes, + ) + + def with_order(self, component_order: Sequence[str]) -> "ViewerComponentLayout": + return self.from_parts( + component_modes=self.component_modes, + component_order=component_order, + declared_axes=self.declared_axes, + ) + + def declared_layout(self) -> "ViewerComponentLayout": + """This layout addressing every declared axis it places.""" + + return self.with_order( + tuple(name for name in self.declared_axes.names() if name in self.component_modes) + ) + + def normalizer(self) -> "ViewerComponentMetadataNormalizer": + return ViewerComponentMetadataNormalizer(self.declared_axes) + def group_window_sources(self, sources): - from polystore.streaming.receivers.core import group_items_by_component_modes + from polystore.streaming.receivers.core import group_items_into_windows - return group_items_by_component_modes( + return group_items_into_windows( sources, display_layout=self, + window_components=self.components_in(FijiSlots.Window), ) def group_window_payloads( @@ -359,20 +349,21 @@ def with_shared_stack_axes( stack_axes = { component for layout in (self, *layouts) - for component in layout.components_for_mode(ViewerComponentMode.STACK) + for component in layout.components_in(NapariSlots.Stack) } - return self.from_parts( - component_modes={ + return self.with_modes( + { **self.component_modes, - **{component: ViewerComponentMode.STACK for component in stack_axes}, - }, - component_order=self.component_order, + **{component: NapariSlots.Stack.wire_value for component in stack_axes}, + } ) -@dataclass(slots=True) +@dataclass(frozen=True, slots=True) class ViewerComponentMetadataNormalizer: - """Normalize component metadata before viewer coordinate indexing.""" + """Normalize component metadata by the stream's declared axes.""" + + declared_axes: DeclaredAxes def normalize(self, components: ComponentMap) -> ComponentMap: return { @@ -381,16 +372,7 @@ def normalize(self, components: ComponentMap) -> ComponentMap: } def normalize_value(self, component: str, value: ComponentValue) -> ComponentValue: - family = AxisFamily.active() - if component not in family.names() or not issubclass( - family.named(component), OrdinalValued - ): - return value - if isinstance(value, str): - stripped = value.strip() - if stripped and stripped.lstrip("+-").isdigit(): - return int(stripped) - return value + return self.declared_axes.normalize_value(component, value) @dataclass(frozen=True, slots=True) @@ -438,10 +420,8 @@ def from_component_metadata( *, component_layout: ViewerComponentLayout, metadata_items: Sequence[Mapping[str, ComponentValue] | None], - normalizer: ViewerComponentMetadataNormalizer | None = None, ) -> "ViewerComponentValueDomainPayload": - if normalizer is None: - normalizer = ViewerComponentMetadataNormalizer() + normalizer = component_layout.normalizer() values_by_component: dict[str, list[ComponentValue]] = { component: [] for component in component_layout.component_order } @@ -467,15 +447,18 @@ def from_wire_mapping( cls, payload: Mapping[str, Sequence[ComponentWireValue]], *, + declared_axes: DeclaredAxes, context: str, ) -> "ViewerComponentValueDomainPayload": - """Project observed membership with its established sort/dedup contract.""" + """Decode observed values, sorted and without duplicates.""" return cls( tuple( ViewerComponentValueDomainEntry.from_values( entry.component, entry.values ) - for entry in cls._wire_entries(payload, context=context) + for entry in cls._wire_entries( + payload, declared_axes=declared_axes, context=context + ) ) ) @@ -484,6 +467,7 @@ def from_ordered_wire_mapping( cls, payload: Mapping[str, Sequence[ComponentWireValue]], *, + declared_axes: DeclaredAxes, context: str, ) -> "ViewerComponentValueDomainPayload": """Decode an explicitly ordered pixel-plane coordinate declaration.""" @@ -492,7 +476,9 @@ def from_ordered_wire_mapping( ViewerComponentValueDomainEntry.from_declared_values( entry.component, entry.values ) - for entry in cls._wire_entries(payload, context=context) + for entry in cls._wire_entries( + payload, declared_axes=declared_axes, context=context + ) ) ) @@ -501,10 +487,11 @@ def _wire_entries( cls, payload: Mapping[str, Sequence[ComponentWireValue]], *, + declared_axes: DeclaredAxes, context: str, ) -> tuple[ViewerComponentValueDomainEntry, ...]: - """Parse and normalize external coordinates once for either projection.""" - normalizer = ViewerComponentMetadataNormalizer() + """Parse and normalize external coordinates once for either decoder.""" + normalizer = ViewerComponentMetadataNormalizer(declared_axes) entries = [] for component, raw_values in payload.items(): if isinstance(raw_values, str) or not isinstance(raw_values, Sequence): @@ -584,7 +571,7 @@ def parse(value: ComponentWireValue, *, context: str) -> ComponentValue: class ViewerComponentMetadataPayload: - """Parse component metadata mappings into canonical viewer component values.""" + """Parse component metadata mappings into viewer component values.""" @classmethod def component_map( @@ -676,30 +663,24 @@ def require_fields(self, fields: Sequence[ViewerBatchField]) -> None: def required_component_names_metadata( self, *, + declared_axes: DeclaredAxes, context: str, ) -> ViewerComponentNameMetadata: return ViewerComponentNameMetadata.from_wire_mapping( self.required_mapping(ViewerBatchWireField.COMPONENT_NAMES_METADATA), + declared_axes=declared_axes, context=context, ) def optional_component_names_metadata( self, *, + declared_axes: DeclaredAxes, context: str, ) -> ViewerComponentNameMetadata: return ViewerComponentNameMetadata.from_wire_mapping( self.optional_mapping(ViewerBatchWireField.COMPONENT_NAMES_METADATA), - context=context, - ) - - def component_value_domain( - self, - *, - context: str, - ) -> ViewerComponentValueDomainPayload: - return ViewerComponentValueDomainPayload.from_wire_mapping( - self.required_mapping(ViewerBatchWireField.COMPONENT_VALUE_DOMAIN), + declared_axes=declared_axes, context=context, ) @@ -709,9 +690,14 @@ def component_axis_semantics( *, context: str, ) -> "ViewerComponentAxisSemantics": - return ViewerComponentAxisSemanticsAuthority.from_display_config( - display_config, - self.component_value_domain(context=context), + layout = display_config.layout() + return ViewerComponentAxisSemantics( + entries=ViewerComponentValueDomainPayload.from_wire_mapping( + self.required_mapping(ViewerBatchWireField.COMPONENT_VALUE_DOMAIN), + declared_axes=layout.declared_axes, + context=context, + ).entries, + layout=layout, ) @@ -830,6 +816,7 @@ class ViewerComponentNameMetadata(ComponentMetadataPresentationABC[ComponentValu store: ViewerComponentNameMetadataStore = field( default_factory=ViewerComponentNameMetadataStore ) + declared_axes: DeclaredAxes = field(default_factory=DeclaredAxes) @classmethod def empty(cls) -> "ViewerComponentNameMetadata": @@ -840,9 +827,10 @@ def from_wire_mapping( cls, payload: ComponentNameMetadataWireMapping, *, + declared_axes: DeclaredAxes, context: str, ) -> "ViewerComponentNameMetadata": - metadata = cls.empty() + metadata = cls(declared_axes=declared_axes) metadata.store.merge_mapping( payload, context=context, @@ -851,6 +839,13 @@ def from_wire_mapping( def merge(self, incoming: "ViewerComponentNameMetadata") -> None: self.store.merge_store(incoming.store) + self.declare(incoming.declared_axes) + + def declare(self, declared_axes: DeclaredAxes) -> None: + """Label components by the latest stream's axis declarations.""" + + if declared_axes: + object.__setattr__(self, "declared_axes", declared_axes) def clear(self) -> None: self.store.clear() @@ -868,12 +863,14 @@ def display_name(self, component: str, value: ComponentValue) -> str | None: return str(name) def abbreviation(self, component: str) -> str: - return ViewerAxisLabelStrategy.for_component(component).abbreviate(component) + """The axis's declared label; an undeclared component keeps its name.""" + + return self.declared_axes.label(component) def named_axis_label(self, component: str, value: ComponentValue, name: str) -> str: - return ViewerAxisLabelStrategy.for_component(component).named_label( - component, value, name - ) + if component in self.declared_axes.names_with_role(PartitionAxis): + return str(name) + return f"{self.abbreviation(component)} {value}: {name}" def compact_tuple_labels( self, @@ -943,13 +940,7 @@ def for_display_layout( ) return ViewerComponentAxisSemantics( entries=self.entries, - layout=ViewerComponentLayout.from_parts( - component_modes={ - component: layout.component_modes[component] - for component in component_order - }, - component_order=component_order, - ), + layout=layout.with_order(component_order), ) @property @@ -962,8 +953,8 @@ def component_modes(self) -> ComponentModeMap: -class ViewerComponentAxisSemanticsAuthority: - """Build component-axis semantics from external config/domain inputs.""" +class ViewerComponentAxisSemanticsFactory: + """Build component-axis semantics from a display config and its values.""" @staticmethod def from_display_config( @@ -975,30 +966,9 @@ def from_display_config( layout=display_config.layout(), ) - @staticmethod - def from_display_config_and_metadata( - *, - display_config: ViewerDisplayConfigInput, - metadata_items: ComponentMetadataItems, - ) -> ViewerComponentAxisSemantics: - layout = display_config.layout() - return ViewerComponentAxisSemantics( - entries=ViewerComponentValueDomainPayload.from_component_metadata( - component_layout=layout, - metadata_items=metadata_items, - ).entries, - layout=layout, - ) - @staticmethod def empty() -> ViewerComponentAxisSemantics: - return ViewerComponentAxisSemantics( - entries=(), - layout=ViewerComponentLayout.from_parts( - component_modes={}, - component_order=(), - ), - ) + return ViewerComponentAxisSemantics(entries=(), layout=ViewerComponentLayout.empty()) @dataclass(frozen=True, slots=True, kw_only=True) @@ -1271,7 +1241,7 @@ def coordinate_index( """Return the projected viewer coordinate for one component address.""" self.require_matching_scalar_components(components, context=context) return tuple( - ViewerComponentCoordinateAuthority.index( + ViewerComponentCoordinates.index( components=components, component_values=self.component_values, component=component, @@ -1298,7 +1268,7 @@ def expected_indices(self) -> set[tuple[int, ...]]: return {()} return { tuple( - ViewerComponentCoordinateAuthority.value_index( + ViewerComponentCoordinates.value_index( value=value, component_values=self.component_values, component=component, @@ -1326,7 +1296,7 @@ def require_matching_scalar_components( f"Collapsed component {component!r} must have one value, " f"got {values!r}." ) - value = ViewerComponentCoordinateAuthority.required_value( + value = ViewerComponentCoordinates.required_value( components, component, context=context, @@ -1492,7 +1462,7 @@ def projection_steps(self) -> tuple[ViewerLayerAxisProjectionStep, ...]: ) -class ViewerLayerAxisProjectionRequestAuthority: +class ViewerLayerAxisProjectionRequestBuilder: """Build viewer-axis projection requests from component-axis semantics.""" @staticmethod @@ -1507,8 +1477,8 @@ def from_component_axis_semantics( publish: bool = True, viewer_component_values: ComponentValues | None = None, ) -> ViewerLayerAxisProjectionRequest: - axis_components = component_axis_semantics.layout.components_for_mode( - ViewerComponentMode.STACK + axis_components = component_axis_semantics.layout.components_in( + NapariSlots.Stack ) domain = ( route_value_tracker.domain_for(route_key, axis_components) @@ -1533,7 +1503,7 @@ def from_component_axis_semantics( return ViewerLayerAxisProjectionRequest.from_component_values( projected_axis_components=axis_components, route_component_coordinates=( - ViewerLayerAxisProjectionRequestAuthority.route_coordinates( + ViewerLayerAxisProjectionRequestBuilder.route_coordinates( axis_components=axis_components, layer_items=layer_items, aggregate_component_values=aggregate_component_values, @@ -1585,7 +1555,7 @@ def route_coordinates( ), } coordinates.add( - ViewerComponentCoordinateAuthority.value_tuple( + ViewerComponentCoordinates.value_tuple( components, axis_components, context="viewer routed item", @@ -1633,7 +1603,7 @@ def project( ) -class ViewerComponentCoordinateAuthority: +class ViewerComponentCoordinates: """Fail-loud component coordinate lookup shared by viewer backends.""" @staticmethod @@ -1723,7 +1693,7 @@ def value_tuple( ) -class ViewerDimensionValueAuthority: +class ViewerDimensionValues: """Collect and index tuple-valued dimensions for viewer coordinate systems.""" @classmethod @@ -1761,7 +1731,7 @@ def value_tuple( metadata: Mapping[str, ComponentValue], components: Sequence[str], ) -> tuple: - return ViewerComponentCoordinateAuthority.value_tuple( + return ViewerComponentCoordinates.value_tuple( metadata, components, context="Viewer dimension metadata", diff --git a/openhcs/runtime/viewer_control_actions.py b/openhcs/runtime/viewer_control_actions.py new file mode 100644 index 0000000000..056b735498 --- /dev/null +++ b/openhcs/runtime/viewer_control_actions.py @@ -0,0 +1,319 @@ +"""One control-action family for every viewer server. + +Each viewer declares a family root (``ViewerControlAction`` subclass with +``family_root=True``). The root gets its own registry keyed by the control +message wire type, and inherits every lifecycle action declared once here: +the metaclass composes each :class:`LifecycleControlAction` mixin with the +root, so a viewer cannot forget shutdown, clear-state, process-launch or +settle. Messages no leaf registers answer ERROR through one shared +:class:`UnknownControlAction`. Pong capabilities derive from the registry. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import replace +from typing import TYPE_CHECKING, ClassVar + +from zmqruntime.messages import ( + ControlMessageType, + EndpointControlCapability, + ResponseType, +) +from zmqruntime.viewer_protocol import ( + ViewerControlMessageType, + ViewerControlReplyHeader, + ViewerControlReplyPayload, + ViewerControlResponseField, + ViewerProtocolStatus, +) + +from openhcs.runtime.viewer_protocol import ( + OpenHCSViewerControlMessageType, + ViewerControlField, + ViewerSettlePhase, + ViewerSettleProgress, +) + +if TYPE_CHECKING: + from openhcs.core.streaming_config_factory import ViewerProcessLaunchConfig + +ControlReply = dict[str, object] + + +class ViewerServerPort(ABC): + """What the shared lifecycle actions need from a viewer server.""" + + control_actions: ClassVar[type["ViewerControlAction"]] + viewer_display_name: ClassVar[str] + process_launch: "ViewerProcessLaunchConfig" + + def _create_pong_response(self): + """Advertise the lifecycle actions this viewer's registry handles.""" + + return replace( + super()._create_pong_response(), + control_capabilities=self.control_actions.control_capabilities(), + ) + + @abstractmethod + def shut_down_after_reply(self) -> None: + """Stop the server once the shutdown acknowledgement is sent.""" + + @abstractmethod + def clear_stream_state(self) -> None: + """Forget accumulated stream state without shutting down.""" + + @abstractmethod + def settle_progress(self) -> ViewerSettleProgress: + """Queued and active display-work progress.""" + + @abstractmethod + def settle_failure_message(self) -> str | None: + """The terminal display failure, if one happened.""" + + def settle_unavailable_message(self) -> str | None: + """Why settlement cannot be observed at all, if it cannot.""" + + return None + + def settle_progress_on_transport_thread(self) -> ViewerSettleProgress: + """Settlement progress readable without the viewer's UI thread.""" + + return self.settle_progress() + + def handle_control_message(self, message: Mapping[str, object]) -> ControlReply: + """Answer one control message through this viewer's action registry.""" + + return self.control_actions.for_message(message).handle(self, message) + + +def control_reply( + status: ViewerProtocolStatus, + *, + response_type: str | None = None, + message: str | None = None, + fields: Mapping[str, object] | None = None, + payload: object | None = None, +) -> ControlReply: + """Spell one viewer control reply on the wire.""" + + return ViewerControlReplyPayload( + ViewerControlReplyHeader(status, response_type=response_type, message=message), + fields=dict(fields or {}), + payload=payload, + ).to_wire_mapping() + + +class ViewerControlAction(ABC): + """Behaviour for one control message type on one viewer.""" + + message_type: ClassVar[str | None] = None + _registry: ClassVar[dict[str, type["ViewerControlAction"]]] + _unknown: ClassVar[type["ViewerControlAction"]] + + def __init_subclass__(cls, *, family_root: bool = False, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if family_root: + cls._registry = {} + for lifecycle in LifecycleControlAction.declared(): + type( + f"{cls.__name__.removesuffix('ControlAction')}{lifecycle.__name__}", + (lifecycle, cls), + {"__module__": cls.__module__, "message_type": lifecycle.message_type}, + ) + cls._unknown = type( + f"{cls.__name__.removesuffix('ControlAction')}UnknownControlAction", + (UnknownControlAction, cls), + {"__module__": cls.__module__, "message_type": None}, + ) + return + message_type = cls.__dict__.get("message_type") + if message_type is None: + return + existing = cls._registry.get(message_type) + if existing is not None and not issubclass(cls, existing): + raise TypeError( + f"{cls.__qualname__} and {existing.__qualname__} both handle " + f"control message {message_type!r}." + ) + cls._registry[message_type] = cls + + @classmethod + def registered_message_types(cls) -> frozenset[str]: + return frozenset(cls._registry) + + @classmethod + def for_message_type(cls, message_type: object) -> "ViewerControlAction": + if isinstance(message_type, str) and message_type in cls._registry: + return cls._registry[message_type]() + return cls._unknown() + + @classmethod + def for_message(cls, message: Mapping[str, object]) -> "ViewerControlAction": + return cls.for_message_type(message.get(ViewerControlResponseField.TYPE.value)) + + @classmethod + def control_capabilities(cls) -> frozenset[EndpointControlCapability]: + """Lifecycle capabilities advertised on pong, derived from the registry.""" + + return frozenset( + capability + for capability in EndpointControlCapability + if capability is EndpointControlCapability.PING + or capability.value in cls._registry + ) + + @property + def response_type(self) -> str: + return f"{self.message_type}_ack" + + @abstractmethod + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + """Answer one control message.""" + + def transport_thread_response( + self, server: ViewerServerPort, message: Mapping[str, object] + ) -> ControlReply | None: + """Answer without the viewer's UI thread, or ``None`` to defer to it.""" + + del server, message + return None + + +class LifecycleControlAction(ABC): + """A control action every viewer family inherits.""" + + message_type: ClassVar[str] + _declared: ClassVar[list[type["LifecycleControlAction"]]] = [] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if LifecycleControlAction in cls.__bases__ and "message_type" in cls.__dict__: + LifecycleControlAction._declared.append(cls) + + @staticmethod + def declared() -> tuple[type["LifecycleControlAction"], ...]: + return tuple(LifecycleControlAction._declared) + + +class ShutdownControlAction(LifecycleControlAction): + message_type = ControlMessageType.SHUTDOWN.value + + @property + def response_type(self) -> str: + return ResponseType.SHUTDOWN_ACK.value + + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + del message + server.shut_down_after_reply() + return control_reply( + ViewerProtocolStatus.SUCCESS, + response_type=self.response_type, + message=f"{server.viewer_display_name} viewer shutting down", + ) + + +class ForceShutdownControlAction(ShutdownControlAction, LifecycleControlAction): + message_type = ControlMessageType.FORCE_SHUTDOWN.value + + +class ClearStateControlAction(LifecycleControlAction): + message_type = ViewerControlMessageType.CLEAR_STATE.value + + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + del message + server.clear_stream_state() + return control_reply( + ViewerProtocolStatus.SUCCESS, + response_type=self.response_type, + message=f"{server.viewer_display_name} stream state cleared", + ) + + +class ProcessLaunchControlAction(LifecycleControlAction): + message_type = OpenHCSViewerControlMessageType.PROCESS_LAUNCH.value + + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + del message + return control_reply( + ViewerProtocolStatus.SUCCESS, + response_type=self.response_type, + fields={ + ViewerControlField.PROCESS_LAUNCH.value: ( + server.process_launch.to_wire_mapping() + ) + }, + ) + + def transport_thread_response( + self, server: ViewerServerPort, message: Mapping[str, object] + ) -> ControlReply: + return self.handle(server, message) + + +class SettleControlAction(LifecycleControlAction): + message_type = ViewerControlMessageType.SETTLE.value + + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + del message + return self.reply(server, server.settle_progress) + + def transport_thread_response( + self, server: ViewerServerPort, message: Mapping[str, object] + ) -> ControlReply: + """Observe settlement without waiting for the UI thread to render.""" + + del message + return self.reply(server, server.settle_progress_on_transport_thread) + + def reply(self, server: ViewerServerPort, progress_of) -> ControlReply: + unavailable = server.settle_unavailable_message() + if unavailable is not None: + return control_reply( + ViewerProtocolStatus.ERROR, + response_type=self.response_type, + message=unavailable, + ) + progress = progress_of() + failed = progress.phase is ViewerSettlePhase.FAILED + name = server.viewer_display_name + return control_reply( + ViewerProtocolStatus.ERROR if failed else ViewerProtocolStatus.SUCCESS, + response_type=self.response_type, + message=( + f"{name} viewer settlement failed: {server.settle_failure_message()}" + if failed + else ( + f"{name} viewer settlement progress: " + f"{progress.completed_update_count}/{progress.total_update_count}." + ) + ), + fields=progress.to_wire_mapping(), + ) + + +class UnknownControlAction: + """Shared answer for a message type no leaf of the family handles.""" + + @property + def response_type(self) -> str: + return ResponseType.ERROR.value + + def handle(self, server: ViewerServerPort, message: Mapping[str, object]) -> ControlReply: + requested = message.get(ViewerControlResponseField.TYPE.value) + return control_reply( + ViewerProtocolStatus.ERROR, + response_type=self.response_type, + message=( + f"Unsupported {server.viewer_display_name} control message: " + f"{requested!r}." + ), + ) + + def transport_thread_response( + self, server: ViewerServerPort, message: Mapping[str, object] + ) -> ControlReply: + return self.handle(server, message) + diff --git a/openhcs/runtime/viewer_controls.py b/openhcs/runtime/viewer_controls.py index 43c5693a2f..3d0c0b8f7e 100644 --- a/openhcs/runtime/viewer_controls.py +++ b/openhcs/runtime/viewer_controls.py @@ -65,7 +65,7 @@ def selected_items( ) -class ViewerResultElementCoordinateAuthority: +class ViewerResultElementCoordinates: """Derive a selected element's slice from its native N-D coordinates.""" @classmethod @@ -211,7 +211,7 @@ def _coordinate_value(value: object, *, axis_label: str) -> float: return numeric_value -class ViewerPointCoordinateAuthority(ViewerResultElementCoordinateAuthority): +class ViewerPointCoordinates(ViewerResultElementCoordinates): """Navigate to the nearest spatial slice without rounding stored geometry.""" @classmethod @@ -662,7 +662,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class ViewerMeasurementCoordinates: - """Audit projection from the admitted item and native coordinate owners.""" + """Audit projection from the accepted item and native coordinate owners.""" route_key: str source_path: str @@ -733,7 +733,7 @@ class ViewerPolylineMeasurement: reduction: str = "mean across centred perpendicular band" sampling: str = ( "ceil(segment length+1) endpoint-inclusive; repeated junction uses preceding segment; " - "nearest(0)/bilinear(1), constant exterior=0 with full band admitted inside source" + "nearest(0)/bilinear(1), constant exterior=0 with full band accepted inside source" ) data_length_unit: str = "pixel" intensity_unit: str = "raw source value" diff --git a/openhcs/runtime/viewer_display.py b/openhcs/runtime/viewer_display.py new file mode 100644 index 0000000000..46792d72bc --- /dev/null +++ b/openhcs/runtime/viewer_display.py @@ -0,0 +1,421 @@ +"""What a viewer is told about a stream's axes, and where each viewer puts them. + +A stream's display config carries its own :class:`DeclaredAxis` list. The +viewer validates and lays out the stream from those declarations, never from +the axis family active in its own process, so a viewer shows any domain's +axes. Each viewer owns a :class:`ViewerSlotFamily`: the native slots it can put +an axis into, and which roles default to which slot. +""" + +from abc import ABC +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass, field, fields +from enum import Enum +from functools import cache +from typing import ClassVar + +from python_introspect import ( + AnnotatedDataclassValidationMixin, + dataclass_from_mapping, +) +from zmqruntime.config import NonBlankString +from zmqruntime.viewer_protocol import ViewerWireMapping, ViewerWirePayload, ViewerWireValue + +from openhcs.core.axes import ( + Axis, + AxisRole, + AxisValueKind, + ColourAxis, + OrdinalValued, + StackAxis, + TimeAxis, +) + +DECLARED_AXES_FIELD = "declared_axes" +"""Display-config wire field carrying the stream's axis declarations.""" + + +def _declared_kinds(base: type) -> dict[str, type]: + """Role or value-kind classes by wire name, derived from inheritance.""" + + kinds: dict[str, type] = {} + pending = list(base.__subclasses__()) + while pending: + kind = pending.pop() + pending.extend(kind.__subclasses__()) + if not issubclass(kind, Axis): + kinds[kind.__name__] = kind + return kinds + + +def _decode_kind(base: type, name: object, *, context: str) -> type: + kinds = _declared_kinds(base) + if not isinstance(name, str) or name not in kinds: + raise ValueError(f"{context} names unknown {base.__name__} {name!r}.") + return kinds[name] + + +@dataclass(frozen=True, slots=True) +class DeclaredAxis: + """One axis of a stream, as declared by the domain that produced it.""" + + name: str + label: str + roles: tuple[type[AxisRole], ...] + value_kind: type[AxisValueKind] + + @classmethod + def of(cls, axis: type[Axis]) -> "DeclaredAxis": + return cls( + name=axis.name, + label=axis.label, + roles=tuple( + base + for base in axis.__mro__ + if issubclass(base, AxisRole) + and base is not AxisRole + and not issubclass(base, Axis) + ), + value_kind=next( + base + for base in axis.__mro__ + if AxisValueKind in base.__bases__ and not issubclass(base, Axis) + ), + ) + + def has_role(self, role: type[AxisRole]) -> bool: + return any(issubclass(declared, role) for declared in self.roles) + + def normalize_value(self, value: object) -> object: + """Read numeric text of an ordinal axis as its integer value.""" + + if issubclass(self.value_kind, OrdinalValued) and isinstance(value, str): + stripped = value.strip() + if stripped and stripped.lstrip("+-").isdigit(): + return int(stripped) + return value + + def to_wire(self) -> dict[str, ViewerWireValue]: + return { + "name": self.name, + "label": self.label, + "roles": [role.__name__ for role in self.roles], + "value_kind": self.value_kind.__name__, + } + + @classmethod + def from_wire(cls, payload: object) -> "DeclaredAxis": + if not isinstance(payload, Mapping): + raise TypeError("A declared axis must be a mapping.") + name = payload["name"] + label = payload["label"] + roles = payload["roles"] + if not isinstance(name, str) or not name or not isinstance(label, str): + raise TypeError(f"Declared axis needs text name and label, got {payload!r}.") + if isinstance(roles, str) or not isinstance(roles, Sequence): + raise TypeError(f"Declared axis {name!r} roles must be a sequence.") + context = f"Declared axis {name!r}" + return cls( + name=name, + label=label, + roles=tuple(_decode_kind(AxisRole, role, context=context) for role in roles), + value_kind=_decode_kind(AxisValueKind, payload["value_kind"], context=context), + ) + + +@dataclass(frozen=True, slots=True) +class DeclaredAxes: + """A stream's declared axes, in declaration order.""" + + axes: tuple[DeclaredAxis, ...] = () + + def __post_init__(self) -> None: + names = [axis.name for axis in self.axes] + if len(set(names)) != len(names): + raise ValueError(f"Declared axes repeat a name: {names!r}.") + + @classmethod + def of(cls, axes: Sequence[type[Axis]]) -> "DeclaredAxes": + return cls(tuple(DeclaredAxis.of(axis) for axis in axes)) + + @classmethod + def from_wire(cls, payload: object) -> "DeclaredAxes": + if isinstance(payload, str) or not isinstance(payload, Sequence): + raise TypeError(f"{DECLARED_AXES_FIELD!r} must be a sequence of axes.") + return cls(tuple(DeclaredAxis.from_wire(axis) for axis in payload)) + + @classmethod + def from_display_payload(cls, payload: Mapping[str, ViewerWireValue]) -> "DeclaredAxes": + if DECLARED_AXES_FIELD not in payload: + raise ValueError(f"Display config is missing {DECLARED_AXES_FIELD!r}.") + return cls.from_wire(payload[DECLARED_AXES_FIELD]) + + def to_wire(self) -> list[dict[str, ViewerWireValue]]: + return [axis.to_wire() for axis in self.axes] + + def __iter__(self) -> Iterator[DeclaredAxis]: + return iter(self.axes) + + def __bool__(self) -> bool: + return bool(self.axes) + + def names(self) -> tuple[str, ...]: + return tuple(axis.name for axis in self.axes) + + def __contains__(self, name: object) -> bool: + return name in self.names() + + def named(self, name: str) -> DeclaredAxis: + for axis in self.axes: + if axis.name == name: + return axis + raise ValueError(f"{name!r} is not a declared axis; declared: {self.names()!r}.") + + def with_role(self, role: type[AxisRole]) -> tuple[DeclaredAxis, ...]: + return tuple(axis for axis in self.axes if axis.has_role(role)) + + def names_with_role(self, role: type[AxisRole]) -> tuple[str, ...]: + return tuple(axis.name for axis in self.with_role(role)) + + def label(self, name: str) -> str: + """The declared label of an axis; an undeclared component keeps its name.""" + + return self.named(name).label if name in self else name + + def normalize_value(self, name: str, value: object) -> object: + return self.named(name).normalize_value(value) if name in self else value + + def require_names(self, names: Sequence[str], *, context: str) -> None: + undeclared = tuple(name for name in names if name not in self) + if undeclared: + raise ValueError( + f"{context} names undeclared axes {undeclared!r}; " + f"the stream declares {self.names()!r}." + ) + + +# --------------------------------------------------------------------------- +# Native slots +# --------------------------------------------------------------------------- + + +class ViewerSlot(ABC): + """One native place a viewer can put an axis's values.""" + + wire_value: ClassVar[str] + roles: ClassVar[tuple[type[AxisRole], ...]] = () + """Axes with one of these roles default to this slot.""" + separates_values: ClassVar[bool] = False + """Each value of an axis in this slot gets its own native layer or window.""" + + +class ViewerSlotFamily(ABC): + """The slots one viewer offers, declared as nested :class:`ViewerSlot` classes.""" + + slots: ClassVar[tuple[type[ViewerSlot], ...]] = () + default_slot: ClassVar[type[ViewerSlot]] + families: ClassVar[list[type["ViewerSlotFamily"]]] = [] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + ViewerSlotFamily.families.append(cls) + cls.slots = tuple( + value + for key, value in cls.__dict__.items() + if isinstance(value, type) + and issubclass(value, ViewerSlot) + and key == value.__name__ + ) + + @staticmethod + def separating_wire_values() -> frozenset[str]: + """Wire values of every slot, in any viewer, that splits values apart.""" + + return frozenset( + slot.wire_value + for family in ViewerSlotFamily.families + for slot in family.slots + if slot.separates_values + ) + + @classmethod + def slot_for_roles(cls, roles: Sequence[type[AxisRole]]) -> type[ViewerSlot]: + """The first slot claiming one of ``roles``, else the default slot.""" + + for slot in cls.slots: + if any(issubclass(role, claimed) for role in roles for claimed in slot.roles): + return slot + return cls.default_slot + + @classmethod + def slot_for(cls, axis: DeclaredAxis) -> type[ViewerSlot]: + return cls.slot_for_roles(axis.roles) + + @classmethod + def named(cls, wire_value: object) -> type[ViewerSlot]: + for slot in cls.slots: + if slot.wire_value == wire_value: + return slot + raise ValueError( + f"{wire_value!r} is not a {cls.__name__} slot; " + f"slots are {[slot.wire_value for slot in cls.slots]!r}." + ) + + @classmethod + @cache + def choice_enum(cls, name: str, module: str) -> type[Enum]: + """The form-choice view of this family (members named after the slots' wire values).""" + + choices = Enum( + name, + [(slot.wire_value.upper(), slot.wire_value) for slot in cls.slots], + module=module, + ) + choices.__doc__ = cls.__doc__.splitlines()[0] + return choices + + +class NapariSlots(ViewerSlotFamily): + """Napari places each axis on a stacked dims slider or in separate layers.""" + + class Layer(ViewerSlot): + wire_value = "layer" + separates_values = True + + class Stack(ViewerSlot): + wire_value = "stack" + + default_slot = Stack + + +class FijiSlots(ViewerSlotFamily): + """ImageJ hyperstack C, Z and T, or a separate window per value. + + Several axes in one slot combine their values in declared order. + """ + + class Window(ViewerSlot): + wire_value = "window" + separates_values = True + + class HyperstackChannel(ViewerSlot): + wire_value = "channel" + roles = (ColourAxis,) + + class HyperstackSlice(ViewerSlot): + wire_value = "slice" + roles = (StackAxis,) + + class HyperstackFrame(ViewerSlot): + wire_value = "frame" + roles = (TimeAxis,) + + default_slot = HyperstackFrame + + +# --------------------------------------------------------------------------- +# Viewer-specific display settings (the non-axis part of a display config) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ViewerDisplaySettings(AnnotatedDataclassValidationMixin): + """Display settings one viewer reads from a stream's display config. + + Each direct subclass is one viewer's settings: its fields are the wire + keys, and it names the viewer's slot family. + """ + + slot_family: ClassVar[type[ViewerSlotFamily]] + _viewer_settings: ClassVar[list[type["ViewerDisplaySettings"]]] = [] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if ViewerDisplaySettings in cls.__bases__: + ViewerDisplaySettings._viewer_settings.append(cls) + + @classmethod + def settings_type(cls) -> type["ViewerDisplaySettings"]: + """The viewer settings class this class is or inherits.""" + + return next( + base for base in cls.__mro__ if base in ViewerDisplaySettings._viewer_settings + ) + + @classmethod + def from_display_payload(cls, payload: Mapping[str, ViewerWireValue]): + settings_type = cls.settings_type() + names = tuple(declared.name for declared in fields(settings_type)) + missing = tuple(name for name in names if name not in payload) + if missing: + raise ValueError( + f"{settings_type.__name__} display config is missing {missing!r}." + ) + return dataclass_from_mapping( + settings_type, {name: payload[name] for name in names} + ) + + def settings_wire_mapping(self) -> ViewerWireMapping: + return ViewerWirePayload.mapping( + { + declared.name: getattr(self, declared.name) + for declared in fields(self.settings_type()) + }, + context=f"{type(self).__name__} display settings", + ) + + +class NapariVariableSizeHandling(Enum): + """How to handle images with different sizes in the same layer.""" + + SEPARATE_LAYERS = "separate_layers" + PAD_TO_MAX = "pad_to_max" + + +@dataclass(frozen=True) +class NapariDisplaySettings(ViewerDisplaySettings): + slot_family = NapariSlots + + colormap: NonBlankString = field( + default="gray", + metadata={ + "description": ( + "Name of a colormap registered in the installed Napari viewer. " + "Napari validates this extensible registry name when displaying " + "the layer." + ) + }, + ) + variable_size_handling: NapariVariableSizeHandling = field( + default=NapariVariableSizeHandling.PAD_TO_MAX, + metadata={ + "description": ( + "How Napari handles streamed images with different spatial " + "dimensions: preserve each shape in separate layers or pad " + "smaller images to the largest shape before stacking." + ) + }, + ) + + +@dataclass(frozen=True) +class FijiDisplaySettings(ViewerDisplaySettings): + slot_family = FijiSlots + + lut: NonBlankString = field( + default="Grays", + metadata={ + "description": ( + "Name of a lookup table available to the installed Fiji/ImageJ " + "runtime, including plugin-provided LUTs." + ) + }, + ) + auto_contrast: bool = field( + default=True, + metadata={ + "description": ( + "Automatically set Fiji display limits from the streamed image data." + ) + }, + ) diff --git a/openhcs/runtime/viewer_measurements.py b/openhcs/runtime/viewer_measurements.py index 4df0827453..318eff4cfb 100644 --- a/openhcs/runtime/viewer_measurements.py +++ b/openhcs/runtime/viewer_measurements.py @@ -77,7 +77,7 @@ def read(self, data: np.ndarray) -> np.ndarray: @dataclass(frozen=True, slots=True) class NativeImageMeasurement: - """One admitted original 2D payload plus native transform; never display padding.""" + """One accepted original 2D payload plus native transform; never display padding.""" data: np.ndarray origin_yx: tuple[int, int] @@ -190,7 +190,7 @@ def polyline( ) if len(profile) != counts[i] or not np.isfinite(profile).all(): raise ValueError( - "Profile interpolation did not retain the admitted finite source support." + "Profile interpolation did not retain the accepted finite source support." ) drop = 0 if i == 0 else 1 profiles.extend(float(v) for v in profile[drop:]) diff --git a/openhcs/runtime/viewer_protocol.py b/openhcs/runtime/viewer_protocol.py index cf248a918d..923f9143b8 100644 --- a/openhcs/runtime/viewer_protocol.py +++ b/openhcs/runtime/viewer_protocol.py @@ -8,6 +8,7 @@ import re import subprocess import sys +import threading import time from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, MutableMapping @@ -75,7 +76,7 @@ from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.core.execution_visualizer import ExecutionVisualizerABC -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ViewerFamily from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, ViewerProcessLaunchConfig, @@ -114,6 +115,7 @@ ViewerLaunchLiteral: TypeAlias = str | int | float | bool | None ViewerOptionalT = TypeVar("ViewerOptionalT") +logger = logging.getLogger(__name__) _EXECUTION_OWNED_VIEWER_PROCESSES = EndpointProcessGroup() register_cleanup_callback(_EXECUTION_OWNED_VIEWER_PROCESSES.stop_all) @@ -244,7 +246,7 @@ class ViewerImageColorControlOptions(ViewerProjectionRecord): @dataclass(frozen=True) class ViewerNativeWindowGeometry(ViewerProjectionRecord): - """Qt logical client geometry; actual screen admission belongs to native Qt.""" + """Qt logical client geometry; actual screen acceptance belongs to native Qt.""" x: StrictInt y: StrictInt @@ -343,7 +345,7 @@ class ViewerSourceSpatialSummary(ViewerProjectionRecord): @property def voxel_spacing(self) -> SourceVoxelSpacing: - """Retain declared calibration, with unspecified spacing for legacy receipts.""" + """Retain declared calibration, with unspecified spacing for legacy records.""" return self.optional(self.source_voxel_spacing) or SourceVoxelSpacing() @property @@ -356,8 +358,8 @@ def source_domain(self) -> ViewerSourceSpatialDomainPayload: def require_uncropped_source_shape(self) -> tuple[int, int]: domain = self.source_domain if domain.origin_yx != (0, 0): - raise ValueError("Image receipt requires explicit uncropped pixel placement.") - return domain.required_source_shape_yx(source_label="image receipt") + raise ValueError("Image record requires explicit uncropped pixel placement.") + return domain.required_source_shape_yx(source_label="image record") @dataclass(frozen=True, kw_only=True) class ViewerAggregateComponentSummary(ViewerProjectionRecord): @@ -374,7 +376,7 @@ def validate_record(self) -> None: def require_plane_components(self) -> Mapping[str, tuple[ViewerComponentValue, ...]]: if self.aggregate_component_values is VIEWER_FIELD_ABSENT or self.aggregate_component_values is None: - raise ValueError("Image receipt requires its explicit plane component domain.") + raise ValueError("Image record requires its explicit plane component domain.") return self.aggregate_component_values @@ -408,9 +410,9 @@ def require_full_image_window(self) -> None: shape = self.require_positive_shape(rank=3) source_shape = self.require_uncropped_source_shape() except ValueError as error: - raise ValueError("Image receipt requires an explicit full three-axis image window.") from error + raise ValueError("Image record requires an explicit full three-axis image window.") from error if source_shape != shape[-2:]: - raise ValueError("Image receipt requires an explicit full three-axis image window.") + raise ValueError("Image record requires an explicit full three-axis image window.") @property def full_image_plane_count(self) -> int: @@ -534,7 +536,7 @@ class ViewerLayerIsolationField(str, Enum): @dataclass(frozen=True, kw_only=True) -class ViewerLayerRetirementReceipt(ViewerProjectionRecord): +class ViewerLayerRetirementResult(ViewerProjectionRecord): """One native retirement observation, projected into the agent envelope.""" applied: bool = False @@ -612,10 +614,10 @@ class ViewerDescriptorField(str, Enum): @dataclass(frozen=True, slots=True) -class ViewerTypeIdentity: +class ViewerFamilyIdentity: """Inherited viewer identity for runtime protocol records.""" - viewer_type: ViewerType + viewer_family: type[ViewerFamily] class ViewerPersistenceMode(Enum): @@ -802,7 +804,7 @@ def to_wire_mapping(self) -> dict[str, object]: class ViewerComponentValueOrdering: - """Canonical ordering for viewer component values and stack coordinates.""" + """ordering for viewer component values and stack coordinates.""" NATURAL_TOKEN_PATTERN = re.compile(r"(\d+)") INTEGER_PATTERN = re.compile(r"^[+-]?\d+$") @@ -1018,12 +1020,12 @@ def child_environment( class ViewerGraphicalSessionUnavailableError(RuntimeError): """Raised before spawn when a detached interactive viewer has no GUI session.""" - viewer_type: ViewerType + viewer_family: type[ViewerFamily] port: int def __str__(self) -> str: return ( - f"Cannot launch detached {self.viewer_type.display_name} viewer on port " + f"Cannot launch detached {self.viewer_family.display_name} viewer on port " f"{self.port}: no authoritative graphical session is available." ) @@ -1080,7 +1082,7 @@ def viewer_lifecycle_registry_key( f"{cls.__name__} must declare detached_server_entrypoint to register " "as a managed viewer lifecycle." ) from exc - return entrypoint.viewer_type.wire_value + return entrypoint.viewer_family.wire_value @dataclass(frozen=True, slots=True, kw_only=True) @@ -1302,13 +1304,13 @@ class DetachedViewerLaunchFailure(RuntimeError): def __init__( self, *, - viewer_type: ViewerType, + viewer_family: type[ViewerFamily], port: int, cause: Exception, log_file: Path, log_tail: str, ) -> None: - self.viewer_type = viewer_type + self.viewer_family = viewer_family self.port = port self.cause = cause self.log_file = log_file @@ -1322,7 +1324,7 @@ def __init__( @dataclass(frozen=True, slots=True) -class DetachedViewerLaunchRequest(ViewerTypeIdentity): +class DetachedViewerLaunchRequest(ViewerFamilyIdentity): """Authoritative detached launch request for a viewer process.""" port: int @@ -1343,12 +1345,12 @@ class DetachedViewerLaunchRequest(ViewerTypeIdentity): def log_file_for( cls, *, - viewer_type: ViewerType, + viewer_family: type[ViewerFamily], port: int, log_dir: Path | None = None, ) -> Path: launch_log_dir = get_openhcs_log_dir() if log_dir is None else log_dir - return launch_log_dir / (f"{viewer_type.wire_value}_detached_port_{port}.log") + return launch_log_dir / (f"{viewer_family.wire_value}_detached_port_{port}.log") def command(self) -> list[str]: launch_policy = BackgroundProcessLaunchPolicy.current(detached=True) @@ -1361,7 +1363,7 @@ def command(self) -> list[str]: def failure(self, cause: Exception) -> DetachedViewerLaunchFailure: """Project one startup exception through this request's log authority.""" return DetachedViewerLaunchFailure( - viewer_type=self.viewer_type, + viewer_family=self.viewer_family, port=self.port, cause=cause, log_file=self.log_file, @@ -1371,7 +1373,7 @@ def failure(self, cause: Exception) -> DetachedViewerLaunchFailure: def launch(self) -> subprocess.Popen[bytes]: if not self.launch_context.graphical_session_available: raise ViewerGraphicalSessionUnavailableError( - viewer_type=self.viewer_type, + viewer_family=self.viewer_family, port=self.port, ) self.log_file.parent.mkdir(parents=True, exist_ok=True) @@ -1393,7 +1395,7 @@ def launch(self) -> subprocess.Popen[bytes]: @dataclass(frozen=True, slots=True) -class DetachedViewerServerEntrypointSpec(ViewerTypeIdentity): +class DetachedViewerServerEntrypointSpec(ViewerFamilyIdentity): """Declared server function used to launch one detached viewer family.""" module_name: str @@ -1402,7 +1404,7 @@ class DetachedViewerServerEntrypointSpec(ViewerTypeIdentity): def log_file_for(self, port: int) -> Path: return DetachedViewerLaunchRequest.log_file_for( - viewer_type=self.viewer_type, + viewer_family=self.viewer_family, port=port, ) @@ -1475,7 +1477,7 @@ def launch_request( if process_launch is None: process_launch = ViewerProcessLaunchConfig() return DetachedViewerLaunchRequest( - viewer_type=self.viewer_type, + viewer_family=self.viewer_family, port=port, python_code=self.python_code( import_authority, @@ -1676,7 +1678,7 @@ def to_wire_mapping(self, *, deadline: OperationDeadline | None = None) -> dict[ from openhcs.agent.dto.viewer import ViewerWindowControlRequest # Full snapshot/retirement carriers contain a sender-local clock. - # Their enclosing budget is admitted once from the envelope below. + # Their enclosing budget is accepted once from the envelope below. payload = self.payload if isinstance(payload, ViewerWindowControlRequest): payload = replace(payload, operation_deadline=None) @@ -1720,7 +1722,7 @@ def send(self) -> ViewerControlResponse: @dataclass(frozen=True, slots=True) -class ViewerProcessLaunchAdmission(ViewerReuseAdmissionABC): +class ViewerProcessLaunchReuseCheck(ViewerReuseAdmissionABC): """Admit a new OpenHCS launch request inside atomic managed acquisition.""" requested: ViewerProcessLaunchConfig @@ -1753,16 +1755,17 @@ class ManagedViewerLifecycleMixin( viewer_registry_key: ClassVar[str | None] = None viewer_process_label: ClassVar[str] = "viewer" detached_server_entrypoint: ClassVar[DetachedViewerServerEntrypointSpec] + starts_in_background: ClassVar[bool] = False def __init__( self, *, runtime_config: StreamingViewerRuntimeConfig, ) -> None: - if runtime_config.viewer_type is not self.viewer_type: + if runtime_config.viewer_family is not self.viewer_family: raise ValueError( - f"{type(self).__name__} owns {self.viewer_type.name}, but received " - f"runtime config for {runtime_config.viewer_type.name}." + f"{type(self).__name__} owns {self.viewer_family.__name__}, but " + f"received runtime config for {runtime_config.viewer_family.__name__}." ) super().__init__(port=runtime_config.transport_endpoint.port) self.persistent: bool = runtime_config.persistent @@ -1792,18 +1795,80 @@ def persistence_label(self) -> str: return self.persistence_mode.value @property - def viewer_type(self) -> ViewerType: - """Return the typed identity owned by the detached entrypoint.""" + def viewer_family(self) -> type[ViewerFamily]: + """The viewer the detached entrypoint launches.""" - return self.detached_server_entrypoint.viewer_type + return self.detached_server_entrypoint.viewer_family @property def viewer_title(self) -> str: - return self.viewer_type.title + return self.viewer_family.title - @abstractmethod - def start_viewer(self, async_mode: bool = False) -> None: - """Start the concrete viewer server process.""" + def start_viewer(self) -> None: + """Start the viewer process, in the background if this viewer declares so.""" + + if self.starts_in_background: + threading.Thread(target=self.start_viewer_now, daemon=True).start() + logger.info( + "🔬 VISUALIZER: Starting %s viewer in the background on port %s", + self.viewer_process_label, + self.required_port, + ) + return + self.start_viewer_now() + + def start_viewer_now(self) -> None: + """Start the detached viewer process and wait for it to spawn.""" + + with self._lock: + port = self.required_port + self.prepare_fresh_viewer_start() + if self.lifecycle_state.is_active: + logger.warning("%s viewer is already running.", self.viewer_process_label) + return + logger.info( + "🔬 VISUALIZER: Starting %s %s viewer (detached) on port %s", + self.persistence_label, + self.viewer_process_label, + port, + ) + self.process = self.launch_detached_viewer() + if self.owned_viewer_process_is_alive(): + self.lifecycle_state.mark_owned_process() + logger.info( + "🔬 VISUALIZER: %s viewer process started (PID: %s)", + self.viewer_process_label, + self.process_pid_label, + ) + else: + logger.error( + "🔬 VISUALIZER: Failed to start %s viewer process", + self.viewer_process_label, + ) + + def stop_viewer(self) -> None: + """Stop the viewer process unless it is persistent.""" + + with self._lock: + if self.persistent: + logger.info( + "🔬 VISUALIZER: Keeping persistent %s viewer alive", + self.viewer_process_label, + ) + return + logger.info( + "🔬 VISUALIZER: Stopping non-persistent %s viewer", + self.viewer_process_label, + ) + if self.process and self.terminate_owned_viewer_process(): + logger.warning( + "🔬 VISUALIZER: Force killing %s viewer process", + self.viewer_process_label, + ) + self.lifecycle_state.mark_stopped() + + def stop(self, timeout: float = 5.0) -> None: + self.stop_viewer() @abstractmethod def detached_server_arguments( @@ -2020,12 +2085,12 @@ def rollback_failed_bootstrap(self) -> None: manager = ViewerStateManager.get_instance() managed_viewer = manager.get_viewer( - self.viewer_type.wire_value, + self.viewer_family.wire_value, self.required_port, ) if managed_viewer is self: manager.release_viewer( - self.viewer_type.wire_value, + self.viewer_family.wire_value, self.required_port, stop=True, force=True, @@ -2038,7 +2103,7 @@ def rollback_failed_bootstrap(self) -> None: lifecycle_state.mark_stopped() def start(self, detached: bool = True) -> subprocess.Popen[bytes]: - self.start_viewer(async_mode=False) + self.start_viewer_now() if self.process is None: raise RuntimeError( f"{self.viewer_process_label} viewer process failed to start." diff --git a/openhcs/runtime/viewer_snapshot.py b/openhcs/runtime/viewer_snapshot.py index f14a8b82f2..f7f56342e8 100644 --- a/openhcs/runtime/viewer_snapshot.py +++ b/openhcs/runtime/viewer_snapshot.py @@ -19,7 +19,7 @@ @dataclass(frozen=True, kw_only=True) class ViewerWindowSnapshotFailureReply(ControlErrorResponse): - """Native observation evidence on the existing canonical control error.""" + """Native observation evidence on the existing control error.""" observation: WindowVisualObservation @@ -54,7 +54,7 @@ def snapshot_reply(descriptor, snapshot: QtWindowSnapshot) -> dict[str, object]: "type": "screenshot_ack", "status": "success", "viewer": { - "type": descriptor.viewer_type.wire_value, + "type": descriptor.viewer_type.value, "title": descriptor.title, }, "resource": to_jsonable( diff --git a/pyproject.toml b/pyproject.toml index d2624c71d9..70f77fefee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,14 +99,14 @@ dependencies = [ "psutil>=5.9.0", # External dependencies (PyPI versions for production builds) - "zmqruntime>=0.4.1,<0.5", + "zmqruntime>=0.5.0,<0.6", "pycodify>=0.1.3,<0.2", "objectstate>=1.3.1,<2", - "python-introspect>=0.2.3,<0.3", + "python-introspect>=0.2.5,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.3.8,<0.4", - "polystore>=0.3.3,<0.4", - "pyqt-reactive>=0.3.28,<0.4", + "polystore>=0.4.0,<0.5", + "pyqt-reactive>=0.3.29,<0.4", ] [project.optional-dependencies] diff --git a/scripts/mcp_thesis_demo_live.py b/scripts/mcp_thesis_demo_live.py index bfe7e635eb..acef4d9ff9 100755 --- a/scripts/mcp_thesis_demo_live.py +++ b/scripts/mcp_thesis_demo_live.py @@ -1225,9 +1225,9 @@ def demo_source( LazySourceBindingsConfig, LazyStepMaterializationConfig, LazyStepWellFilterConfig, - NapariVariableSizeHandling, PipelineConfig, ) +from openhcs.runtime.viewer_display import NapariVariableSizeHandling from openhcs.core.source_bindings import ( ComponentSelector, NamedSourceBinding, diff --git a/tests/diagnostics/check_viewer_feature_measurement_live.py b/tests/diagnostics/check_viewer_feature_measurement_live.py index 165ca6bbb7..dbf7f36812 100644 --- a/tests/diagnostics/check_viewer_feature_measurement_live.py +++ b/tests/diagnostics/check_viewer_feature_measurement_live.py @@ -198,7 +198,7 @@ def save() -> None: ViewerTransportEndpoint, ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG - from openhcs.core.streaming_config_declarations import ViewerType + from openhcs.core.streaming_config_declarations import NapariViewer from zmqruntime.config import TransportMode from python_introspect import to_jsonable from openhcs.agent.runtime_platform import AgentRuntimePlatformAuthority @@ -299,7 +299,7 @@ def call(name: str, arguments: dict, *, expect_error: bool = False) -> dict: "plate_path": str(paths[0].parent), "file_paths": [str(p) for p in paths], "fresh_viewer": False, - "viewer_config_key": ViewerType.NAPARI.config_key, + "viewer_config_key": NapariViewer.config_key, "limit": 2, }, ) diff --git a/tests/fixtures/mcp/mcp_tool_contract.json b/tests/fixtures/mcp/mcp_tool_contract.json index d0f1cbcd0a..a5d0a0a058 100644 --- a/tests/fixtures/mcp/mcp_tool_contract.json +++ b/tests/fixtures/mcp/mcp_tool_contract.json @@ -6454,7 +6454,7 @@ "type": "object" }, "NapariDimensionMode": { - "description": "How component values are placed in Napari image layers.", + "description": "Napari places each axis on a stacked dims slider or in separate layers.", "enum": [ "layer", "stack" diff --git a/tests/integration/test_cellprofiler_official30_zmq.py b/tests/integration/test_cellprofiler_official30_zmq.py index bc9cd1df98..95c911aba9 100644 --- a/tests/integration/test_cellprofiler_official30_zmq.py +++ b/tests/integration/test_cellprofiler_official30_zmq.py @@ -84,7 +84,7 @@ case_index, case, id=( - "+".join(viewer_type.wire_value for viewer_type in viewer_types) + "+".join(viewer_type.value for viewer_type in viewer_types) + f"-{case_index:02d}-{case.name}" ), ) @@ -262,7 +262,7 @@ def _registered_streaming_config_kwargs( port=ports_by_viewer[viewer_type], transport_mode=TransportMode.TCP, ) - config_kwargs[viewer_type.config_key] = config_type(**init_kwargs) + config_kwargs[viewer_type.family.config_key] = config_type(**init_kwargs) if selected_viewers - set(StreamingConfig.__registry__): raise ValueError("Official30 viewer variant selected an unregistered config.") @@ -585,11 +585,11 @@ def test_official30_fiji_variants_project_registered_viewer_configs() -> None: if field.name not in registered_keys ) assert { - config.viewer_type for config in configs.values() if config.enabled + config.viewer_family.viewer_type() for config in configs.values() if config.enabled } == set(viewer_types) assert all(config.persistent is config.enabled for config in configs.values()) assert { - config.viewer_type: config.port + config.viewer_family.viewer_type(): config.port for config in configs.values() if config.enabled } == ports_by_viewer @@ -775,15 +775,15 @@ def test_official30_persistent_fiji_variants_isolated_per_case( **streaming_configs, ) endpoints = { - viewer_type.wire_value: ViewerRuntimeEndpoint( - streaming_configs[viewer_type.config_key] + viewer_type.value: ViewerRuntimeEndpoint( + streaming_configs[viewer_type.family.config_key] .viewer_runtime_config() .transport_endpoint, OPENHCS_ZMQ_CONFIG, ) for viewer_type in viewer_types } - variant_id = "+".join(viewer_type.wire_value for viewer_type in viewer_types) + variant_id = "+".join(viewer_type.value for viewer_type in viewer_types) case_output_root = tmp_path / variant_id / f"case_{case_index:02d}" macro_path = tmp_path / f"{variant_id}_fresh_process_probe.ijm" macro_path.write_text(_FIJI_FRESH_PROCESS_IMAGE_PROBE, encoding="utf-8") @@ -822,7 +822,7 @@ def test_official30_persistent_fiji_variants_isolated_per_case( runtime_observation = _runtime_observation(observations[0]) for viewer_type in viewer_types: - config = streaming_configs[viewer_type.config_key] + config = streaming_configs[viewer_type.family.config_key] if viewer_type is ViewerType.NAPARI: _assert_live_napari_state(config, runtime_observation) elif viewer_type is ViewerType.FIJI: diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index b726853983..90cd0ef786 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -26,8 +26,8 @@ VFSConfig, Backend, ZarrConfig, - NapariVariableSizeHandling, ) +from openhcs.runtime.viewer_display import NapariVariableSizeHandling from openhcs.runtime.zmq_execution_client import OpenHCSExecutionSubmission from openhcs.core.config import ( LazyStepMaterializationConfig, diff --git a/tests/pyqt_gui/test_image_browser_filter_controller.py b/tests/pyqt_gui/test_image_browser_filter_controller.py index d0bde42224..05d273b402 100644 --- a/tests/pyqt_gui/test_image_browser_filter_controller.py +++ b/tests/pyqt_gui/test_image_browser_filter_controller.py @@ -207,7 +207,7 @@ def stream_rois_async(self, _request) -> None: _prepare_streaming=lambda _config_key: ( object(), "disk", - SimpleNamespace(display_name="Napari"), + SimpleNamespace(viewer_family=SimpleNamespace(display_name="Napari")), ), streaming_service=StreamingService(), _status_update_signal=SimpleNamespace(emit=lambda _message: None), diff --git a/tests/unit/agent/test_native_presentation_451452.py b/tests/unit/agent/test_native_presentation_451452.py index db5a579df5..762c15634a 100644 --- a/tests/unit/agent/test_native_presentation_451452.py +++ b/tests/unit/agent/test_native_presentation_451452.py @@ -252,9 +252,9 @@ def native_server(): def test_color_native_action_preserves_units_and_has_one_reply_and_route_owner(): import numpy as np from openhcs.runtime.napari_viewer_server import ( - NapariControlMessageAction, - NapariMountedRouteControlMessageAction, - NapariPresentationControlMessageAction, + NapariControlAction, + NapariMountedRouteControlAction, + NapariPresentationControlAction, ) from openhcs.runtime.viewer_protocol import ( OpenHCSViewerControlMessageType, @@ -263,11 +263,11 @@ def test_color_native_action_preserves_units_and_has_one_reply_and_route_owner() ) server, _ = native_server() - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( OpenHCSViewerControlMessageType.IMAGE_COLOR.value ) - assert isinstance(action, NapariMountedRouteControlMessageAction) - assert isinstance(action, NapariPresentationControlMessageAction) + assert isinstance(action, NapariMountedRouteControlAction) + assert isinstance(action, NapariPresentationControlAction) originals = [layer.data.copy() for layer in server.viewer.layers] camera = (server.viewer.camera.center, server.viewer.camera.zoom) for route, colormap in (("C1", "blue"), ("C3", "green")): @@ -341,7 +341,7 @@ def test_color_native_action_preserves_units_and_has_one_reply_and_route_owner() def test_window_action_reads_actual_state_focuses_only_its_window_and_bounds_geometry(): - from openhcs.runtime.napari_viewer_server import NapariControlMessageAction + from openhcs.runtime.napari_viewer_server import NapariControlAction from openhcs.runtime.viewer_protocol import ( OpenHCSViewerControlMessageType, ViewerNativeWindowControlOptions, @@ -351,7 +351,7 @@ def test_window_action_reads_actual_state_focuses_only_its_window_and_bounds_geo server, window = native_server() other = NativeWindowFixture(x=900) - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( OpenHCSViewerControlMessageType.WINDOW_PRESENTATION.value ) before = action.handle(server, {"payload": ViewerNativeWindowControlOptions()}) @@ -385,8 +385,8 @@ def test_geometry_rejects_invalid_declarations(width): def test_independent_new_native_capability_composes_real_cooperative_mro(): from openhcs.runtime.napari_viewer_server import ( - NapariControlMessageAction, - NapariImageColorControlMessageAction, + NapariControlAction, + NapariImageColorControlAction, ) from openhcs.runtime.viewer_protocol import ( ViewerImageColorControlOptions, @@ -403,13 +403,13 @@ def apply_presentation(self, server, request): return result class NewColorDeclaration( - IndependentReadbackCapability, NapariImageColorControlMessageAction + IndependentReadbackCapability, NapariImageColorControlAction ): message_type = "source-test-independent-color" server, _ = native_server() try: - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( NewColorDeclaration.message_type ) reply = action.handle( @@ -425,6 +425,6 @@ class NewColorDeclaration( assert calls == ["before", ("after", "blue")] assert NewColorDeclaration.__mro__.index( IndependentReadbackCapability - ) < NewColorDeclaration.__mro__.index(NapariImageColorControlMessageAction) + ) < NewColorDeclaration.__mro__.index(NapariImageColorControlAction) finally: - NapariControlMessageAction.__registry__.pop(NewColorDeclaration.message_type) + NapariControlAction._registry.pop(NewColorDeclaration.message_type) diff --git a/tests/unit/agent/test_native_presentation_mcp_451452.py b/tests/unit/agent/test_native_presentation_mcp_451452.py index a095712fc5..2693da6c83 100644 --- a/tests/unit/agent/test_native_presentation_mcp_451452.py +++ b/tests/unit/agent/test_native_presentation_mcp_451452.py @@ -18,7 +18,7 @@ from openhcs.agent.services.viewer_window_service import ViewerWindowService from openhcs.mcp.context import OpenHCSAgentContext from openhcs.mcp.server import build_server -from openhcs.runtime.napari_viewer_server import NapariControlMessageAction +from openhcs.runtime.napari_viewer_server import NapariControlAction from openhcs.runtime.viewer_protocol import ( ViewerNativeWindowControlOptions, ViewerNativeWindowGeometry, @@ -44,7 +44,7 @@ def __init__(self): def presentation_control(self, request): self.calls.append(request) - return NapariControlMessageAction.for_message_type(request.message_type).handle( + return NapariControlAction.for_message_type(request.message_type).handle( self.server, {"payload": request.control_payload}, ) diff --git a/tests/unit/agent/test_selected_plane_reopen_stream.py b/tests/unit/agent/test_selected_plane_reopen_stream.py index 5da1080593..333e000a04 100644 --- a/tests/unit/agent/test_selected_plane_reopen_stream.py +++ b/tests/unit/agent/test_selected_plane_reopen_stream.py @@ -33,7 +33,7 @@ from openhcs.core.virtual_workspace_metadata import AtomicMetadataWriter from openhcs.core.dataset_sources.source_schema import SourceSchemaFilenameParser from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariImagePayloadAxisLabelPolicy, NapariStreamLayerItem, ) @@ -91,7 +91,7 @@ def receive(_manager, data, paths, backend, **fields): image_metadata=payload.image_metadata, plane_component_domain=payload.plane_component_domain, ) - bindings = NapariAggregateAxisBindingAuthority.bindings((item,), semantics) + bindings = NapariAggregateAxisBindingBuilder.bindings((item,), semantics) NapariImagePayloadAxisLabelPolicy.axis_labels( pixels, item.image_metadata, bindings.payload_axes ) diff --git a/tests/unit/pyqt_gui/test_live_server_tree_sync.py b/tests/unit/pyqt_gui/test_live_server_tree_sync.py index ffa0f52523..d5c22fa038 100644 --- a/tests/unit/pyqt_gui/test_live_server_tree_sync.py +++ b/tests/unit/pyqt_gui/test_live_server_tree_sync.py @@ -163,7 +163,7 @@ def test_launching_viewer_row_keeps_nominal_viewer_identity(monkeypatch): list_viewers=lambda: ( SimpleNamespace( port=5555, - viewer_type=ViewerType.NAPARI.wire_value, + viewer_type=ViewerType.NAPARI.value, queued_images=3, state=live_tree_sync.ViewerState.LAUNCHING, ), @@ -188,7 +188,7 @@ def test_launching_viewer_row_keeps_nominal_viewer_identity(monkeypatch): def test_launching_viewer_row_tracks_current_viewer_snapshot(monkeypatch): viewer = SimpleNamespace( port=5555, - viewer_type=ViewerType.NAPARI.wire_value, + viewer_type=ViewerType.NAPARI.value, queued_images=3, state=live_tree_sync.ViewerState.LAUNCHING, ) diff --git a/tests/unit/test_aggregate_layer_materialization_515.py b/tests/unit/test_aggregate_layer_materialization_515.py index bb5bf9c1b3..a9ba6c4ef4 100644 --- a/tests/unit/test_aggregate_layer_materialization_515.py +++ b/tests/unit/test_aggregate_layer_materialization_515.py @@ -27,12 +27,13 @@ from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.core.source_spatial_domain import SourceSpatialDomain from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariStreamLayerAddress, NapariStreamLayerItem, ) +from openhcs.runtime.viewer_display import NapariSlots from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentValueDomainPayload, ViewerMappingDisplayConfigInput, ) @@ -43,7 +44,7 @@ from polystore.base import ensure_storage_registry, storage_registry from polystore.filemanager import FileManager from polystore.streaming import StreamingBatchMessageBuilder, StreamingBatchMessageRequest -from polystore.streaming.receivers.napari.layer_key import build_route_key, normalize_component_layout +from polystore.streaming.receivers.napari.layer_key import build_route_key from polystore.streaming.viewer_transport import ViewerStreamKwarg from polystore.streaming.viewer_transport import ViewerStreamBackendKwargs from polystore.streaming_constants import StreamingDataType @@ -60,6 +61,7 @@ _plan, ) from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES class ComponentDisplayConfig(StreamingConfigStub): @@ -187,7 +189,9 @@ def _route_keys(batches): return [ build_route_key( item["producer_identity"], item["metadata"], - normalize_component_layout(batch.message["display_config"]), + ViewerMappingDisplayConfigInput(batch.message["display_config"]) + .layout() + .components_in(NapariSlots.Layer), StreamingDataType(item["data_type"]), ) for batch in batches for item in batch.batch_images @@ -206,12 +210,14 @@ def _received_item(batch, pixels): image_metadata=ImagePayloadMetadata.from_viewer_image_metadata(wire["image_metadata"]), plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( wire["plane_component_values"], context="actual automatic TIFF wire domain", + declared_axes=STREAM_AXES, ), ) - semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput(batch.message["display_config"]), ViewerComponentValueDomainPayload.from_wire_mapping( batch.message["component_value_domain"], context="actual automatic TIFF viewer domain", + declared_axes=STREAM_AXES, ), ) return item, semantics @@ -247,7 +253,7 @@ def test_automatic_aggregate_label_tiff_preserves_stack(component, viewer_ack_re assert component.name not in item["metadata"] assert len(_route_keys(batches)) == 1 received, semantics = _received_item(batches[0], saves[0][0]) - bindings = NapariAggregateAxisBindingAuthority.bindings((received,), semantics) + bindings = NapariAggregateAxisBindingBuilder.bindings((received,), semantics) assert len(bindings.bindings) == 1 assert bindings.bindings[0].component == component.name assert bindings.bindings[0].values == (1, 2) @@ -259,11 +265,16 @@ def test_strict_receiver_still_rejects_aggregate_without_scalar_layer(viewer_ack Microscopy.Channel, "stack", viewer_ack_return_route, monkeypatch, tmp_path, ) batch = batches[0] - layout = normalize_component_layout(batch.message["display_config"]) - layout = replace(layout, component_modes={**layout.component_modes, "channel": "layer"}) + layout = ViewerMappingDisplayConfigInput(batch.message["display_config"]).layout() + layout = layout.with_modes({**layout.component_modes, "channel": "layer"}) item = batch.batch_images[0] - with pytest.raises(ValueError, match="missing layer component 'channel'"): - build_route_key(item["producer_identity"], item["metadata"], layout, StreamingDataType.IMAGE) + with pytest.raises(ValueError, match="missing layer component .channel."): + build_route_key( + item["producer_identity"], + item["metadata"], + layout.components_in(NapariSlots.Layer), + StreamingDataType.IMAGE, + ) @pytest.mark.parametrize("plane_count", (1, 2)) @@ -317,9 +328,10 @@ def test_strict_aggregate_receiver_rejects_malformed_tiff_domains( received, semantics = _received_item(batches[0], saves[0][0]) received = replace(received, plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( domain, context="deliberately malformed incoming TIFF domain", + declared_axes=STREAM_AXES, )) with pytest.raises(ValueError, match=error): - NapariAggregateAxisBindingAuthority.bindings((received,), semantics) + NapariAggregateAxisBindingBuilder.bindings((received,), semantics) def test_strict_receiver_rejects_duplicate_plane_coordinates(viewer_ack_return_route, monkeypatch, tmp_path): @@ -331,6 +343,7 @@ def test_strict_receiver_rejects_duplicate_plane_coordinates(viewer_ack_return_r with pytest.raises(ValueError, match="coordinates must be unique"): ViewerComponentValueDomainPayload.from_ordered_wire_mapping( domain, context="duplicate incoming pixel-plane coordinate", + declared_axes=STREAM_AXES, ) @@ -342,10 +355,11 @@ def test_strict_receiver_rejects_inconsistent_item_plane_domains(viewer_ack_retu reversed_item = replace( received, plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( {"channel": [2, 1]}, context="contradictory second item pixel-plane order", + declared_axes=STREAM_AXES, ), ) with pytest.raises(ValueError, match="inconsistent plane component domains"): - NapariAggregateAxisBindingAuthority.bindings((received, reversed_item), semantics) + NapariAggregateAxisBindingBuilder.bindings((received, reversed_item), semantics) @pytest.mark.parametrize("component", (Microscopy.Channel, Microscopy.Site)) diff --git a/tests/unit/test_axis_family_witness.py b/tests/unit/test_axis_family_witness.py index 8d7f47a6c1..40428b68aa 100644 --- a/tests/unit/test_axis_family_witness.py +++ b/tests/unit/test_axis_family_witness.py @@ -107,8 +107,73 @@ def test_configuration_derives_from_the_active_family(remote_sensing) -> None: "scene": "frame", } assert set(NapariDisplayConfig().component_modes()) == set(RemoteSensing.names()) - payload = {"component_modes": fiji.component_modes(), "lut": "Grays", "auto_contrast": True} - assert FijiDisplayConfig.from_display_payload(payload) == fiji + + +def _streamed_display_config(config) -> dict: + """The display-config wire section a producer under ``RemoteSensing`` sends.""" + + return { + "component_modes": config.component_modes(), + "component_order": list(config.COMPONENT_ORDER), + **config.display_payload_extra(), + } + + +def test_viewer_decodes_a_stream_by_its_own_declared_axes(remote_sensing) -> None: + from openhcs.core.config import FijiDisplayConfig, NapariDisplayConfig + from openhcs.runtime.viewer_component_system import ( + ViewerComponentNameMetadata, + ViewerMappingDisplayConfigInput, + ) + from openhcs.runtime.viewer_display import ( + FijiDisplaySettings, + FijiSlots, + NapariSlots, + ) + + fiji_payload = _streamed_display_config(FijiDisplayConfig(lut="Fire")) + napari_payload = _streamed_display_config(NapariDisplayConfig()) + + # The viewer process runs with its own (microscopy) family active. + Microscopy.activate() + fiji = ViewerMappingDisplayConfigInput(fiji_payload).layout() + assert fiji.declared_axes.names() == ("tile", "band", "date", "scene") + assert fiji.components_in(FijiSlots.HyperstackChannel) == ("band",) + assert fiji.components_in(FijiSlots.HyperstackSlice) == () + assert fiji.components_in(FijiSlots.HyperstackFrame) == ("tile", "date", "scene") + assert FijiDisplaySettings.from_display_payload(fiji_payload) == FijiDisplaySettings( + lut="Fire" + ) + + napari = ViewerMappingDisplayConfigInput(napari_payload).layout() + assert napari.components_in(NapariSlots.Stack) == ("tile", "band", "date", "scene") + assert napari.names_with_role(PartitionAxis) == ("scene",) + assert napari.names_with_role(StackAxis) == () + + names = ViewerComponentNameMetadata.from_wire_mapping( + {"band": {"3": "NIR"}, "scene": {"S07": "Lake"}}, + declared_axes=fiji.declared_axes, + context="remote sensing names", + ) + assert names.axis_label("band", 3) == "Band 3: NIR" + assert names.axis_label("date", 14) == "Date 14" + assert names.axis_label("scene", "S07") == "Lake" + + +def test_an_axis_without_a_viewer_role_takes_each_viewer_default_slot() -> None: + from openhcs.core.config import FijiDisplayConfig, NapariDisplayConfig + + class Survey(AxisFamily): + payload_spatial_rank = 2 + + class Replicate(Axis, DefaultVariable, OrdinalValued): + name = "replicate" + + class Site(Axis, PartitionAxis, LabelValued): + name = "site" + + assert FijiDisplayConfig().slot_for_axis(Survey.Replicate) == "frame" + assert NapariDisplayConfig().slot_for_axis(Survey.Replicate) == "stack" def test_plane_addresses_spell_the_declared_tokens(remote_sensing) -> None: diff --git a/tests/unit/test_configuration_mirror_deletion.py b/tests/unit/test_configuration_mirror_deletion.py index 6b4ec83b1f..f182f47458 100644 --- a/tests/unit/test_configuration_mirror_deletion.py +++ b/tests/unit/test_configuration_mirror_deletion.py @@ -405,45 +405,14 @@ def test_viewer_identity_is_owned_by_the_streaming_declaration_type() -> None: for path in _python_files(): tree = ast.parse(path.read_text(), filename=str(path)) for node in ast.walk(tree): - if isinstance(node, ast.ClassDef) and node.name == "ViewerType": + if isinstance(node, ast.ClassDef) and node.name == "ViewerFamily": definitions.append(str(path.relative_to(PROJECT_ROOT))) if ( isinstance(node, ast.ImportFrom) and node.module == "openhcs.runtime.viewer_protocol" - and any(alias.name == "ViewerType" for alias in node.names) + and any(alias.name in {"ViewerType", "ViewerFamily"} for alias in node.names) ): stale_imports.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}") assert definitions == ["openhcs/core/streaming_config_declarations.py"] assert stale_imports == [] - - declarations_path = PROJECT_ROOT / "openhcs/core/streaming_config_declarations.py" - declarations_tree = ast.parse( - declarations_path.read_text(), - filename=str(declarations_path), - ) - assert not any( - isinstance(node, ast.ClassDef) and node.name == "StreamingViewerConfigSpec" - for node in declarations_tree.body - ) - viewer_type = next( - node - for node in declarations_tree.body - if isinstance(node, ast.ClassDef) and node.name == "ViewerType" - ) - members = { - node.targets[0].id: node.value - for node in viewer_type.body - if isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - and node.targets[0].id in {"FIJI", "NAPARI"} - } - assert set(members) == {"FIJI", "NAPARI"} - assert all( - isinstance(value, ast.Tuple) - and len(value.elts) == 2 - and isinstance(value.elts[1], ast.Name) - and value.elts[1].id.endswith("ViewerDeclaration") - for value in members.values() - ) diff --git a/tests/unit/test_fiji_viewer_server.py b/tests/unit/test_fiji_viewer_server.py index e2c9870a79..1ce37ffc8b 100644 --- a/tests/unit/test_fiji_viewer_server.py +++ b/tests/unit/test_fiji_viewer_server.py @@ -22,13 +22,10 @@ FijiMacroExecutionRequest, FijiMacroExecutionResponse, ) +from openhcs.runtime.viewer_display import FijiDisplaySettings from openhcs.runtime.fiji_viewer_server import ( FijiBatchSettlementState, FijiBatchWireParser, - FijiClearStateControlPlan, - FijiControlMessageAuthority, - FijiControlMessagePlan, - FijiControlRequestContext, FijiDimensionAxis, FijiHyperstackCoordinates, FijiImageIntensityRange, @@ -39,7 +36,6 @@ FijiPayloadLocalPlaneExpander, FijiPlaneGeometry, FijiRoiPayloadHandler, - FijiSettleControlPlan, FijiSharedMemoryItemCopier, FijiStackSliceLabelBuilder, FijiViewerServer, @@ -52,18 +48,34 @@ PayloadMap, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentNameMetadata, ViewerComponentValueDomainPayload, ViewerObjectDisplayConfigInput, ) from openhcs.runtime.viewer_protocol import ( - OpenHCSViewerControlMessageType, ViewerControlMessageType, ViewerControlResponse, ViewerSettlePhase, ViewerSettleProgress, ) +from tests.unit.viewer_axes_fixture import STREAM_AXES + +def fiji_control_server(*, settlement=None, ij=None, process_launch=None): + """A Fiji server with only the state its control actions read.""" + + server = object.__new__(FijiViewerServer) + server.ij = ij + server.windows = FijiWindowRegistry() + server.batch_processor = SimpleNamespace( + settlement=settlement or FijiBatchSettlementState() + ) + server.launch_config = SimpleNamespace( + process_launch=process_launch or ViewerProcessLaunchConfig() + ) + server._shutdown_requested = False + return server + PRODUCER_IDENTITY = { "origin": "pipeline", @@ -85,6 +97,7 @@ def _component_value_domain(payload: dict) -> ViewerComponentValueDomainPayload: return ViewerComponentValueDomainPayload.from_wire_mapping( payload, context="test Fiji component value domain", + declared_axes=STREAM_AXES, ) @@ -111,21 +124,17 @@ def close(self) -> None: self.visible = False -def test_fiji_display_config_rehydrates_its_own_wire_payload() -> None: - default_config = FijiDisplayConfig() - config = FijiDisplayConfig.from_display_payload( - { - "component_modes": default_config.component_modes(), - "component_order": list(default_config.COMPONENT_ORDER), - "lut": "Fire", - "auto_contrast": False, - } - ) +def test_fiji_viewer_reads_its_settings_from_the_streamed_display_config() -> None: + streamed = FijiDisplayConfig(lut="Fire", auto_contrast=False) + payload = { + "component_modes": streamed.component_modes(), + "component_order": list(streamed.COMPONENT_ORDER), + **streamed.display_payload_extra(), + } - assert isinstance(config, FijiDisplayConfig) - assert config.lut == "Fire" - assert config.auto_contrast is False - assert config.component_modes() == default_config.component_modes() + assert FijiDisplaySettings.from_display_payload(payload) == FijiDisplaySettings( + lut="Fire", auto_contrast=False + ) def test_fiji_runtime_mode_follows_inherited_streaming_enablement() -> None: @@ -214,57 +223,12 @@ def test_fiji_server_stop_closes_transport_before_owned_jvm(monkeypatch) -> None assert server.ij is None -def test_fiji_control_dispatch_registry_is_module_local_and_eager() -> None: - registry = FijiControlMessagePlan.__registry__ - assert type(registry) is dict - assert ( - registry[ViewerControlMessageType.CLEAR_STATE.value] - is FijiClearStateControlPlan - ) - assert registry[ViewerControlMessageType.SETTLE.value] is FijiSettleControlPlan -def test_fiji_process_launch_control_reports_server_owned_declaration() -> None: - active = ViewerProcessLaunchConfig(listen_host="*") - response = FijiControlMessageAuthority( - FijiControlRequestContext( - FijiWindowRegistry(), object(), FijiBatchSettlementState(), active - ) - ).response_for({"type": OpenHCSViewerControlMessageType.PROCESS_LAUNCH.value}) - wire = response.to_wire_mapping() - assert wire["status"] == "success" - assert ViewerProcessLaunchConfig.from_wire_mapping(wire["process_launch"]) == active - - -def test_fiji_intensity_window_control_fails_closed_as_unsupported() -> None: - response = FijiControlMessageAuthority( - FijiControlRequestContext( - FijiWindowRegistry(), - object(), - FijiBatchSettlementState(), - ) - ).response_for({"type": ViewerControlMessageType.APPLY_INTENSITY_WINDOW.value}) - - wire_response = response.to_wire_mapping() - assert wire_response["status"] == "error" - assert "supported only by Napari" in wire_response["message"] -def test_fiji_settlement_reports_typed_terminal_progress() -> None: - response = FijiControlMessageAuthority( - FijiControlRequestContext( - FijiWindowRegistry(), - object(), - FijiBatchSettlementState(), - ) - ).response_for({"type": ViewerControlMessageType.SETTLE.value}) - wire_response = ViewerControlResponse(response.to_wire_mapping()) - assert wire_response.succeeded() - assert ViewerSettleProgress.from_response(wire_response) == ( - ViewerSettleProgress.complete() - ) def test_fiji_settlement_tracks_queued_and_bounded_native_work() -> None: @@ -272,12 +236,12 @@ def test_fiji_settlement_tracks_queued_and_bounded_native_work() -> None: settlement.queue_items(2) settlement.queue_items(3) - queued_response = FijiSettleControlPlan().response( - FijiControlRequestContext(FijiWindowRegistry(), object(), settlement), - None, - ) queued = ViewerSettleProgress.from_response( - ViewerControlResponse(queued_response.to_wire_mapping()) + ViewerControlResponse( + fiji_control_server(settlement=settlement).handle_control_message( + {"type": ViewerControlMessageType.SETTLE.value} + ) + ) ) assert queued.phase is ViewerSettlePhase.RUNNING @@ -318,12 +282,11 @@ def test_fiji_clear_state_resets_terminal_settlement_for_next_run() -> None: route = settlement.begin_pending_update(1) settlement.fail_active_update(route, ValueError("invalid Fiji ROI")) - response = FijiClearStateControlPlan().response( - FijiControlRequestContext(FijiWindowRegistry(), object(), settlement), - None, + response = fiji_control_server(settlement=settlement).handle_control_message( + {"type": ViewerControlMessageType.CLEAR_STATE.value} ) - assert response.header.status.value == "success" + assert response["status"] == "success" assert settlement.progress() == ViewerSettleProgress.complete() assert settlement.failure_message() is None @@ -363,22 +326,6 @@ def test_fiji_window_registry_closes_replaced_hyperstack() -> None: assert registry.open_hyperstack("A14") is new_image_plus -def test_fiji_state_control_message_fails_loudly_until_projector_exists() -> None: - response = FijiControlMessageAuthority( - FijiControlRequestContext( - FijiWindowRegistry(), - object(), - FijiBatchSettlementState(), - ) - ).response_for({"type": ViewerControlMessageType.STATE.value}) - - wire_response = response.to_wire_mapping() - - assert wire_response["status"] == "error" - assert wire_response["type"] == "state_ack" - assert ( - "Fiji live viewer state polling is not implemented" in wire_response["message"] - ) def test_fiji_macro_control_executes_inside_managed_imagej_runtime(tmp_path) -> None: @@ -406,22 +353,16 @@ class FakeImageJ: macro_variables={"Threshold": "0.25"}, input_images=(np.zeros((4, 5), dtype=np.uint8),), ) - response = FijiControlMessageAuthority( - FijiControlRequestContext( - FijiWindowRegistry(), - FakeImageJ(), - FijiBatchSettlementState(), - ) - ).response_for( + response = fiji_control_server(ij=FakeImageJ()).handle_control_message( { "type": FijiMacroExecutionRequest.message_type, "payload": request, } ) - assert response.header.status.value == "success" - assert isinstance(response.payload, FijiMacroExecutionResponse) - assert len(response.payload.outputs) == 1 + assert response["status"] == "success" + assert isinstance(response["payload"], FijiMacroExecutionResponse) + assert len(response["payload"].outputs) == 1 def test_fiji_batch_message_normalizes_wire_items() -> None: @@ -442,6 +383,7 @@ def test_fiji_batch_message_normalizes_wire_items() -> None: "display_config": { "component_modes": default_config.component_modes(), "component_order": list(default_config.COMPONENT_ORDER), + "declared_axes": STREAM_AXES.to_wire(), "lut": "Grays", "auto_contrast": True, }, @@ -488,6 +430,7 @@ def test_fiji_batch_message_preserves_scoped_wire_component_layout() -> None: "display_config": { "component_modes": component_modes, "component_order": list(component_order), + "declared_axes": STREAM_AXES.to_wire(), "lut": "Grays", "auto_contrast": True, }, @@ -502,11 +445,9 @@ def test_fiji_batch_message_preserves_scoped_wire_component_layout() -> None: } ).batch_message() - assert isinstance(batch.viewer_display_config, FijiDisplayConfig) + assert batch.viewer_display_config == FijiDisplaySettings() assert batch.layout.component_order == component_order - assert batch.layout.component_modes == { - component: component_modes[component] for component in component_order - } + assert batch.layout.component_modes == component_modes def test_fiji_shared_memory_copy_leaves_sender_allocation_owned(monkeypatch) -> None: @@ -579,7 +520,7 @@ def test_fiji_window_item_projection_preserves_nominal_items() -> None: projection = FijiWindowItemProjection.from_items( items, - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerObjectDisplayConfigInput(FijiDisplayConfig()), _component_value_domain( { @@ -595,13 +536,13 @@ def test_fiji_window_item_projection_preserves_nominal_items() -> None: window_items = next(iter(projection.windows.values())) assert window_items == items - assert projection.coordinate_components.channel == ["channel"] - assert projection.coordinate_components.z_axis_components == ["z_index"] - assert projection.coordinate_components.frame == [ + assert projection.coordinate_components.channel == ("channel",) + assert projection.coordinate_components.z_axis_components == ("z_index",) + assert projection.coordinate_components.frame == tuple( component for component in AxisFamily.active().names() if component in {"site", "well", "timepoint"} - ] + ) def test_payload_local_site_axis_projects_exactly_across_fiji_and_napari() -> None: @@ -635,7 +576,7 @@ def test_payload_local_site_axis_projects_exactly_across_fiji_and_napari() -> No } ) - fiji_semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + fiji_semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerObjectDisplayConfigInput(FijiDisplayConfig()), component_domain, ) @@ -662,7 +603,7 @@ def test_payload_local_site_axis_projects_exactly_across_fiji_and_napari() -> No for item in projected_items ] == ["C1_Z0_T1_0_A14", "C1_Z0_T2_0_A14"] - napari_semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + napari_semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerObjectDisplayConfigInput(NapariDisplayConfig()), component_domain, ) @@ -694,7 +635,7 @@ def test_fiji_payload_local_plane_axis_rejects_coordinate_count_mismatch() -> No ) with pytest.raises(ValueError, match="coordinate count does not match"): - FijiPayloadLocalPlaneExpander.expand_item(item) + FijiPayloadLocalPlaneExpander(STREAM_AXES).expand_item(item) def test_fiji_payload_local_plane_axis_rejects_malformed_declarations() -> None: @@ -715,13 +656,13 @@ def test_fiji_payload_local_plane_axis_rejects_malformed_declarations() -> None: } with pytest.raises(ValueError, match="exactly one component declaration"): - FijiPayloadLocalPlaneExpander.expand_item(FijiWireItem.from_payload(payload)) + FijiPayloadLocalPlaneExpander(STREAM_AXES).expand_item(FijiWireItem.from_payload(payload)) payload_without_axis = dict(payload) payload_without_axis["plane_component_values"] = {"site": [1, 2]} payload_without_axis.pop("plane_axis") with pytest.raises(ValueError, match="requires plane_axis"): - FijiPayloadLocalPlaneExpander.expand_item( + FijiPayloadLocalPlaneExpander(STREAM_AXES).expand_item( FijiWireItem.from_payload(payload_without_axis) ) @@ -999,7 +940,7 @@ def convert_work_unit(encoded_rois, _scyjava): ) display_config = FijiDisplayConfig() - semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerObjectDisplayConfigInput(display_config), _component_value_domain( { diff --git a/tests/unit/test_function_artifact_materialization.py b/tests/unit/test_function_artifact_materialization.py index 2c2011389c..4a3e733354 100644 --- a/tests/unit/test_function_artifact_materialization.py +++ b/tests/unit/test_function_artifact_materialization.py @@ -136,7 +136,6 @@ IncompleteFunctionOutputFilenameIdentityError, ) -from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, StreamingViewerSurface, @@ -160,8 +159,6 @@ ) from openhcs.processing.materialization.core import ( MaterializationSpec, - Output, - SavedMaterializationOutputs, materialization_outputs, ) from openhcs.processing.materialization.options import ( @@ -170,12 +167,14 @@ ) from openhcs.core.axes import AxisFamily, Ungrouped from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES +from openhcs.core.streaming_config_declarations import NapariViewer from openhcs.core.payload_axes import PayloadAxes from openhcs.core.axes import ColourAxis class StreamingConfigStub(ViewerDisplayConfigABC): - backend = SimpleNamespace(value="napari_stream") + viewer_family = NapariViewer COMPONENT_ORDER = AxisFamily.active().names() host = "127.0.0.1" transport_mode = "tcp" @@ -190,6 +189,7 @@ def component_modes(self): def display_payload_extra(self): return { + "declared_axes": STREAM_AXES.to_wire(), "colormap": self.colormap.value, "variable_size_handling": self.variable_size_handling.value, } @@ -203,7 +203,7 @@ def streaming_viewer_surface(self, _context): transport_mode=self.transport_mode, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), display_config=self, source=self.viewer_source(_context), @@ -2930,6 +2930,7 @@ def fake_materialize( streaming_config.component_modes() ) assert stream_request.display_config.display_payload_extra() == { + "declared_axes": STREAM_AXES.to_wire(), "colormap": "gray", "variable_size_handling": "pad_to_max", } diff --git a/tests/unit/test_function_outputs.py b/tests/unit/test_function_outputs.py index 67b70b27b1..1453dffb28 100644 --- a/tests/unit/test_function_outputs.py +++ b/tests/unit/test_function_outputs.py @@ -86,7 +86,6 @@ StreamOutputBatch, finalize_function_step_outputs, ) -from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, StreamingViewerSurface, @@ -110,6 +109,8 @@ ) from openhcs.core.axes import Axis, AxisFamily from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES +from openhcs.core.streaming_config_declarations import NapariViewer from openhcs.core.payload_axes import PayloadAxes @@ -254,7 +255,7 @@ def ensure_directory(self, path, backend): class StreamingConfigStub(ViewerDisplayConfigABC): - backend = SimpleNamespace(value="napari_stream") + viewer_family = NapariViewer COMPONENT_ORDER = ("well", "site", "channel", "z_index", "timepoint") host = "127.0.0.1" port = 5555 @@ -264,7 +265,7 @@ def component_modes(self): return {component: "stack" for component in self.COMPONENT_ORDER} def display_payload_extra(self): - return {} + return {"declared_axes": STREAM_AXES.to_wire()} def streaming_viewer_surface(self, context): return StreamingViewerSurface( @@ -275,7 +276,7 @@ def streaming_viewer_surface(self, context): transport_mode=self.transport_mode, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), display_config=self, source=ViewerStreamSourceIdentity( diff --git a/tests/unit/test_g6_viewer_guards.py b/tests/unit/test_g6_viewer_guards.py new file mode 100644 index 0000000000..a79509e044 --- /dev/null +++ b/tests/unit/test_g6_viewer_guards.py @@ -0,0 +1,155 @@ +"""G6 guards: the viewers stay blind to OpenHCS's domain and keep one mechanism each.""" + +from __future__ import annotations + +import ast +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +OPENHCS = ROOT / "openhcs" +RUNTIME = OPENHCS / "runtime" +VIEWER_MODULES = tuple( + sorted( + { + *RUNTIME.glob("*viewer*.py"), + *RUNTIME.glob("napari_*.py"), + *RUNTIME.glob("fiji_*.py"), + *(OPENHCS / "napari_roi_manager").rglob("*.py"), + } + ) +) +FIRST_PARTY_SOURCES = tuple((ROOT / "external").glob("*/src")) + + +def _python_files(*roots: Path) -> tuple[Path, ...]: + return tuple( + path + for root in roots + for path in ([root] if root.is_file() else root.rglob("*.py")) + if "__pycache__" not in path.parts + ) + + +def _names(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + found.add(node.id) + elif isinstance(node, ast.Attribute): + found.add(node.attr) + elif isinstance(node, (ast.ClassDef, ast.FunctionDef)): + found.add(node.name) + elif isinstance(node, ast.alias): + found.add(node.name.rsplit(".", 1)[-1]) + return found + + +def _strings(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + return { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + + +@pytest.mark.parametrize( + "module", + ("openhcs.runtime.napari_viewer_server", "openhcs.runtime.fiji_viewer_server"), +) +def test_viewer_server_imports_no_domain_orchestration_or_config(module) -> None: + pytest.importorskip("napari") + code = ( + "import importlib, json, sys\n" + f"importlib.import_module({module!r})\n" + "print(json.dumps(sorted(name for name in sys.modules if name.startswith('openhcs'))))\n" + ) + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(ROOT), *sys.path])} + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, env=env, check=True, + ) + loaded = json.loads(result.stdout.strip().splitlines()[-1]) + forbidden = tuple( + name + for name in loaded + if name.startswith(("openhcs.microscopes", "openhcs.core.orchestrator")) + or name == "openhcs.core.config" + ) + assert forbidden == () + + +def test_viewer_modules_never_consult_the_process_axis_family() -> None: + offenders = [ + path.relative_to(ROOT).as_posix() + for path in VIEWER_MODULES + if "AxisFamily" in _names(path) + ] + assert offenders == [] + + +def test_flat_viewer_mode_enum_and_label_tables_are_gone() -> None: + deleted = { + "ViewerComponentMode", + "ViewerComponentModeGroups", + "viewer_component_mode_value", + "ViewerAxisLabelStrategy", + "role_modes_from_wire", + "mode_field_for_axis", + "NapariDisplayWireField", + "FijiDisplayWireField", + } + offenders = { + path.relative_to(ROOT).as_posix(): sorted(deleted & _names(path)) + for path in _python_files(OPENHCS, *FIRST_PARTY_SOURCES) + if deleted & _names(path) + } + assert offenders == {} + + +def test_one_control_action_family_for_every_viewer() -> None: + deleted = { + "NapariControlMessageAction", + "NapariUnknownControlMessageAction", + "FijiControlMessagePlan", + "FijiControlMessageAuthority", + "FijiControlRequestContext", + "FijiControlMessageResponse", + } + offenders = { + path.relative_to(ROOT).as_posix(): sorted( + name + for name in _names(path) + if name in deleted or (name.startswith("FijiUnsupported") and "Control" in name) + ) + for path in VIEWER_MODULES + } + assert {path: names for path, names in offenders.items() if names} == {} + fiji_literals = _strings(RUNTIME / "fiji_viewer_server.py") + assert fiji_literals.isdisjoint({"shutdown", "force_shutdown", "clear_state"}) + + +def test_viewer_identity_has_one_declaration_per_viewer() -> None: + offenders = [ + path.relative_to(ROOT).as_posix() + for path in _python_files(OPENHCS) + if {"ViewerDeclarationABC", "NapariViewerDeclaration", "FijiViewerDeclaration", + "viewer_type_declaration", "from_wire_value", "from_config_key"} & _names(path) + ] + assert offenders == [] + + +def test_viewer_dtos_do_not_import_the_execution_dtos() -> None: + tree = ast.parse((OPENHCS / "agent/dto/viewer.py").read_text(encoding="utf-8")) + imported = { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module is not None + } + assert "openhcs.agent.dto.execution" not in imported diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 1832b8b1f5..ee6fd4a87b 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -128,6 +128,7 @@ ) from openhcs.core.axes import AxisFamily from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES from openhcs.core.payload_axes import PayloadAxes @@ -348,7 +349,7 @@ def component_modes(self): return {} def display_payload_extra(self): - return {} + return {"declared_axes": STREAM_AXES.to_wire()} class _TestViewerFilenameParser(ViewerFilenameParserABC): @@ -857,9 +858,10 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( image_metadata=metadata, plane_component_domain=ViewerComponentValueDomainPayload.from_wire_mapping( {"z_index": z_values}, context="materialized point source domain", + declared_axes=STREAM_AXES, ), ) - points, properties = viewer_server._build_nd_points([item], projection) + points, properties = viewer_server._build_nd_points([item], projection, ("z_index",)) assert points.tolist() == [[2.375, 1.25, 3.5]] assert properties["label"] == [7] assert properties["object_label"] == [7] @@ -927,6 +929,7 @@ def test_payload_label_roi_reopen_preserves_geometric_plane_domain( from polystore.streaming_constants import StreamingDataType plane_domain = ViewerComponentValueDomainPayload.from_wire_mapping( fields[ViewerWireField.PLANE_COMPONENT_VALUES.value], context="saved label ROI", + declared_axes=STREAM_AXES, ) assert plane_domain.to_wire_mapping() == {"z_index": list(z_values)} diff --git a/tests/unit/test_napari_accepted_work_settlement.py b/tests/unit/test_napari_accepted_work_settlement.py index 4e1b08772d..7f2e5ae021 100644 --- a/tests/unit/test_napari_accepted_work_settlement.py +++ b/tests/unit/test_napari_accepted_work_settlement.py @@ -26,7 +26,7 @@ from openhcs.core.config import NapariDimensionMode, NapariDisplayConfig from openhcs.runtime.napari_viewer_server import ( - NapariSettleControlMessageAction, + NapariControlAction, NapariViewerServer, ) from openhcs.runtime.viewer_protocol import ( @@ -36,6 +36,7 @@ ViewerSettlePhase, ViewerSettleProgress, ) +from openhcs.core.streaming_config_declarations import NapariViewer @pytest.fixture @@ -137,7 +138,7 @@ def image_item(): def settle(receiver): - response = NapariSettleControlMessageAction().handle(receiver, {}) + response = NapariControlAction.for_message_type("settle").handle(receiver, {}) return response, ViewerSettleProgress.from_response(ViewerControlResponse(response)) @@ -180,7 +181,7 @@ def test_saved_roi_reopen_reports_pre_route_failure_and_recovers( # previous failed cycle must not become a permanent unrelated failure. assert wire_receiver(image_item())["status"] == "success" admitted = ViewerSettleProgress.from_response(ViewerControlResponse( - NapariSettleControlMessageAction().transport_thread_response(receiver, {}) + NapariControlAction.for_message_type("settle").transport_thread_response(receiver, {}) )) assert admitted.phase is ViewerSettlePhase.RUNNING assert admitted.total_update_count == 0 @@ -203,7 +204,7 @@ def test_previous_complete_cannot_settle_new_accepted_work(receiver): ) assert not receiver.accepted_stream_batches.empty() admitted = ViewerSettleProgress.from_response(ViewerControlResponse( - NapariSettleControlMessageAction().transport_thread_response(receiver, {}) + NapariControlAction.for_message_type("settle").transport_thread_response(receiver, {}) )) assert admitted.phase is ViewerSettlePhase.RUNNING assert admitted.total_update_count == 0 @@ -217,7 +218,6 @@ def test_initial_settlement_is_observable_before_qt_intake_and_completes( from zmqruntime.transport import get_control_port from polystore.filemanager import FileManager from polystore.streaming.viewer_transport import ViewerTransportEndpoint - from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.streaming_config_factory import StreamingViewerRuntimeConfig from openhcs.runtime.napari_stream_visualizer import NapariStreamVisualizer @@ -266,7 +266,7 @@ def test_initial_settlement_is_observable_before_qt_intake_and_completes( runtime_config=StreamingViewerRuntimeConfig( transport_endpoint=ViewerTransportEndpoint( port=receiver.port, host="localhost", transport_mode=receiver.transport_mode, - ), persistent=False, viewer_type=ViewerType.NAPARI, + ), persistent=False, viewer_family=NapariViewer, ), ) client.lifecycle_state.mark_connected_external() @@ -445,7 +445,7 @@ def test_clear_state_discards_pre_route_failure(receiver, tmp_path): == "success" ) receiver.process_accepted_stream_messages() - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() assert settle(receiver)[1].phase is ViewerSettlePhase.COMPLETE assert receiver.layer_route_state.update_failure_message() is None diff --git a/tests/unit/test_napari_native_frame_applied.py b/tests/unit/test_napari_native_frame_applied.py new file mode 100644 index 0000000000..0752954937 --- /dev/null +++ b/tests/unit/test_napari_native_frame_applied.py @@ -0,0 +1,33 @@ +"""Native frame completion against the installed Napari layer slicing API.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +napari_components = pytest.importorskip("napari.components") + +from openhcs.runtime.napari_viewer_server import NapariLayerDisplayPipeline # noqa: E402 + + +def _pipeline_for(viewer) -> SimpleNamespace: + return SimpleNamespace( + server=SimpleNamespace(require_viewer=lambda: viewer), + _native_frame_mutation_depth=0, + ) + + +def test_native_frame_applied_compares_slices_from_real_napari_layers(): + viewer = napari_components.ViewerModel() + layer = viewer.add_image(np.zeros((3, 8, 8), dtype=np.uint16)) + pipeline = _pipeline_for(viewer) + + assert NapariLayerDisplayPipeline.native_frame_applied(pipeline) + + applied_slice = layer._slice_input + viewer.dims.set_current_step(0, 2) + assert layer._slice_input != applied_slice + layer._slicing_state._slice_input = applied_slice + assert not NapariLayerDisplayPipeline.native_frame_applied(pipeline) diff --git a/tests/unit/test_napari_orthogonal_navigation.py b/tests/unit/test_napari_orthogonal_navigation.py index 66452c18d9..bdb94f58b1 100644 --- a/tests/unit/test_napari_orthogonal_navigation.py +++ b/tests/unit/test_napari_orthogonal_navigation.py @@ -6,7 +6,6 @@ import pytest from napari.components import ViewerModel from qtpy.QtCore import Qt -from zmqruntime.viewer_protocol import ViewerComponentMode from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowNavigationRequest @@ -17,19 +16,21 @@ NapariLayerRouteStateStore, ) from openhcs.runtime.napari_viewer_server import ( - NapariNavigationControlMessageAction, + NapariNavigationControlAction, NapariResultSelectionController, NapariViewerStateProjection, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerLayerAxisProjection, ) from openhcs.runtime.viewer_controls import ( ViewerNavigationControlOptions, - ViewerResultElementCoordinateAuthority, + ViewerResultElementCoordinates, ) +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES class NativeHarness(SimpleNamespace): @@ -38,7 +39,7 @@ class NativeHarness(SimpleNamespace): def mount(server, route, layer, components, offsets=None, payload_axes=()): offsets = offsets or (0,) * len(components) - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() values = { axis: list(range(layer.data.shape[index])) for index, axis in enumerate(components) @@ -46,8 +47,9 @@ def mount(server, route, layer, components, offsets=None, payload_axes=()): presentation = NapariAxisPresentation( entries=semantics.entries, layout=ViewerComponentLayout.from_parts( - component_modes={axis: ViewerComponentMode.STACK for axis in components}, + component_modes={axis: NapariSlots.Stack.wire_value for axis in components}, component_order=components, + declared_axes=STREAM_AXES, ), route_key=route, projection=ViewerLayerAxisProjection( @@ -115,7 +117,7 @@ def test_native_planes_preserve_extra_axes_world_identity_and_alignment(leading, viewer.dims.axis_labels = axes viewer.dims.point = image.data_to_world(anchor) previous_point = tuple(viewer.dims.point) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, {"payload": ViewerNavigationControlOptions(route_key="raw", display_axes=pair)}, ) @@ -157,7 +159,7 @@ def test_nonspatial_or_missing_axes_reject_before_any_mutation(pair): tuple(viewer.dims.current_step), viewer.layers.selection.active, ) - reply = NapariNavigationControlMessageAction().handle( + reply = NapariNavigationControlAction().handle( server, { "payload": ViewerNavigationControlOptions( @@ -187,7 +189,7 @@ def test_visible_planar_shapes_reject_cross_section_but_hidden_shapes_allow_raw( server = harness(viewer) mount(server, "raw", image, ("z_index",)) before = tuple(viewer.dims.order) - action = NapariNavigationControlMessageAction() + action = NapariNavigationControlAction() request = ViewerNavigationControlOptions( route_key="raw", display_axes=("z_index", "x") ) @@ -208,7 +210,7 @@ def test_visible_planar_shapes_reject_cross_section_but_hidden_shapes_allow_raw( ) def test_hidden_indices_use_actual_displayed_dimensions(displayed, expected): assert ( - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=(1, 2, 4, 7), axis_labels=("channel", "z_index", "y", "x"), displayed_axis_indices=displayed, @@ -219,14 +221,14 @@ def test_hidden_indices_use_actual_displayed_dimensions(displayed, expected): def test_displayed_z_may_be_fractional_and_span_slices_but_hidden_y_must_not(): - assert ViewerResultElementCoordinateAuthority.axis_indices( + assert ViewerResultElementCoordinates.axis_indices( coordinates=((1, 2.25, 4, 7), (1, 3.5, 4, 8)), axis_labels=("channel", "z_index", "y", "x"), displayed_axis_indices=(1, 3), spatial_axis_labels=("z_index", "y", "x"), ) == {"channel": 1, "y": 4} with pytest.raises(ValueError, match="spans multiple 'y'"): - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=((1, 2.25, 4, 7), (1, 3.5, 5, 8)), axis_labels=("channel", "z_index", "y", "x"), displayed_axis_indices=(1, 3), @@ -262,7 +264,7 @@ def test_shared_route_offsets_survive_orientation_and_local_navigation(): server = harness(viewer) mount(server, "raw", image, ("channel", "z_index"), (4, 16)) mount(server, "other", other, ("channel", "z_index")) - action = NapariNavigationControlMessageAction() + action = NapariNavigationControlAction() reply = action.handle( server, { @@ -301,7 +303,7 @@ def test_lower_rank_presentation_maps_to_right_aligned_viewer_dimensions(): assert presentation.viewer_dimension_indices(4) == (1, 2, 3) order = presentation.display_order(("z_index", "x"), tuple(viewer.dims.order)) assert order[-2:] == (1, 3) - step = NapariNavigationControlMessageAction().axis_step( + step = NapariNavigationControlAction().axis_step( server, image, ViewerNavigationControlOptions(route_key="raw", axis_indices={"z_index": 2}), @@ -324,14 +326,14 @@ def test_plugin_choices_and_button_use_same_native_navigation_owner(qtbot, monke ) assert pairs == (("z_index", "y"), ("z_index", "x"), ("y", "x")) assert all(set(pair).issubset(presentation.spatial_axis_labels) for pair in pairs) - original = NapariNavigationControlMessageAction.handle + original = NapariNavigationControlAction.handle submitted = [] def record(action, owner, message): submitted.append(message["payload"]) return original(action, owner, message) - monkeypatch.setattr(NapariNavigationControlMessageAction, "handle", record) + monkeypatch.setattr(NapariNavigationControlAction, "handle", record) widget.planes.setCurrentIndex(pairs.index(("z_index", "x"))) qtbot.mouseClick(widget.apply_button, Qt.LeftButton) assert submitted == [ diff --git a/tests/unit/test_napari_render_snapshot.py b/tests/unit/test_napari_render_snapshot.py index 9b1409f3ff..b5c30fc627 100644 --- a/tests/unit/test_napari_render_snapshot.py +++ b/tests/unit/test_napari_render_snapshot.py @@ -20,9 +20,9 @@ from openhcs.agent.dto.viewer import ViewerWindowSnapshotRequest from openhcs.runtime.napari_viewer_server import ( NapariAcceptedControlRequest, - NapariControlMessageAction, + NapariControlAction, NapariLayerDisplayPipeline, - NapariScreenshotControlMessageAction, + NapariScreenshotControlAction, NapariViewerServer, ) from openhcs.runtime.viewer_snapshot import ViewerWindowSnapshotService @@ -94,11 +94,11 @@ def test_default_render_condition_flows_through_registered_queue_and_real_paint( observation_timeout_s=0.5, ) assert request.frame_condition is WindowSnapshotFrameCondition.RENDER_COMPLETE - action = NapariControlMessageAction.for_message_type("screenshot") - assert isinstance(action, NapariScreenshotControlMessageAction) + action = NapariControlAction.for_message_type("screenshot") + assert isinstance(action, NapariScreenshotControlAction) assert isinstance(action, ViewerWindowSnapshotService) assert action.__class__.__mro__.index( - NapariControlMessageAction + NapariControlAction ) < action.__class__.__mro__.index(ViewerWindowSnapshotService) reply = enqueue(server, request) assert not reply.done() and not tuple(tmp_path.glob("*.png")) @@ -162,7 +162,7 @@ def test_real_vispy_native_binding_through_original_registered_queue( assert isinstance(native, QGLWidget) and isinstance(native, QtCore.QObject) native.hide() server.viewer.window.qt_viewer.canvas = canvas - action = NapariControlMessageAction.for_message_type("screenshot") + action = NapariControlAction.for_message_type("screenshot") assert action.qt_core() is QtCore print(f"QtPy binding={API_NAME}; native MRO={type(native).__mro__}") try: @@ -194,7 +194,7 @@ def test_real_vispy_native_binding_through_original_registered_queue( def test_new_control_case_requires_only_registered_declaration_and_hook(queued_viewer): _, _, server = queued_viewer - class DeclarationOnlyControl(NapariControlMessageAction): + class DeclarationOnlyControl(NapariControlAction): message_type = "source_qa_declaration_only" def handle(self, server, message): @@ -212,7 +212,7 @@ def handle(self, server, message): server.process_messages() assert pickle.loads(reply.result(timeout=0)) == {"status": "success", "token": 17} finally: - NapariControlMessageAction.__registry__.pop(DeclarationOnlyControl.message_type) + NapariControlAction._registry.pop(DeclarationOnlyControl.message_type) def test_deferred_reply_projection_error_completes_accepted_request( @@ -224,7 +224,7 @@ def reject_native_projection(server, response, dimensions): raise ValueError("native dimension projection failed") monkeypatch.setattr( - NapariScreenshotControlMessageAction, "_native_reply", + NapariScreenshotControlAction, "_native_reply", staticmethod(reject_native_projection), ) request = ViewerWindowSnapshotRequest.from_fields( diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 6a85ab226f..31bd0d8bd3 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -20,8 +20,8 @@ from openhcs.core.config import ( NapariDimensionMode, NapariDisplayConfig, - NapariVariableSizeHandling, ) +from openhcs.runtime.viewer_display import NapariVariableSizeHandling from openhcs.core.runtime_image_values import ImagePayloadMetadata from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.runtime.napari_streaming_handlers import ( @@ -33,7 +33,7 @@ ) from openhcs.runtime.napari_viewer_server import ( NapariAcceptedControlRequest, - NapariControlMessageAction, + NapariControlAction, NapariComponentAwareDisplayCoordinator, NapariImagePayloadLayoutRole, NapariLayerDisplayPipeline, @@ -42,7 +42,7 @@ NapariShapesLayerDisplayHandler, NapariPointsLayerDisplayHandler, NapariSelectablePresentationRetention, - NapariNavigationControlMessageAction, + NapariNavigationControlAction, NapariResultSelectionController, NapariResultSelectionGroupBinding, NapariStreamLayerContext, @@ -57,19 +57,20 @@ ) from openhcs.runtime.viewer_controls import ( ViewerLayerRetirementControlOptions, ViewerNavigationControlOptions, - ViewerPointCoordinateAuthority, + ViewerPointCoordinates, ) from openhcs.core.roi_point_metadata import ROIFractionalZ from openhcs.runtime.viewer_protocol import ( OpenHCSViewerControlMessageType, ViewerSettlePhase, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentNameMetadata, ViewerComponentValueDomainPayload, ViewerMappingDisplayConfigInput, ViewerRouteComponentValueTracker, ) +from tests.unit.viewer_axes_fixture import STREAM_AXES @pytest.fixture @@ -130,17 +131,19 @@ def enqueue( time_mode=NapariDimensionMode.LAYER, variable_size_handling=NapariVariableSizeHandling.PAD_TO_MAX, ) - semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": config.component_modes(), "component_order": config.COMPONENT_ORDER, + "declared_axes": STREAM_AXES.to_wire(), } ), ViewerComponentValueDomainPayload.from_ordered_wire_mapping( {"well": domain or [well], "channel": channels or [channel], "site": [1], "z_index": z_domain or [z_index], "timepoint": [1]}, - context="synthetic transition" + context="synthetic transition", + declared_axes=STREAM_AXES, ), ) context = NapariStreamLayerContext( @@ -205,7 +208,7 @@ def test_shared_slot_batch_aligns_two_manual_channels_and_wells( receiver, result_first, data_type, replace_layers, ): """Four earlier 5D raw routes promote as one nonrecursive native batch.""" - from openhcs.runtime.napari_viewer_server import NapariNavigationControlMessageAction + from openhcs.runtime.napari_viewer_server import NapariNavigationControlAction from openhcs.runtime.viewer_controls import ViewerNavigationControlOptions receiver.replace_layers = replace_layers @@ -245,10 +248,10 @@ def result(): if result_first: result_route = result() native = receiver.layer_route_state.layer(result_route) - prepared = NapariNavigationControlMessageAction().prepare( + prepared = NapariNavigationControlAction().prepare( receiver, ViewerNavigationControlOptions(route_key=result_route, data_index=0), ) - NapariNavigationControlMessageAction().apply_prepared(receiver, prepared) + NapariNavigationControlAction().apply_prepared(receiver, prepared) identities = set(native.features.iloc[list(native.selected_data)][NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE]) raw() current = receiver.layer_route_state.layer(result_route) @@ -271,7 +274,7 @@ def result(): state = receiver.layer_route_state.dimension_state_for(route) assert state.axis_labels == ("site", "channel", "z_index", "timepoint", "well", "y", "x") assert tuple(layer.contrast_limits) == (0, 500) and layer.gamma == 0.8 and layer.opacity == 0.4 - target = NapariNavigationControlMessageAction().prepare( + target = NapariNavigationControlAction().prepare( receiver, ViewerNavigationControlOptions( route_key=route, axis_indices=state.presentation.route_local_component_indices( state.presentation.projection.coordinate_index( @@ -283,7 +286,7 @@ def result(): ), ) result_layer.visible = False - NapariNavigationControlMessageAction().apply_prepared(receiver, target) + NapariNavigationControlAction().apply_prepared(receiver, target) assert receiver.viewer.dims.current_step[1] == channel - 1 assert receiver.viewer.dims.current_step[4] == wells.index(well) assert np.max(layer._data_view) == 10 * wells.index(well) + channel @@ -315,7 +318,7 @@ def test_clear_before_replacement_keeps_settled_pixels_inventory_and_calibration domain=["A01", "A14"], ) assert route_b == route - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() assert receiver.layer_route_state.layer(route) is native_a assert receiver.component_groups.existing_items_for(route)[0].data is a assert receiver.component_groups.existing_items_for(route) is items_a @@ -356,7 +359,7 @@ def test_clear_between_replacement_shapes_chunks_preserves_settled_native_layer( domain=["A01", "A14"], ) assert route_b == route - advance_in_qt(receiver, route, update_b, receiver.clear_accumulated_stream_state) + advance_in_qt(receiver, route, update_b, receiver.clear_stream_state) assert receiver.layer_route_state.layer(route) is native_a assert receiver.component_groups.existing_items_for(route) is items_a assert native_a.visible and len(native_a.data) == 1 @@ -394,7 +397,7 @@ def test_completed_shapes_publish_full_inventory_and_declared_domain( layer = receiver.layer_route_state.layer(route) assert layer.visible and len(layer.data) == len(layer.features) == 3 assert receiver.component_groups.existing_items_for(route)[0].data is shapes - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() assert receiver.layer_route_state.layer(route) is layer assert receiver.component_values.shared_values_for(["well"]) == { "well": ["A01", "A02"] @@ -416,7 +419,7 @@ def test_clear_between_shapes_chunks_does_not_retain_partial_native_payload( route, update = enqueue( receiver, shapes, producer="roi", data_type=StreamingDataType.SHAPES ) - advance_in_qt(receiver, route, update, receiver.clear_accumulated_stream_state) + advance_in_qt(receiver, route, update, receiver.clear_stream_state) assert not receiver.viewer.layers assert not receiver.layer_route_state.layers assert not receiver.component_groups.groups @@ -434,7 +437,7 @@ def test_native_deletion_then_clear_reprojects_surviving_shared_axis(receiver): assert native_b.translate[0] == 1 receiver.viewer.layers.selection.active = native_b receiver.viewer.layers.remove(receiver.layer_route_state.layer(route_a)) - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() assert receiver.component_values.shared_values_for(["well"]) == {"well": ["A02"]} assert native_b.translate[0] == 0 presentation = receiver.layer_route_state.dimension_state_for(route_b).presentation @@ -466,7 +469,7 @@ def test_pruning_interior_shared_value_rematerializes_from_settled_items(receive assert old_native.data.shape == (3, 2, 2) receiver.viewer.layers.selection.active = old_native receiver.viewer.layers.remove(receiver.layer_route_state.layer(middle)) - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() native = receiver.layer_route_state.layer(sparse) assert native is not old_native and old_native not in receiver.viewer.layers assert receiver.component_groups.existing_items_for(sparse) is original_items @@ -482,7 +485,7 @@ def test_pruning_interior_shared_value_rematerializes_from_settled_items(receive assert receiver.component_values.shared_values_for(["well"]) == { "well": ["A01", "A03"] } - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() assert receiver.layer_route_state.layer(sparse) is native @@ -491,7 +494,7 @@ def test_native_deletion_with_queued_replacement_clear_cannot_resurrect_route(re advance_in_qt(receiver, route, update_a) _, update_b = enqueue(receiver, np.full((2, 2), 9)) receiver.viewer.layers.remove(receiver.layer_route_state.layer(route)) - receiver.clear_accumulated_stream_state() + receiver.clear_stream_state() advance_in_qt(receiver, route, update_b) assert not receiver.viewer.layers assert not receiver.layer_route_state.layers @@ -664,7 +667,7 @@ def test_retirement_prevalidates_entire_set_without_collateral_mutation(receiver request = replace(request, operation_deadline=OperationDeadline("expired", 5000, 0)) layers = tuple(receiver.viewer.layers) groups = dict(receiver.component_groups.groups) - response = NapariControlMessageAction.for_message_type( + response = NapariControlAction.for_message_type( OpenHCSViewerControlMessageType.RETIRE_LAYERS.value, ).handle(receiver, {"payload": request}) assert response["status"] == "error" @@ -775,7 +778,7 @@ def test_retirement_retains_survivor_source_members_without_late_navigation( if selected: # Domain expansion now retains the actual source frame. This fixture # explicitly selects A01 members, so choose A01 before native assignment. - prepared = NapariNavigationControlMessageAction().prepare( + prepared = NapariNavigationControlAction().prepare( receiver, ViewerNavigationControlOptions(route_key=route, data_index=0), ) receiver.viewer.dims.current_step = prepared.viewer_step @@ -839,7 +842,7 @@ class NewDeclaredPointsHandler(IndependentPresentationCapability, NapariPointsLa old = receiver.layer_route_state.layer(route) # Select the declared A03 source frame before retaining its member. Native # selected_data alone can contain an off-slice Points row. - prepared = NapariNavigationControlMessageAction().prepare( + prepared = NapariNavigationControlAction().prepare( receiver, ViewerNavigationControlOptions(route_key=route, data_index=1, visible=True, selected=True), ) @@ -921,7 +924,7 @@ def test_last_source_frame_retains_points_and_shapes_during_domain_pruning( for route in routes: layer = receiver.layer_route_state.layer(route) selected = len(layer.data) - 1 - assert selected in layer._indices_view + assert selected in layer._view_indices layer.selected_data = {selected} retained[route] = (layer, layer.features.iloc[selected][feature]) QApplication.instance().processEvents() @@ -944,7 +947,7 @@ def test_last_source_frame_retains_points_and_shapes_during_domain_pruning( native = receiver.layer_route_state.layer(route) assert (native is not old) is joined assert set(native.features.iloc[list(native.selected_data)][feature]) == {identity} - assert set(native.selected_data) <= set(native._indices_view) + assert set(native.selected_data) <= set(native._view_indices) state = receiver.layer_route_state.dimension_state_for(route) assert state.presentation.projection.component_values["well"] == ( ["A01", "A03"] if joined else ["A03"] @@ -1105,7 +1108,7 @@ def test_fractional_spatial_selection_survives_real_qt_rematerialization(receive ) original_coordinates = old.data[1, spatial_dimensions].copy() sources = receiver.component_groups.existing_items_for(route) - navigation = NapariNavigationControlMessageAction() + navigation = NapariNavigationControlAction() def select_and_check(layer): member = tuple(layer.features[feature]).index(identity) @@ -1156,7 +1159,7 @@ def _coordinate_value(cls, value, *, axis_label): admitted.append(axis_label) return super()._coordinate_value(value, axis_label=axis_label) - class RecordedPointCoordinates(CoordinateAdmissionRecording, ViewerPointCoordinateAuthority): + class RecordedPointCoordinates(CoordinateAdmissionRecording, ViewerPointCoordinates): pass class RecordedPointsHandler(NapariPointsLayerDisplayHandler): @@ -1170,7 +1173,7 @@ class RecordedPointsHandler(NapariPointsLayerDisplayHandler): advance_in_qt(receiver, route, update) layer = receiver.layer_route_state.layer(route) presentation = receiver.layer_route_state.dimension_state_for(route).presentation - action = NapariNavigationControlMessageAction() + action = NapariNavigationControlAction() for pair in (("y", "x"), ("z_index", "x"), ("z_index", "y")): action.result_element_axis_indices( receiver, layer, route, 0, diff --git a/tests/unit/test_napari_shared_axis_navigation.py b/tests/unit/test_napari_shared_axis_navigation.py index 82a464f90d..3469985b8b 100644 --- a/tests/unit/test_napari_shared_axis_navigation.py +++ b/tests/unit/test_napari_shared_axis_navigation.py @@ -5,11 +5,12 @@ from openhcs.runtime.napari_streaming_handlers import NapariAxisPresentation from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerLayerAxisProjection, ) -from zmqruntime.viewer_protocol import ViewerComponentMode +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES @pytest.mark.parametrize( @@ -19,12 +20,13 @@ def test_navigation_inverts_route_placement_with_inserted_singleton_axes( display_axes, viewer_origin ): - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() presentation = NapariAxisPresentation( entries=semantics.entries, layout=ViewerComponentLayout.from_parts( - component_modes={axis: ViewerComponentMode.STACK for axis in display_axes}, + component_modes={axis: NapariSlots.Stack.wire_value for axis in display_axes}, component_order=display_axes, + declared_axes=STREAM_AXES, ), route_key="processed-channel", projection=ViewerLayerAxisProjection( diff --git a/tests/unit/test_napari_streaming_handlers.py b/tests/unit/test_napari_streaming_handlers.py index 87dd9419ef..3096ac207a 100644 --- a/tests/unit/test_napari_streaming_handlers.py +++ b/tests/unit/test_napari_streaming_handlers.py @@ -12,25 +12,21 @@ from napari.layers.shapes._shapes_constants import ShapeType from polystore.streaming.identity import StreamProducerIdentity from polystore.streaming_constants import StreamingDataType -from zmqruntime.viewer_protocol import ViewerComponentMode from openhcs.core.axes import AxisFamily from openhcs.core.artifacts import ObjectArtifactSubjectBinding from openhcs.core.config import ( NapariDisplayConfig, NapariDimensionMode, - NapariVariableSizeHandling, ) +from openhcs.runtime.viewer_display import NapariVariableSizeHandling from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, ) from openhcs.core.runtime_plane_projection import RuntimePlaneAxis from openhcs.core.source_spatial_domain import SourceSpatialDomain -from openhcs.core.streaming_config_factory import ViewerProcessLaunchConfig from openhcs.runtime.viewer_protocol import ( NapariLayerKind, - NapariViewerServerRequest, - ViewerControlMessageType, ViewerControlResponseField, ViewerNavigationControlOptions, ViewerPayloadControlOptions, @@ -45,7 +41,7 @@ ) from openhcs.runtime.napari_streaming_handlers import ( NapariAggregateAxisBinding, - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariAggregateAxisBindingSet, NapariAxisPresentation, NapariBatchProcessorStore, @@ -54,7 +50,7 @@ NapariImageLayerPresentationPolicy, NapariLayerBatchDebouncePolicy, NapariLayerRouteStateStore, - NapariLayerUpdateAuthority, + NapariLayerUpdates, NapariPendingLayerUpdate, NapariShapeFeatureColumns, NapariShapeLayerPayload, @@ -63,8 +59,8 @@ ) from openhcs.runtime.viewer_component_system import ( ViewerComponentAxisSemantics, - ViewerComponentAxisSemanticsAuthority, - ViewerComponentCoordinateAuthority, + ViewerComponentAxisSemanticsFactory, + ViewerComponentCoordinates, ViewerComponentLayout, ViewerComponentMetadataNormalizer, ViewerComponentNameMetadata, @@ -79,7 +75,6 @@ from openhcs.runtime.viewer_protocol import ( NapariLayerKind, ViewerComponentValueOrdering, - ViewerControlMessageType, ViewerControlResponse, ViewerControlResponseField, ViewerIntensityWindowControlOptions, @@ -93,6 +88,10 @@ ViewerStateControlOptions, ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES + +ALL_STACKED = {name: "stack" for name in STREAM_AXES.names()} from openhcs.domains.microscopy.axes import Microscopy from openhcs.core.payload_axes import PayloadAxes @@ -101,11 +100,12 @@ def _component_name_metadata(payload, context="test component metadata"): return ViewerComponentNameMetadata.from_wire_mapping( payload, context=context, + declared_axes=STREAM_AXES, ) def _component_value_domain(payload, context="test component value domain"): - return ViewerComponentValueDomainPayload.from_wire_mapping(payload, context=context) + return ViewerComponentValueDomainPayload.from_wire_mapping(payload, context=context, declared_axes=STREAM_AXES) def _layer_item( @@ -172,7 +172,7 @@ def test_napari_stream_context_reconstructs_exact_source_spatial_domain(): }, "test image payload", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) @@ -212,13 +212,14 @@ def _axis_presentation( ) ) ) - component_axis_semantics = ViewerComponentAxisSemanticsAuthority.empty() + component_axis_semantics = ViewerComponentAxisSemanticsFactory.empty() component_layout = ViewerComponentLayout.from_parts( component_modes={ - component: ViewerComponentMode.STACK + component: NapariSlots.Stack.wire_value for component in display_axis_components }, component_order=display_axis_components, + declared_axes=STREAM_AXES, ) return NapariAxisPresentation( entries=_component_value_domain(component_values).entries, @@ -400,6 +401,7 @@ def test_component_value_domain_wire_mapping_normalizes_variable_axis_values(): domain = ViewerComponentValueDomainPayload.from_wire_mapping( {"timepoint": ["1", 1], "channel": ["2", 2], "well": ["A01"]}, context="mixed wire domain", + declared_axes=STREAM_AXES, ) assert domain.to_wire_mapping() == { @@ -786,7 +788,7 @@ def test_napari_state_control_message_honors_state_request_payload(): _layer_item({"site": 1}, data=np.ones((2, 2), dtype=np.uint8)) ) - response = napari_viewer_server.NapariStateControlMessageAction().handle( + response = napari_viewer_server.NapariStateControlAction().handle( server, { "type": "state", @@ -960,7 +962,7 @@ def _apply_intensity_window( route_key, axis_indices, ): - return napari_viewer_server.NapariIntensityWindowControlMessageAction().handle( + return napari_viewer_server.NapariIntensityWindowControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ( @@ -1179,7 +1181,7 @@ def test_napari_intensity_window_uses_matching_raw_payloads_not_sparse_padding() ) server.component_groups.items_for(route_key).extend((first, second, excluded)) - response = napari_viewer_server.NapariIntensityWindowControlMessageAction().handle( + response = napari_viewer_server.NapariIntensityWindowControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ( @@ -1209,7 +1211,7 @@ def test_napari_intensity_window_uses_matching_raw_payloads_not_sparse_padding() assert layer.contrast_limits == (10.0, 60.0) all_coordinates_response = ( - napari_viewer_server.NapariIntensityWindowControlMessageAction().handle( + napari_viewer_server.NapariIntensityWindowControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ( @@ -1283,7 +1285,7 @@ def test_napari_intensity_window_fails_closed( server.layer_route_state.set_layer(route_key, layer) server.component_groups.items_for(route_key).extend(items) - response = napari_viewer_server.NapariIntensityWindowControlMessageAction().handle( + response = napari_viewer_server.NapariIntensityWindowControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ( @@ -1305,7 +1307,7 @@ def test_napari_intensity_window_fails_closed( def test_napari_layer_update_authority_replaces_existing_image_without_global_axis_labels(): viewer = _FakeViewer() layers = {} - authority = NapariLayerUpdateAuthority() + authority = NapariLayerUpdates() first = authority.create_or_update( layer_kind=NapariLayerKind.IMAGE, @@ -1366,7 +1368,7 @@ def test_napari_layer_update_authority_preserves_user_selected_layer(): viewer.layers.append(selected_layer) viewer.layers.selection.active = selected_layer layers = {} - authority = NapariLayerUpdateAuthority() + authority = NapariLayerUpdates() new_layer = authority.create_or_update( layer_kind=NapariLayerKind.IMAGE, @@ -1388,7 +1390,7 @@ def test_napari_layer_update_authority_preserves_user_selected_layer(): def test_napari_layer_update_authority_transfers_active_replaced_layer(): viewer = _FakeViewer() layers = {} - authority = NapariLayerUpdateAuthority() + authority = NapariLayerUpdates() first = authority.create_or_update( layer_kind=NapariLayerKind.IMAGE, @@ -1423,7 +1425,7 @@ def test_napari_layer_update_authority_transfers_active_replaced_layer(): def test_napari_layer_update_authority_marks_color_images_as_rgb(): viewer = _FakeViewer() layers = {} - authority = NapariLayerUpdateAuthority() + authority = NapariLayerUpdates() image = np.zeros((2, 16, 16, 3), dtype=np.uint8) layer = authority.create_or_update( @@ -1582,9 +1584,11 @@ def test_napari_viewer_model_keeps_declared_plate_axis_sizes_across_routes() -> entries=_component_value_domain(full_component_values).entries, layout=ViewerComponentLayout.from_parts( component_modes={ - component: ViewerComponentMode.STACK for component in full_route_axes + **ALL_STACKED, + **{component: NapariSlots.Stack.wire_value for component in full_route_axes}, }, component_order=full_route_axes, + declared_axes=STREAM_AXES, ), ) full_work = pipeline.display_layer_batch( @@ -1609,9 +1613,11 @@ def test_napari_viewer_model_keeps_declared_plate_axis_sizes_across_routes() -> entries=_component_value_domain(reduced_component_values).entries, layout=ViewerComponentLayout.from_parts( component_modes={ - component: ViewerComponentMode.STACK for component in reduced_route_axes + **ALL_STACKED, + **{component: NapariSlots.Stack.wire_value for component in reduced_route_axes}, }, component_order=reduced_route_axes, + declared_axes=STREAM_AXES, ), ) reduced_work = pipeline.display_layer_batch( @@ -1701,7 +1707,7 @@ def test_napari_display_pipeline_applies_dimension_labels_to_layer_route_state() assert axis_labels == ("channel", "y", "x") state = pipeline.server.layer_route_state.dimension_state_for("route-labels") - assert state.labels == {"channel": ["Ch1: DAPI", "Ch 2"]} + assert state.labels == {"channel": ["Ch 1: DAPI", "Ch 2"]} assert state.stack_axes == ("channel",) assert state.axis_labels == ("channel", "y", "x") @@ -1752,7 +1758,7 @@ def test_napari_display_pipeline_labels_collapsed_route_components(): state = pipeline.server.layer_route_state.dimension_state_for("route-stack") assert axis_labels == ("site", "y", "x") assert state.labels == {"site": ["Site 1", "Site 2"]} - assert state.scalar_labels == ("Ch1: DAPI", "T 1", "Z 1") + assert state.scalar_labels == ("Ch 1: DAPI", "T 1", "Z 1") def test_napari_display_pipeline_rejects_nonsemantic_payload_axis_labels() -> None: @@ -1777,11 +1783,12 @@ def test_napari_display_pipeline_projects_route_axes_locally(): {"channel": [1, 2, 3, 4, 5], "site": [1, 2]} ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"channel": "stack", "site": "stack"}, "component_order": ["channel", "site"], + "declared_axes": STREAM_AXES.to_wire(), } ), component_value_domain, @@ -1827,11 +1834,12 @@ def test_napari_display_pipeline_uses_declared_domain_independent_of_arrival_ord napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") pipeline = napari_viewer_server.NapariLayerDisplayPipeline(_FakeNapariServer()) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"channel": "stack"}, "component_order": ["channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), _component_value_domain({"channel": [1, 2, 4]}), @@ -1875,8 +1883,9 @@ def test_napari_display_pipeline_aligns_scalar_and_stack_routes_by_semantic_valu raw_semantics = ViewerComponentAxisSemantics( entries=_component_value_domain({"channel": [2]}).entries, layout=ViewerComponentLayout.from_parts( - component_modes={"channel": ViewerComponentMode.STACK}, + component_modes={**ALL_STACKED, "channel": NapariSlots.Stack.wire_value}, component_order=("channel",), + declared_axes=STREAM_AXES, ), ) server.layer_route_state.set_title(raw_route, "Raw channel 2") @@ -1976,6 +1985,16 @@ def component_modes(self) -> dict[str, str]: self.REGION_COMPONENT: self.region_mode.value, } + def display_payload_extra(self) -> dict: + extra = super().display_payload_extra() + region = { + "name": self.REGION_COMPONENT, + "label": "Region", + "roles": [], + "value_kind": "OrdinalValued", + } + return {**extra, "declared_axes": [*extra["declared_axes"], region]} + @pytest.mark.parametrize( "component, mode_field, config_type", @@ -2060,6 +2079,7 @@ def test_display_batch_composes_domain_and_names_for_new_declared_component(): entries=_component_value_domain({component: [1, 2]}).entries, layout=shared_layout, store=names.store, + declared_axes=shared_layout.declared_axes, viewer_display_config=config, ) @@ -2067,10 +2087,10 @@ def test_display_batch_composes_domain_and_names_for_new_declared_component(): assert batch.axis_labels(component, [1, 2]) == ["Region 1: West", "Region 2: East"] projection = batch.axis_projection_semantics() assert projection.component_order == (component,) - assert projection.layout.components_for_mode(ViewerComponentMode.STACK) == (component,) + assert projection.layout.components_in(NapariSlots.Stack) == (component,) assert projection.entries is batch.entries assert batch.store is names.store - assert grouping_layout.components_for_mode(ViewerComponentMode.LAYER) == (component,) + assert grouping_layout.components_in(NapariSlots.Layer) == (component,) def test_paired_raw_projection_keeps_one_inherited_algorithm_owner(): @@ -2097,8 +2117,9 @@ def test_napari_display_pipeline_rejects_shared_axis_expansion_requiring_remater server.viewer = ViewerModel() pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) component_layout = ViewerComponentLayout.from_parts( - component_modes={"channel": ViewerComponentMode.STACK}, + component_modes={**ALL_STACKED, "channel": NapariSlots.Stack.wire_value}, component_order=("channel",), + declared_axes=STREAM_AXES, ) sparse_route = "channels-1-3" @@ -2161,11 +2182,12 @@ def test_napari_display_pipeline_projects_aggregate_payload_axes_into_route_doma {"channel": [1], "z_index": [1, 2]} ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"channel": "stack", "z_index": "stack"}, "component_order": ["z_index", "channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), component_value_domain, @@ -2430,7 +2452,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "nuclei", NapariDimensionLayerState( - labels={"channel": ["Ch1: DAPI"]}, + labels={"channel": ["Ch 1: DAPI"]}, presentation=_axis_presentation( layer_key="nuclei", projected_axis_components=("channel",), @@ -2443,7 +2465,7 @@ class FakeViewer: pipeline.dimension_label_overlay.setup_for_layer("nuclei") assert server.viewer.dims.axis_labels == ("channel", "y", "x") - assert server.viewer.text_overlay.text == "Ch1: DAPI" + assert server.viewer.text_overlay.text == "Ch 1: DAPI" def test_napari_display_pipeline_applies_value_overlay_for_offset_route(): @@ -2582,7 +2604,7 @@ def test_napari_navigation_control_selects_visible_layer_and_route_local_axes(): server.component_groups = NapariComponentGroupStore() server.display_pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) - response = napari_viewer_server.NapariNavigationControlMessageAction().handle( + response = napari_viewer_server.NapariNavigationControlAction().handle( server, { "type": "navigate", @@ -2661,7 +2683,7 @@ def test_napari_navigation_visibility_change_preserves_selected_label_route(): ) server.component_groups = NapariComponentGroupStore() server.display_pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) - navigation = napari_viewer_server.NapariNavigationControlMessageAction() + navigation = napari_viewer_server.NapariNavigationControlAction() navigation.handle( server, @@ -2838,7 +2860,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "source-map2", NapariDimensionLayerState( - labels={"channel": ["Ch2: MAP2"]}, + labels={"channel": ["Ch 2: MAP2"]}, presentation=_axis_presentation( layer_key="source-map2", projected_axis_components=("channel",), @@ -2850,7 +2872,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "derived-neurite-skeleton", NapariDimensionLayerState( - labels={"channel": ["Ch4: SMI312"]}, + labels={"channel": ["Ch 4: SMI312"]}, presentation=_axis_presentation( layer_key="derived-neurite-skeleton", projected_axis_components=("channel",), @@ -2866,13 +2888,13 @@ class FakeViewer: "derived-neurite-skeleton" ) assert server.viewer.dims.axis_labels == ("channel", "y", "x") - assert server.viewer.text_overlay.text == "Ch4: SMI312" + assert server.viewer.text_overlay.text == "Ch 4: SMI312" assert server.viewer.layers.selection.active is selected_layer pipeline.dimension_label_overlay._update_overlay() assert server.layer_route_state.active_dimension_label_route == "source-map2" - assert server.viewer.text_overlay.text == "Ch2: MAP2" + assert server.viewer.text_overlay.text == "Ch 2: MAP2" def test_napari_display_pipeline_preserves_active_non_openhcs_layer_during_update(): @@ -2890,7 +2912,7 @@ def test_napari_display_pipeline_preserves_active_non_openhcs_layer_during_updat server.layer_route_state.set_dimension_state( "derived", NapariDimensionLayerState( - labels={"channel": ["Ch4: SMI312"]}, + labels={"channel": ["Ch 4: SMI312"]}, presentation=_axis_presentation( layer_key="derived", projected_axis_components=("channel",), @@ -2943,7 +2965,7 @@ class FakeViewer: labels={ "well": ["A14", "B13"], "site": ["Site 1", "Site 2"], - "channel": ["Ch1: OrigDNA"], + "channel": ["Ch 1: OrigDNA"], }, presentation=_axis_presentation( layer_key="identify-primary", @@ -2964,11 +2986,11 @@ class FakeViewer: "well": ["A14", "B13"], "site": ["Site 1", "Site 2"], "channel": [ - "Ch1: OrigDNA", - "Ch2: OrigER", - "Ch3: OrigRNA", - "Ch4: OrigActin_Golgi_Membrane", - "Ch5: OrigMito", + "Ch 1: OrigDNA", + "Ch 2: OrigER", + "Ch 3: OrigRNA", + "Ch 4: OrigActin_Golgi_Membrane", + "Ch 5: OrigMito", ], }, presentation=_axis_presentation( @@ -2988,7 +3010,7 @@ class FakeViewer: pipeline.dimension_label_overlay._update_overlay() assert server.viewer.dims.axis_labels == ("well", "site", "channel", "y", "x") - assert server.viewer.text_overlay.text == "A14 | Site 1 | Ch2: OrigER" + assert server.viewer.text_overlay.text == "A14 | Site 1 | Ch 2: OrigER" def test_napari_display_pipeline_falls_back_from_selected_offset_channel(): @@ -3023,7 +3045,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "source", NapariDimensionLayerState( - labels={"channel": ["Ch1: Hoechst", "Ch2: MAP2", "Ch4: SMI312"]}, + labels={"channel": ["Ch 1: Hoechst", "Ch 2: MAP2", "Ch 4: SMI312"]}, presentation=_axis_presentation( layer_key="source", projected_axis_components=("channel",), @@ -3035,7 +3057,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "neurites", NapariDimensionLayerState( - labels={"channel": ["Ch4: SMI312"]}, + labels={"channel": ["Ch 4: SMI312"]}, presentation=_axis_presentation( layer_key="neurites", projected_axis_components=("channel",), @@ -3050,7 +3072,7 @@ class FakeViewer: pipeline.dimension_label_overlay._update_overlay() assert server.layer_route_state.active_dimension_label_route == "source" - assert server.viewer.text_overlay.text == "Ch1: Hoechst" + assert server.viewer.text_overlay.text == "Ch 1: Hoechst" def test_napari_display_pipeline_labels_all_slices_from_nonzero_axis_origin(): @@ -3085,7 +3107,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "map2", NapariDimensionLayerState( - labels={"channel": ["Ch2: MAP2"]}, + labels={"channel": ["Ch 2: MAP2"]}, presentation=_axis_presentation( layer_key="map2", projected_axis_components=("channel",), @@ -3098,7 +3120,7 @@ class FakeViewer: server.layer_route_state.set_dimension_state( "smi312", NapariDimensionLayerState( - labels={"channel": ["Ch4: SMI312"]}, + labels={"channel": ["Ch 4: SMI312"]}, presentation=_axis_presentation( layer_key="smi312", projected_axis_components=("channel",), @@ -3112,13 +3134,13 @@ class FakeViewer: pipeline.dimension_label_overlay._update_overlay() assert server.layer_route_state.active_dimension_label_route == "map2" - assert server.viewer.text_overlay.text == "Ch2: MAP2" + assert server.viewer.text_overlay.text == "Ch 2: MAP2" server.viewer.dims.current_step = (1, 0, 0) pipeline.dimension_label_overlay._update_overlay() assert server.layer_route_state.active_dimension_label_route == "smi312" - assert server.viewer.text_overlay.text == "Ch4: SMI312" + assert server.viewer.text_overlay.text == "Ch 4: SMI312" def test_napari_axis_origin_includes_implicit_zero_route_offsets(): @@ -3285,7 +3307,7 @@ def add_layer(self, layer): "metadata": {"source_spatial_shape_yx": (2, 2)}, } ] - display_semantics = ViewerComponentAxisSemanticsAuthority.empty() + display_semantics = ViewerComponentAxisSemanticsFactory.empty() class PendingTimer: def stop(self): @@ -3331,7 +3353,7 @@ def stop(self): NapariPendingLayerUpdate.from_semantics( timer=pending_timer, data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), ), ) @@ -3348,7 +3370,7 @@ def stop(self): debounce_policy=NapariLayerBatchDebouncePolicy(delay_ms=1) ) - server.clear_accumulated_stream_state() + server.clear_stream_state() assert len(server.component_groups) == 0 assert server.component_values.values_for(("old", ("well",)), ["well"]) == { @@ -3390,7 +3412,7 @@ def test_clear_state_preserves_mounted_payload_and_domains_until_native_removal( server.display_pipeline = NapariLayerDisplayPipeline(server) server.batch_processors = NapariBatchProcessorStore() - server.clear_accumulated_stream_state() + server.clear_stream_state() assert server.component_groups.existing_items_for("retained") == [item] assert server.component_groups.existing_items_for("cancelled") is None @@ -3400,7 +3422,7 @@ def test_clear_state_preserves_mounted_payload_and_domains_until_native_removal( assert server.component_name_metadata.display_name("well", "A01") == "source well" server.viewer.layers.remove(layer) - server.clear_accumulated_stream_state() + server.clear_stream_state() assert not server.layer_route_state.layers assert not server.component_groups.groups assert not server.component_values.domains @@ -3939,7 +3961,7 @@ def test_declared_object_subject_selects_all_neuron_paths_and_metrics_row( ) original_order = tuple(viewer.layers) monkeypatch.setattr( - napari_viewer_server.NapariResultSelectionGroupAuthority, + napari_viewer_server.NapariResultSelectionGroups, "feature_values", lambda *_args, **_kwargs: pytest.fail( "ROI selection rescanned features after layer binding" @@ -4296,31 +4318,12 @@ def test_napari_entrypoint_bind_failure_quits_event_loop_and_stops_server(monkey assert events[-1] == "server_stop" -def test_napari_unknown_control_message_returns_typed_protocol_error(): - napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - - response = napari_viewer_server.NapariUnknownControlMessageAction().handle( - object(), - {ViewerControlResponseField.TYPE.value: "unsupported"}, - ) - - assert response[ViewerControlResponseField.STATUS.value] == ( - ViewerProtocolStatus.ERROR.value - ) - assert response[ViewerControlResponseField.TYPE.value] == "error" - assert "unsupported" in response[ViewerControlResponseField.MESSAGE.value] -@pytest.mark.parametrize( - "action_name", - ( - "NapariGracefulShutdownControlMessageAction", - "NapariForceShutdownControlMessageAction", - ), -) +@pytest.mark.parametrize("message_type", ("shutdown", "force_shutdown")) def test_napari_shutdown_cancels_pending_updates_before_scheduling_viewer_close( monkeypatch, - action_name, + message_type, ): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") from qtpy import QtCore @@ -4365,13 +4368,13 @@ def close(self): NapariPendingLayerUpdate.from_semantics( timer=PendingTimer(route_key), data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), ), ) monkeypatch.setattr(QtCore, "QTimer", FakeQTimer) - action = getattr(napari_viewer_server, action_name)() + action = napari_viewer_server.NapariControlAction.for_message_type(message_type) response = action.handle(server, {}) assert server.is_running() is False @@ -4390,50 +4393,10 @@ def close(self): assert not any(event.startswith("display:") for event in events) -def test_napari_control_dispatch_registry_is_module_local_and_eager(): - napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - registry = napari_viewer_server.NapariControlMessageAction.__registry__ - assert type(registry) is dict - assert registry[ViewerControlMessageType.CLEAR_STATE.value] is ( - napari_viewer_server.NapariClearStateControlMessageAction - ) - assert registry[ViewerControlMessageType.APPLY_INTENSITY_WINDOW.value] is ( - napari_viewer_server.NapariIntensityWindowControlMessageAction - ) -def test_napari_endpoint_lifecycle_capabilities_derive_from_registered_actions(): - napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - from zmqruntime.messages import EndpointControlCapability - - expected = frozenset(EndpointControlCapability) - assert ( - napari_viewer_server.NapariControlMessageAction.endpoint_control_capabilities() - == expected - ) - server = napari_viewer_server.NapariViewerServer( - NapariViewerServerRequest( - port=54321, - viewer_title="lifecycle capability test", - ) - ) - - assert server._create_pong_response().control_capabilities == expected - - -def test_napari_process_launch_action_reports_immutable_declaration(): - napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - process_launch = ViewerProcessLaunchConfig(qt_font_dpi=96) - - response = napari_viewer_server.NapariProcessLaunchControlMessageAction().handle( - SimpleNamespace(process_launch=process_launch), - {}, - ) - - assert response["status"] == "success" - assert response["process_launch"] == process_launch.to_wire_mapping() def test_napari_axis_projector_drops_only_globally_singleton_axes(): @@ -4478,7 +4441,7 @@ def test_napari_axis_projector_drops_only_globally_singleton_axes(): def test_napari_layer_update_authority_declares_shapes_and_points_kwargs(): viewer = _FakeViewer() layers = {} - authority = NapariLayerUpdateAuthority() + authority = NapariLayerUpdates() shapes = authority.create_or_update( layer_kind=NapariLayerKind.SHAPES, @@ -4539,7 +4502,7 @@ def test_napari_layer_title_authority_uses_stream_display_name_policy(): component_order=["well", "channel"], ) - title = napari_viewer_server.NapariLayerTitleAuthority.title( + title = napari_viewer_server.NapariLayerTitles.title( producer=producer, stream_layer_data_type=StreamingDataType.SHAPES, component_info={"well": "A01", "channel": 1}, @@ -4654,9 +4617,10 @@ def __init__(self): display_config = { "component_modes": {"well": "stack"}, "component_order": ["well"], + "declared_axes": STREAM_AXES.to_wire(), } component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput(display_config), _component_value_domain({"well": ["A01"]}), ) @@ -4738,11 +4702,12 @@ def __init__(self): pipeline_position=7, ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"well": "stack", "channel": "stack"}, "component_order": ["well", "channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), _component_value_domain({"well": ["A01"], "channel": [2]}), @@ -4813,11 +4778,12 @@ def __init__(self): self.component_groups = NapariComponentGroupStore() self.replace_layers = False - semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"well": "stack"}, "component_order": ["well"], + "declared_axes": STREAM_AXES.to_wire(), } ), _component_value_domain({"well": ["A01", "B01"]}), @@ -4883,7 +4849,7 @@ def _stream(config: NapariDisplayConfig): ) assert len(separate_server.layer_route_state.layer_pending_updates) == 2 assert { - route.layout.components_for_mode("layer") + route.layout.components_in(NapariSlots.Layer) for _, _, route, _ in separate_server.display_pipeline.scheduled } == {("well",)} assert sorted( @@ -4925,7 +4891,7 @@ def test_napari_configured_colormap_reaches_native_layer_and_invalid_name_fails( viewer = napari.Viewer(show=False) try: with pytest.raises((KeyError, ValueError)): - NapariLayerUpdateAuthority().create_or_update( + NapariLayerUpdates().create_or_update( layer_kind=NapariLayerKind.IMAGE, viewer=viewer, layers={}, @@ -4954,7 +4920,7 @@ def test_napari_layer_title_disambiguation_uses_display_step_number(): layer_route_state.set_title("other-route", "4. Measure") assert ( - napari_viewer_server.NapariLayerTitleAuthority.disambiguate( + napari_viewer_server.NapariLayerTitles.disambiguate( title="4. Measure", producer=producer, route_key="current-route", @@ -4992,7 +4958,7 @@ def test_napari_layer_route_state_store_keeps_layer_labels_and_timers_together() pending_update = NapariPendingLayerUpdate.from_semantics( timer=timer, data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), ) store.set_pending_update("nuclei", pending_update) @@ -5037,7 +5003,7 @@ def get_or_create(self, **_kwargs): update = NapariPendingLayerUpdate.from_semantics( timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), items=server.component_groups.items_for(route_key), ) @@ -5098,14 +5064,14 @@ def get_or_create(self, **_kwargs): NapariPendingLayerUpdate.from_semantics( timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), items=server.component_groups.items_for(route_key), ), ) monkeypatch.setattr(napari_viewer_server, "QTimer", DeferredQtTimer) - action = napari_viewer_server.NapariSettleControlMessageAction() + action = napari_viewer_server.NapariControlAction.for_message_type("settle") first_response = ViewerControlResponse(action.handle(server, {})) first_progress = ViewerSettleProgress.from_response(first_response) @@ -5175,7 +5141,7 @@ def get_or_create(self, **_kwargs): update = NapariPendingLayerUpdate.from_semantics( timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), items=server.component_groups.items_for(route_key), ) @@ -5183,7 +5149,7 @@ def get_or_create(self, **_kwargs): pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) server.display_pipeline = pipeline monkeypatch.setattr(napari_viewer_server, "QTimer", DeferredQtTimer) - action = napari_viewer_server.NapariSettleControlMessageAction() + action = napari_viewer_server.NapariControlAction.for_message_type("settle") initial = ViewerSettleProgress.from_response( ViewerControlResponse(action.handle(server, {})) @@ -5233,7 +5199,7 @@ def get_or_create(self, **_kwargs): update = NapariPendingLayerUpdate.from_semantics( timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), items=server.component_groups.items_for(route_key), ) @@ -5290,7 +5256,7 @@ def display_layer_batch(self, **_kwargs): processor.add_items( layer_key="objects", items=(), - display_payload=ViewerComponentAxisSemanticsAuthority.empty(), + display_payload=ViewerComponentAxisSemanticsFactory.empty(), component_names_metadata=ViewerComponentNameMetadata.empty(), ) is sentinel @@ -5298,7 +5264,7 @@ def display_layer_batch(self, **_kwargs): def test_napari_component_metadata_normalizer_coerces_indexed_strings(): - normalizer = ViewerComponentMetadataNormalizer() + normalizer = ViewerComponentMetadataNormalizer(STREAM_AXES) normalized = normalizer.normalize( { @@ -5323,7 +5289,7 @@ def test_napari_component_metadata_normalizer_coerces_indexed_strings(): def test_viewer_component_coordinate_authority_rejects_missing_axis_component(): with pytest.raises(ValueError, match="missing stack component 'channel'"): - ViewerComponentCoordinateAuthority.index( + ViewerComponentCoordinates.index( components={"site": 1}, component_values={"site": [1], "channel": [1]}, component="channel", @@ -5423,11 +5389,12 @@ def test_napari_display_projection_derives_sparse_coordinates_from_routed_items( "site": [1, 2, 4, 7], } component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"well": "stack", "site": "stack"}, "component_order": ["well", "site"], + "declared_axes": STREAM_AXES.to_wire(), } ), _component_value_domain(component_values), @@ -5645,7 +5612,7 @@ def test_viewer_component_name_metadata_owns_channel_well_and_generic_labels(): context="test", ) - assert metadata.axis_labels("channel", [1, 2]) == ["Ch1: DAPI", "Ch 2"] + assert metadata.axis_labels("channel", [1, 2]) == ["Ch 1: DAPI", "Ch 2"] assert metadata.axis_labels("well", ["A01"]) == ["A01"] assert metadata.axis_labels("site", [3]) == ["Site 3: Field"] @@ -5852,11 +5819,12 @@ def test_napari_aggregate_axis_binding_uses_declared_component_not_equal_extent( {"site": [1, 2], "channel": [1, 2]} ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"site": "stack", "channel": "stack"}, "component_order": ["site", "channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), component_value_domain, @@ -5873,7 +5841,7 @@ def test_napari_aggregate_axis_binding_uses_declared_component_not_equal_extent( ) ] - bindings = NapariAggregateAxisBindingAuthority.bindings( + bindings = NapariAggregateAxisBindingBuilder.bindings( items, component_axis_semantics, ) @@ -5906,11 +5874,12 @@ def test_napari_shape_plane_metadata_requires_declared_component_axis(): {"site": [1, 2], "channel": [1, 2]} ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"site": "stack", "channel": "stack"}, "component_order": ["site", "channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), component_value_domain, @@ -5918,7 +5887,7 @@ def test_napari_shape_plane_metadata_requires_declared_component_axis(): ) with pytest.raises(ValueError, match="plane_component_values"): - NapariAggregateAxisBindingAuthority.bindings( + NapariAggregateAxisBindingBuilder.bindings( [ _layer_item( {"site": 1, "channel": 1}, @@ -5945,11 +5914,12 @@ def test_napari_shape_label_rasterizer_rejects_mixed_aggregate_plane_metadata(): {"channel": [1], "z_index": [1, 2]} ) component_axis_semantics = ( - ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( { "component_modes": {"channel": "stack", "z_index": "stack"}, "component_order": ["z_index", "channel"], + "declared_axes": STREAM_AXES.to_wire(), } ), component_value_domain, @@ -5957,7 +5927,7 @@ def test_napari_shape_label_rasterizer_rejects_mixed_aggregate_plane_metadata(): ) with pytest.raises(ValueError, match="mixes plane-indexed and unindexed"): - NapariAggregateAxisBindingAuthority.bindings( + NapariAggregateAxisBindingBuilder.bindings( [ _layer_item( {"channel": 1}, @@ -6228,8 +6198,9 @@ def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): semantics = ViewerComponentAxisSemantics( entries=_component_value_domain({"z_index": [0, 1, 2, 3]}).entries, layout=ViewerComponentLayout.from_parts( - component_modes={"z_index": ViewerComponentMode.STACK}, + component_modes={**ALL_STACKED, "z_index": NapariSlots.Stack.wire_value}, component_order=("z_index",), + declared_axes=STREAM_AXES, ), ) item = _layer_item( @@ -6364,8 +6335,9 @@ def test_napari_fractional_z_points_are_relative_to_the_declared_anchor(): semantics = ViewerComponentAxisSemantics( entries=_component_value_domain({"z_index": [0, 1, 2, 3, 4]}).entries, layout=ViewerComponentLayout.from_parts( - component_modes={"z_index": ViewerComponentMode.STACK}, + component_modes={**ALL_STACKED, "z_index": NapariSlots.Stack.wire_value}, component_order=("z_index",), + declared_axes=STREAM_AXES, ), ) item = _layer_item( @@ -6381,7 +6353,7 @@ def test_napari_fractional_z_points_are_relative_to_the_declared_anchor(): ) projection = pipeline.display_axis_projection("centres", semantics, [item]) assert projection.component_values == {"z_index": [2, 3, 4]} - points, _properties = napari_viewer_server._build_nd_points([item], projection) + points, _properties = napari_viewer_server._build_nd_points([item], projection, ("z_index",)) assert tuple(points[0]) == (1.25, 1.25, 3.5) assert projection.axis_offsets == (2,) @@ -6405,7 +6377,9 @@ def test_napari_points_layer_rejects_fractional_z_without_z_axis(): component_values={"channel": [1]}, ) with pytest.raises(ValueError, match="projected stack axis"): - napari_viewer_server._build_nd_points([item], presentation.projection) + napari_viewer_server._build_nd_points( + [item], presentation.projection, ("z_index",) + ) def test_native_image_intensity_command_preserves_data_and_navigation(): @@ -6441,7 +6415,7 @@ def test_native_image_intensity_command_preserves_data_and_navigation(): server.component_groups = NapariComponentGroupStore() server.display_pipeline = module.NapariLayerDisplayPipeline(server) presentation = ViewerNativeImageIntensityPresentation((5, 25), 1.5) - action = module.NapariImageIntensityControlMessageAction() + action = module.NapariImageIntensityControlAction() assert action.transport_thread_response(server, {}) is None response = action.handle( server, {"payload": ViewerImageIntensityControlOptions("image", presentation)} diff --git a/tests/unit/test_napari_transport_ownership.py b/tests/unit/test_napari_transport_ownership.py index 2e9a6cd0c4..aeac043055 100644 --- a/tests/unit/test_napari_transport_ownership.py +++ b/tests/unit/test_napari_transport_ownership.py @@ -23,13 +23,10 @@ def test_napari_settlement_surfaces_terminal_transport_failure() -> None: napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - server = type( - "FailedTransportServer", - (), - {"transport_failure": RuntimeError("receiver copy failed")}, - )() + server = object.__new__(napari_viewer_server.NapariViewerServer) + server.transport_failure = RuntimeError("receiver copy failed") - response = napari_viewer_server.NapariSettleControlMessageAction().handle( + response = napari_viewer_server.NapariControlAction.for_message_type("settle").handle( server, {}, ) @@ -154,7 +151,7 @@ def test_napari_control_pump_reports_active_settlement_without_qt_dispatch() -> from openhcs.runtime.napari_streaming_handlers import NapariPendingLayerUpdate from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ) from openhcs.runtime.viewer_protocol import ( ViewerControlResponse, @@ -182,7 +179,7 @@ def stop(self) -> None: update = NapariPendingLayerUpdate.from_semantics( timer=FakeTimer(), data_type=StreamingDataType.SHAPES, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), ) server.layer_route_state.set_pending_update("large-shapes", update) diff --git a/tests/unit/test_native_viewer_presentation.py b/tests/unit/test_native_viewer_presentation.py index ebe8ce0909..730ffcc876 100644 --- a/tests/unit/test_native_viewer_presentation.py +++ b/tests/unit/test_native_viewer_presentation.py @@ -41,12 +41,12 @@ NapariStreamLayerItem, ) from openhcs.runtime.napari_viewer_server import ( - NapariControlMessageAction, + NapariControlAction, NapariStreamLayerContext, PayloadMap, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentNameMetadata, ViewerComponentLayout, ViewerComponentValueDomainPayload, @@ -57,14 +57,16 @@ ViewerControlMessageType, ViewerControlResponseField, ) +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES from openhcs.core.axes import ColourAxis def presentation(): - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() return NapariAxisPresentation( entries=semantics.entries, - layout=ViewerComponentLayout.from_parts(component_modes={}, component_order=()), + layout=ViewerComponentLayout.from_parts(component_modes={}, component_order=(), declared_axes=STREAM_AXES), route_key="paired", projection=ViewerLayerAxisProjection( projected_axis_components=(), @@ -120,7 +122,7 @@ def test_selected_metadata_wire_derives_declarations_and_preserves_batch_compati }, "test", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) assert context.image_metadata.source_voxel_spacing == metadata.source_voxel_spacing @@ -136,7 +138,7 @@ def test_selected_metadata_wire_derives_declarations_and_preserves_batch_compati }, "test", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) assert not context.image_metadata.source_voxel_spacing.has_values @@ -166,7 +168,7 @@ def test_malformed_new_metadata_never_falls_back_to_legacy(value): }, "test", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) @@ -304,13 +306,13 @@ def test_native_display_handlers_apply_shared_calibration_after_wire_roundtrip() def test_two_dimensional_calibration_does_not_calibrate_component_z_axis(): - from zmqruntime.viewer_protocol import ViewerComponentMode - + axis = replace( presentation(), layout=ViewerComponentLayout.from_parts( - component_modes={"z_index": ViewerComponentMode.STACK}, + component_modes={"z_index": NapariSlots.Stack.wire_value}, component_order=("z_index",), + declared_axes=STREAM_AXES, ), ) kwargs = axis.spatial_layer_kwargs( @@ -355,7 +357,7 @@ def test_native_camera_registered_action_readback_preserves_other_state(): ViewerNativeViewportPresentation.from_wire_mapping(desired.to_wire_mapping()) == desired ) - action = NapariControlMessageAction.for_message_type( + action = NapariControlAction.for_message_type( ViewerControlMessageType.VIEWPORT.value ) assert action.transport_thread_response(SimpleNamespace(viewer=viewer), {}) is None diff --git a/tests/unit/test_rescale_stream_plane_declaration.py b/tests/unit/test_rescale_stream_plane_declaration.py index 1c4978daef..5ac112ad41 100644 --- a/tests/unit/test_rescale_stream_plane_declaration.py +++ b/tests/unit/test_rescale_stream_plane_declaration.py @@ -32,10 +32,10 @@ from openhcs.interop.cellprofiler.runtime.module_execution import CellProfilerModuleExecutor from openhcs.interop.cellprofiler.runtime.function_contract_execution import CellProfilerFunctionContractExecutor from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, NapariStreamLayerAddress, NapariStreamLayerItem, + NapariAggregateAxisBindingBuilder, NapariStreamLayerAddress, NapariStreamLayerItem, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, ViewerComponentValueDomainPayload, + ViewerComponentAxisSemanticsFactory, ViewerComponentValueDomainPayload, ) # Reuse the original provider-free compiler and runtime fixtures, not a second ABI. @@ -43,6 +43,7 @@ from tests.unit.cellprofiler_runtime_test_support import ( cellprofiler_runtime_adapter_for_test, ) +from tests.unit.viewer_axes_fixture import STREAM_AXES @pytest.mark.parametrize("method", [RescaleMethod.DIVIDE_BY_VALUE, RescaleMethod.MANUAL_INPUT_RANGE]) @@ -124,10 +125,11 @@ def test_public_rescale_preserves_one_source_plane_through_stream_binding(method image_metadata=item.metadata, plane_component_domain=ViewerComponentValueDomainPayload.from_wire_mapping( fields.get(ViewerWireField.PLANE_COMPONENT_VALUES.value, {}), context="synthetic producer fields", + declared_axes=STREAM_AXES, ), ) # The exact original strict receiver is reached, not replaced by a mock. - NapariAggregateAxisBindingAuthority.bindings((native_item,), ViewerComponentAxisSemanticsAuthority.empty()) + NapariAggregateAxisBindingBuilder.bindings((native_item,), ViewerComponentAxisSemanticsFactory.empty()) assert contract.artifact_inputs.names() == ("Bright",) assert request.image_count == 1 assert result.data.shape == (4, 5) diff --git a/tests/unit/test_streaming_config_factory_registry.py b/tests/unit/test_streaming_config_factory_registry.py index 2e7b98e48d..9a68677a33 100644 --- a/tests/unit/test_streaming_config_factory_registry.py +++ b/tests/unit/test_streaming_config_factory_registry.py @@ -1,40 +1,44 @@ -"""Declaration-ownership tests for managed streaming viewer resolution.""" +"""Each streaming viewer is one ViewerFamily class; everything else is derived.""" import pytest from openhcs.core.config import StreamingConfig from openhcs.core.streaming_config_declarations import ( - ViewerDeclarationABC, + NapariViewer, + ViewerFamily, ViewerType, ) from openhcs.runtime.viewer_protocol import ManagedViewerLifecycleMixin +VIEWER_FAMILIES = ViewerFamily.families() -@pytest.mark.parametrize("viewer_type", tuple(ViewerType), ids=lambda item: item.value) -def test_viewer_member_owns_nominal_leaf_and_runtime_type(viewer_type) -> None: - declaration = viewer_type.declaration - visualizer_type = declaration.visualizer_type() - assert isinstance(declaration, ViewerDeclarationABC) - assert visualizer_type.detached_server_entrypoint.viewer_type is viewer_type - assert ( - ManagedViewerLifecycleMixin.__registry__[viewer_type.wire_value] - is visualizer_type - ) +@pytest.mark.parametrize("family", VIEWER_FAMILIES, ids=lambda item: item.wire_value) +def test_viewer_family_owns_visualizer_config_and_boundary_name(family) -> None: + visualizer_type = family.visualizer_type() + + assert visualizer_type.detached_server_entrypoint.viewer_family is family + assert ManagedViewerLifecycleMixin.__registry__[family.wire_value] is visualizer_type + assert StreamingConfig.config_type_for_key(family.config_key).viewer_family is family + assert ViewerFamily.named(family.viewer_type()) is family + assert family.viewer_type().family is family -def test_viewer_identity_projects_config_and_presentation_names() -> None: - assert ViewerType.NAPARI.config_key == "napari_streaming_config" - assert ViewerType.NAPARI.step_plan_output_key == "napari_streaming_paths" - assert ViewerType.NAPARI.display_name == "Napari" - assert ViewerType.NAPARI.title == "OpenHCS Napari Visualization" - assert set(StreamingConfig.__registry__) == set(ViewerType) +def test_viewer_names_derive_from_the_family_declaration() -> None: + assert NapariViewer.config_key == "napari_streaming_config" + assert NapariViewer.step_plan_output_key == "napari_streaming_paths" + assert NapariViewer.display_name == "Napari" + assert NapariViewer.title == "OpenHCS Napari Visualization" + assert set(StreamingConfig.__registry__) == set(VIEWER_FAMILIES) + assert {member.value for member in ViewerType} == { + family.wire_value for family in VIEWER_FAMILIES + } -def test_viewer_identity_parses_only_at_the_wire_boundary() -> None: - assert ViewerType.from_wire_value("fiji") is ViewerType.FIJI +def test_viewer_names_parse_only_at_the_wire_boundary() -> None: + assert ViewerFamily.named("fiji").viewer_type() is ViewerType.FIJI with pytest.raises(ValueError): - ViewerType.from_wire_value("FijiViewerDeclaration") + ViewerFamily.named("FijiViewer") @pytest.mark.parametrize("viewer_type", tuple(ViewerType), ids=lambda item: item.value) diff --git a/tests/unit/test_streaming_service.py b/tests/unit/test_streaming_service.py index bbdd394459..4b0da9e3b4 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -79,7 +79,7 @@ from openhcs.runtime.viewer_component_system import ( ViewerComponentAxisSemantics, ViewerComponentValueDomainPayload, - ViewerLayerAxisProjectionRequestAuthority, + ViewerLayerAxisProjectionRequestBuilder, ViewerLayerAxisProjector, ViewerObjectDisplayConfigInput, ViewerRouteComponentValueTracker, @@ -90,6 +90,7 @@ materialize, ) from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES class FakeFileManager: @@ -170,14 +171,14 @@ def filename_parse_result(*, channel: int = 1) -> FilenameParseResult: def test_streaming_config_separates_registry_key_from_viewer_identity() -> None: assert set(StreamingConfig.__registry__) == { - ViewerType.NAPARI, - ViewerType.FIJI, + NapariStreamingConfig.viewer_family, + FijiStreamingConfig.viewer_family, } - assert NapariStreamingConfig().streaming_config_key == "napari_streaming_config" - assert NapariStreamingConfig().viewer_type is ViewerType.NAPARI - assert FijiStreamingConfig().streaming_config_key == "fiji_streaming_config" - assert FijiStreamingConfig().viewer_type is ViewerType.FIJI + assert NapariStreamingConfig.viewer_family.config_key == "napari_streaming_config" + assert NapariStreamingConfig.viewer_family.viewer_type() is ViewerType.NAPARI + assert FijiStreamingConfig.viewer_family.config_key == "fiji_streaming_config" + assert FijiStreamingConfig.viewer_family.viewer_type() is ViewerType.FIJI assert "source" not in NapariStreamingConfig().component_modes() assert "source" not in FijiStreamingConfig().component_modes() @@ -188,7 +189,7 @@ def test_streaming_config_separates_registry_key_from_viewer_identity() -> None: assert ( StreamingConfig.display_name_for_config_key("fiji_streaming_config") == "Fiji" ) - assert NapariStreamingConfig().display_name == "Napari" + assert NapariStreamingConfig.viewer_family.display_name == "Napari" def test_napari_streaming_config_owns_process_launch_projection() -> None: @@ -1066,6 +1067,7 @@ def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): domain = ViewerComponentValueDomainPayload.from_wire_mapping( stream.message_extra[ViewerBatchWireField.COMPONENT_VALUE_DOMAIN.value], context="native point archive", + declared_axes=STREAM_AXES, ) semantics = ViewerComponentAxisSemantics( entries=domain.entries, @@ -1082,7 +1084,7 @@ def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): image_metadata=reopened_source, plane_component_domain=domain, ) - request = ViewerLayerAxisProjectionRequestAuthority.from_component_axis_semantics( + request = ViewerLayerAxisProjectionRequestBuilder.from_component_axis_semantics( route_key="centres", component_axis_semantics=semantics, layer_items=[item], @@ -1094,7 +1096,7 @@ def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): assert "z_index" in projection.projected_axis_components from openhcs.runtime.napari_viewer_server import _build_nd_points - points, properties = _build_nd_points([item], projection) + points, properties = _build_nd_points([item], projection, ("z_index",)) assert points[0, projection.projected_axis_components.index("z_index")] == 2.375 assert properties["response"] == [4.75] assert properties[NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE] == [ @@ -1358,7 +1360,7 @@ def fail_after_factory(*, factory, **_kwargs): monkeypatch.setattr( DetachedViewerServerEntrypointSpec, "log_file_for", - lambda self, port: tmp_path / f"{self.viewer_type.wire_value}_{port}.log", + lambda self, port: tmp_path / f"{self.viewer_family.wire_value}_{port}.log", ) with pytest.raises(DetachedViewerLaunchFailure) as error: @@ -1396,7 +1398,7 @@ def test_streaming_viewer_lifecycle_admits_new_launch_inside_managed_acquisition }), ) lifecycle_manager.get_or_create_viewer( - active_config.viewer_type.wire_value, 5563, lambda: existing_viewer + active_config.viewer_family.wire_value, 5563, lambda: existing_viewer ) monkeypatch.setattr( config_type, @@ -1419,5 +1421,5 @@ def test_streaming_viewer_lifecycle_admits_new_launch_inside_managed_acquisition filemanager=FakeFileManager(), config=requested, fresh=False, ) assert lifecycle_manager.get_viewer( - active_config.viewer_type.wire_value, 5563 + active_config.viewer_family.wire_value, 5563 ) is existing_viewer diff --git a/tests/unit/test_viewer_channel_display_metadata.py b/tests/unit/test_viewer_channel_display_metadata.py index e8380f2b45..27e252f0f6 100644 --- a/tests/unit/test_viewer_channel_display_metadata.py +++ b/tests/unit/test_viewer_channel_display_metadata.py @@ -4,7 +4,6 @@ from polystore.virtual_workspace import SourcePixelRef from zmqruntime.viewer_protocol import ( ViewerComponentMetadataPayload, - ViewerComponentMode, ) from openhcs.core.source_binding_workspace import PrimaryPlaneBindingProjection @@ -23,12 +22,14 @@ NapariLayerRouteStateStore, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerComponentNameMetadata, ViewerLayerAxisProjection, ) from openhcs.domains.microscopy.axes import Microscopy +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES def _address(channel: str = "1") -> OpenHCSPlaneAddress: @@ -44,6 +45,7 @@ def _component_name_metadata() -> ViewerComponentNameMetadata: return ViewerComponentNameMetadata.from_wire_mapping( round_trip.component_names_metadata, context="source-binding channel labels", + declared_axes=STREAM_AXES, ) @@ -90,12 +92,12 @@ def test_channel_display_metadata_round_trips_without_replacing_numeric_coordina assert names.to_wire_mapping() == {"channel": {"1": "DNA"}} assert names.display_name("channel", 1) == "DNA" - assert names.axis_label("channel", 1) == "Ch1: DNA" + assert names.axis_label("channel", 1) == "Ch 1: DNA" def test_napari_viewer_state_keeps_numeric_channel_and_declared_display_label(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() route_key = "source-dna" server = SimpleNamespace( component_name_metadata=_component_name_metadata(), @@ -104,8 +106,9 @@ def test_napari_viewer_state_keeps_numeric_channel_and_declared_display_label(): presentation = NapariAxisPresentation( entries=semantics.entries, layout=ViewerComponentLayout.from_parts( - component_modes={"site": ViewerComponentMode.STACK}, + component_modes={"site": NapariSlots.Stack.wire_value}, component_order=("site",), + declared_axes=STREAM_AXES, ), route_key=route_key, projection=ViewerLayerAxisProjection( @@ -124,7 +127,7 @@ def test_napari_viewer_state_keeps_numeric_channel_and_declared_display_label(): state = server.layer_route_state.dimension_state_for(route_key) assert state.presentation is not None assert state.presentation.projection.scalar_component_values["channel"] == [1] - assert state.scalar_labels == ("Ch1: DNA",) + assert state.scalar_labels == ("Ch 1: DNA",) def test_collapsed_metadata_keeps_unnamed_coordinates_and_binding_label(): @@ -137,7 +140,7 @@ def test_collapsed_metadata_keeps_unnamed_coordinates_and_binding_label(): "custom_coordinate": ["condition-b"], } assert names.scalar_labels(coordinates) == ( - "Ch1: DNA", + "Ch 1: DNA", "Site 7", "Z 2", "T 3", diff --git a/tests/unit/test_viewer_control_actions.py b/tests/unit/test_viewer_control_actions.py new file mode 100644 index 0000000000..7a8f9200aa --- /dev/null +++ b/tests/unit/test_viewer_control_actions.py @@ -0,0 +1,95 @@ +"""One control-action family for every viewer server.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from zmqruntime.messages import EndpointControlCapability + +from openhcs.core.streaming_config_factory import ViewerProcessLaunchConfig +from openhcs.runtime.viewer_control_actions import ( + LifecycleControlAction, + ViewerServerPort, +) +from openhcs.runtime.viewer_protocol import ( + ViewerControlMessageType, + ViewerControlResponse, + ViewerSettleProgress, +) + +pytest.importorskip("napari") + +from openhcs.runtime.fiji_viewer_server import ( # noqa: E402 + FijiBatchSettlementState, + FijiViewerServer, + FijiWindowRegistry, +) +from openhcs.runtime.napari_viewer_server import NapariViewerServer # noqa: E402 + +VIEWER_SERVERS = (NapariViewerServer, FijiViewerServer) + + +@pytest.mark.parametrize("server_type", VIEWER_SERVERS) +def test_every_viewer_inherits_every_lifecycle_action(server_type) -> None: + actions = server_type.control_actions + + assert issubclass(server_type, ViewerServerPort) + for lifecycle in LifecycleControlAction.declared(): + assert lifecycle.message_type in actions.registered_message_types() + assert isinstance(actions.for_message_type(lifecycle.message_type), lifecycle) + assert actions.control_capabilities() == frozenset(EndpointControlCapability) + + +@pytest.mark.parametrize("server_type", VIEWER_SERVERS) +@pytest.mark.parametrize( + "message_type", + (ViewerControlMessageType.NAVIGATE.value, "no_such_message", None), +) +def test_unregistered_control_messages_answer_error(server_type, message_type) -> None: + actions = server_type.control_actions + if message_type in actions.registered_message_types(): + pytest.skip(f"{server_type.__name__} handles {message_type!r}") + server = object.__new__(server_type) + + reply = server.handle_control_message({"type": message_type}) + + assert reply["status"] == "error" + assert reply["type"] == "error" + assert server.viewer_display_name in reply["message"] + + +def test_fiji_navigate_no_longer_reports_success() -> None: + """MCP navigate/viewport/measure sent to Fiji used to succeed silently.""" + + server = object.__new__(FijiViewerServer) + for message_type in ( + ViewerControlMessageType.NAVIGATE, + ViewerControlMessageType.VIEWPORT, + ViewerControlMessageType.STATE, + ViewerControlMessageType.APPLY_INTENSITY_WINDOW, + ): + assert server.handle_control_message({"type": message_type.value})["status"] == ( + "error" + ) + + +def test_fiji_lifecycle_actions_answer_through_the_server_port() -> None: + launch = ViewerProcessLaunchConfig(listen_host="*") + server = object.__new__(FijiViewerServer) + server.windows = FijiWindowRegistry() + server.batch_processor = SimpleNamespace(settlement=FijiBatchSettlementState()) + server.launch_config = SimpleNamespace(process_launch=launch) + server._shutdown_requested = False + + process = server.handle_control_message({"type": "process_launch"}) + settle = server.handle_control_message({"type": "settle"}) + shutdown = server.handle_control_message({"type": "shutdown"}) + + assert process["type"] == "process_launch_ack" + assert ViewerProcessLaunchConfig.from_wire_mapping(process["process_launch"]) == launch + assert ViewerSettleProgress.from_response(ViewerControlResponse(settle)) == ( + ViewerSettleProgress.complete() + ) + assert shutdown["type"] == "shutdown_ack" + assert server._shutdown_requested is True diff --git a/tests/unit/test_viewer_feature_measurement.py b/tests/unit/test_viewer_feature_measurement.py index 76b58d8ce2..d2ee653a4e 100644 --- a/tests/unit/test_viewer_feature_measurement.py +++ b/tests/unit/test_viewer_feature_measurement.py @@ -15,7 +15,6 @@ ) from polystore.streaming_constants import StreamingDataType from python_introspect import dataclass_from_mapping, to_jsonable -from zmqruntime.viewer_protocol import ViewerComponentMode from openhcs.agent.capabilities import ( AgentCapabilitySearchRequest, @@ -42,9 +41,9 @@ NapariStreamLayerAddress, NapariStreamLayerItem, ) -from openhcs.runtime.napari_viewer_server import NapariControlMessageAction +from openhcs.runtime.napari_viewer_server import NapariControlAction from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerComponentValueDomainPayload, ViewerLayerAxisProjection, @@ -55,6 +54,8 @@ ) from openhcs.runtime.viewer_measurements import NativeImageMeasurement from openhcs.runtime.viewer_protocol import OpenHCSViewerControlMessageType +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES def native_route(): @@ -81,10 +82,11 @@ def native_route(): presentation = NapariAxisPresentation( route_key="source", projection=projection, - entries=ViewerComponentAxisSemanticsAuthority.empty().entries, + entries=ViewerComponentAxisSemanticsFactory.empty().entries, layout=ViewerComponentLayout.from_parts( - component_modes={"channel": ViewerComponentMode.STACK}, + component_modes={"channel": NapariSlots.Stack.wire_value}, component_order=("channel",), + declared_axes=STREAM_AXES, ), ) routes = NapariLayerRouteStateStore.empty() @@ -131,7 +133,7 @@ def native_route(): def dispatch(server, kind, request): # Same pickle boundary and registered action used by the Qt control ingress. message = pickle.loads(pickle.dumps({"type": kind.value, "payload": request})) - return NapariControlMessageAction.for_message_type(kind.value).handle( + return NapariControlAction.for_message_type(kind.value).handle( server, message ) @@ -443,7 +445,7 @@ def __init__(self, server): self.server = server def _send_control_message(self, request, message): - return NapariControlMessageAction.for_message_type(message["type"]).handle( + return NapariControlAction.for_message_type(message["type"]).handle( self.server, pickle.loads(pickle.dumps(message)) ) diff --git a/tests/unit/test_viewer_listener_binding.py b/tests/unit/test_viewer_listener_binding.py index 4428c562ac..43fa6f3b78 100644 --- a/tests/unit/test_viewer_listener_binding.py +++ b/tests/unit/test_viewer_listener_binding.py @@ -85,7 +85,7 @@ def test_detached_launch_projects_listener_from_process_declaration(viewer_type) listen_host="192.0.2.1", transport_mode=TransportMode.TCP, ) - visualizer = viewer_type.declaration.visualizer_type()( + visualizer = viewer_type.family.visualizer_type()( filemanager=object(), runtime_config=config.viewer_runtime_config(), ) diff --git a/tests/unit/test_viewer_protocol.py b/tests/unit/test_viewer_protocol.py index 2fc91ed58d..ef084790bd 100644 --- a/tests/unit/test_viewer_protocol.py +++ b/tests/unit/test_viewer_protocol.py @@ -16,7 +16,7 @@ import openhcs.runtime.viewer_protocol as viewer_protocol from openhcs.core.execution_visualizer import ExecutionVisualizerABC -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ViewerFamily from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, ViewerProcessLaunchConfig, @@ -48,6 +48,7 @@ ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG from openhcs.runtime.zmq_application import OPENHCS_ENDPOINT_APPLICATION +from openhcs.core.streaming_config_declarations import NapariViewer @pytest.fixture(autouse=True) @@ -288,7 +289,7 @@ def test_managed_viewer_readiness_uses_endpoint_binding_authority(monkeypatch): class ProbeViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Probe" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -302,7 +303,7 @@ def __init__(self): transport_mode=TransportMode.IPC, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) @@ -339,7 +340,7 @@ def test_managed_viewer_reuses_only_matching_application(monkeypatch): class ProbeViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Probe" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -362,7 +363,7 @@ def detached_server_arguments( transport_mode=TransportMode.IPC, ), persistent=True, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) monkeypatch.setattr(viewer, "active_process_launch", lambda: viewer.process_launch) @@ -413,7 +414,7 @@ def test_managed_viewer_lifecycle_reads_state_through_typed_control_request( class StateViewer(ManagedViewerLifecycleMixin): viewer_process_label = "State" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -427,7 +428,7 @@ def __init__(self): transport_mode=TransportMode.IPC, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) @@ -485,7 +486,7 @@ def test_managed_viewer_settlement_tracks_progress_without_total_timeout( class ProgressViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Progress" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -499,7 +500,7 @@ def __init__(self): transport_mode=TransportMode.IPC, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) @@ -569,7 +570,7 @@ def test_managed_viewer_settlement_rejects_no_progress(monkeypatch): { "viewer_process_label": "Stalled", "detached_server_entrypoint": DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ), @@ -586,7 +587,7 @@ def test_managed_viewer_settlement_rejects_no_progress(monkeypatch): transport_mode=TransportMode.IPC, ), persistent=False, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) viewer.lifecycle_state.mark_connected_external() @@ -721,7 +722,7 @@ def test_projected_graphical_viewer_replaces_noninteractive_qt_platform(): def test_detached_viewer_entrypoint_generates_public_process_call(tmp_path): import_authority = OpenHCSRuntimeImportAuthority.current() python_code = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="openhcs.runtime.napari_viewer_server", function_name="run_napari_viewer_process", ).python_code( @@ -746,12 +747,12 @@ def test_detached_viewer_entrypoint_generates_public_process_call(tmp_path): def test_detached_viewer_launch_request_owns_log_and_python_command(tmp_path): spec = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="openhcs.runtime.napari_viewer_server", function_name="run_napari_viewer_process", ) log_file = DetachedViewerLaunchRequest.log_file_for( - viewer_type=spec.viewer_type, + viewer_family=spec.viewer_family, port=4321, log_dir=tmp_path / "logs", ) @@ -800,7 +801,7 @@ def test_detached_viewer_import_root_is_independent_of_runtime_cwd(tmp_path): encoding="utf-8", ) spec = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="openhcs.viewer_probe", function_name="run_probe", ) @@ -832,7 +833,7 @@ def test_process_resource_cleanup_stops_only_execution_owned_viewer( class CleanupViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Cleanup" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -846,7 +847,7 @@ def __init__(self, port: int, *, persistent: bool): transport_mode=TransportMode.IPC, ), persistent=persistent, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ) ) @@ -906,11 +907,11 @@ def launch(): process.wait(timeout=1) -@pytest.mark.parametrize("viewer_type", (ViewerType.NAPARI, ViewerType.FIJI)) +@pytest.mark.parametrize("viewer_family", ViewerFamily.families()) def test_detached_viewer_launch_uses_console_free_gui_process_policy( monkeypatch, tmp_path, - viewer_type, + viewer_family, ): captured: dict[str, object] = {} @@ -938,10 +939,10 @@ def _popen(command, **kwargs): ) monkeypatch.setattr(viewer_protocol.subprocess, "Popen", _popen) request = DetachedViewerLaunchRequest( - viewer_type=viewer_type, + viewer_family=viewer_family, port=5555, python_code="pass", - log_file=tmp_path / f"{viewer_type.wire_value}.log", + log_file=tmp_path / f"{viewer_family.wire_value}.log", process_launch=ViewerProcessLaunchConfig(qt_font_dpi=96), ) @@ -977,7 +978,7 @@ def test_managed_viewer_lifecycle_inherits_projected_launch_context(): class EnvironmentViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Environment" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -991,7 +992,7 @@ def __init__(self): transport_mode=TransportMode.IPC, ), persistent=True, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), ) @@ -1041,7 +1042,7 @@ def test_detached_viewer_launch_rejects_headless_context_before_spawn( ), ) request = DetachedViewerLaunchRequest( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, port=5555, python_code="raise AssertionError", log_file=log_file, @@ -1061,7 +1062,7 @@ def test_managed_viewer_lifecycle_uses_nominal_state_for_external_viewer(): class ExternalViewer(ManagedViewerLifecycleMixin): viewer_process_label = "External" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -1075,7 +1076,7 @@ def __init__(self): transport_mode=TransportMode.IPC, ), persistent=True, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), ) self.connected = True @@ -1125,7 +1126,7 @@ def force_release_addresses(self): class FreshViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Fresh" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -1139,7 +1140,7 @@ def __init__(self, endpoint): transport_mode=TransportMode.IPC, ), persistent=True, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), ) self.runtime_endpoint = endpoint @@ -1190,7 +1191,7 @@ def force_release_addresses(self): class StuckViewer(ManagedViewerLifecycleMixin): viewer_process_label = "Stuck" detached_server_entrypoint = DetachedViewerServerEntrypointSpec( - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, module_name="tests.fake_viewer", function_name="run", ) @@ -1204,7 +1205,7 @@ def __init__(self, endpoint): transport_mode=TransportMode.IPC, ), persistent=True, - viewer_type=ViewerType.NAPARI, + viewer_family=NapariViewer, ), ) self.runtime_endpoint = endpoint diff --git a/tests/unit/test_viewer_receipt_replay.py b/tests/unit/test_viewer_receipt_replay.py index 1fd6226f1f..55741c1b10 100644 --- a/tests/unit/test_viewer_receipt_replay.py +++ b/tests/unit/test_viewer_receipt_replay.py @@ -42,6 +42,7 @@ from openhcs.runtime.viewer_protocol import ViewerPayloadSummary from openhcs.core.axes import AxisFamily from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES from openhcs.core.axes import ColourAxis @@ -160,16 +161,19 @@ def test_observed_and_declared_wire_domains_have_distinct_order_contracts(): ) observed = ViewerComponentValueDomainPayload.from_wire_mapping( - {"channel": ["2", 1, 2]}, context="observed" + {"channel": ["2", 1, 2]}, context="observed", + declared_axes=STREAM_AXES, ) assert observed.to_wire_mapping() == {"channel": [1, 2]} declared = ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - {"channel": ["2", 1]}, context="declared" + {"channel": ["2", 1]}, context="declared", + declared_axes=STREAM_AXES, ) assert declared.to_wire_mapping() == {"channel": [2, 1]} with pytest.raises(ValueError, match="unique after normalization"): ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - {"channel": ["01", 1]}, context="malformed declared" + {"channel": ["01", 1]}, context="malformed declared", + declared_axes=STREAM_AXES, ) @@ -189,11 +193,11 @@ def test_native_persisted_aggregate_source_projection_preserves_order( from openhcs.core.runtime_image_values import ImagePayloadMetadata from openhcs.core.source_spatial_domain import SourceSpatialDomain from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, NapariStreamLayerAddress, NapariStreamLayerItem, + NapariAggregateAxisBindingBuilder, NapariStreamLayerAddress, NapariStreamLayerItem, ) from openhcs.runtime.napari_viewer_server import NapariViewerStateProjection from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, ViewerComponentValueDomainPayload, + ViewerComponentAxisSemanticsFactory, ViewerComponentValueDomainPayload, ViewerMappingDisplayConfigInput, ) @@ -213,19 +217,21 @@ def test_native_persisted_aggregate_source_projection_preserves_order( ), plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( {"channel": [2, 1]}, context="synthetic native receipt", + declared_axes=STREAM_AXES, ), ) - native_semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + native_semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( - {"component_modes": {"channel": "stack"}, "component_order": ["channel"]} + {"component_modes": {"channel": "stack"}, "component_order": ["channel"], "declared_axes": STREAM_AXES.to_wire()} ), ViewerComponentValueDomainPayload.from_wire_mapping( {"channel": [1, 2]}, context="synthetic observed receipt domain", + declared_axes=STREAM_AXES, ), ) summary = NapariViewerStateProjection.payload_summary( native_item, native_item.address.components, expected, - aggregate_axis_bindings=NapariAggregateAxisBindingAuthority.bindings( + aggregate_axis_bindings=NapariAggregateAxisBindingBuilder.bindings( [native_item], native_semantics, ), ) @@ -337,7 +343,8 @@ def test_native_persisted_aggregate_source_projection_preserves_order( ) domain = ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - metadata.retained_plane_component_values(), context="native receipt domain" + metadata.retained_plane_component_values(), context="native receipt domain", + declared_axes=STREAM_AXES, ) assert domain.to_wire_mapping() == {"channel": [2, 1]} source_items = source.image_source_metadata_items( @@ -352,7 +359,7 @@ def test_native_persisted_aggregate_source_projection_preserves_order( StreamImagePayloadMetadataProjector, ) from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariStreamLayerItem, ) from openhcs.runtime.napari_viewer_server import ( @@ -361,18 +368,19 @@ def test_native_persisted_aggregate_source_projection_preserves_order( _build_nd_image_array, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerLayerAxisProjection, ViewerMappingDisplayConfigInput, ) from zmqruntime.viewer_protocol import ViewerWirePayload observed = ViewerComponentValueDomainPayload.from_wire_mapping( - {"channel": [1, 2]}, context="observed stack domain" + {"channel": [1, 2]}, context="observed stack domain", + declared_axes=STREAM_AXES, ) - semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + semantics = ViewerComponentAxisSemanticsFactory.from_display_config( ViewerMappingDisplayConfigInput( - {"component_modes": {"channel": "stack"}, "component_order": ["channel"]} + {"component_modes": {"channel": "stack"}, "component_order": ["channel"], "declared_axes": STREAM_AXES.to_wire()} ), observed, ) @@ -408,7 +416,7 @@ def test_native_persisted_aggregate_source_projection_preserves_order( }, data=image.data, ) - bindings = NapariAggregateAxisBindingAuthority.bindings([item], semantics) + bindings = NapariAggregateAxisBindingBuilder.bindings([item], semantics) assert bindings.component_values == {"channel": [2, 1]} display_projection = ViewerLayerAxisProjection( projected_axis_components=("channel",), diff --git a/tests/unit/test_viewer_result_element_selection.py b/tests/unit/test_viewer_result_element_selection.py index 51563b1f9b..0da1a77644 100644 --- a/tests/unit/test_viewer_result_element_selection.py +++ b/tests/unit/test_viewer_result_element_selection.py @@ -11,7 +11,6 @@ import pytest from polystore.streaming.identity import StreamProducerIdentity from polystore.streaming_constants import StreamingDataType -from zmqruntime.viewer_protocol import ViewerComponentMode from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ( @@ -32,22 +31,22 @@ ) from openhcs.core.runtime_image_values import ImagePayloadMetadata from openhcs.runtime.napari_viewer_server import ( - NapariLayerIsolationControlMessageAction, - NapariNavigationControlMessageAction, - NapariResultElementSelectionAuthority, + NapariLayerIsolationControlAction, + NapariNavigationControlAction, + NapariResultElementSelections, NapariResultSelectionController, NapariResultSelectionGroupBinding, NapariResultSelectionSurface, NapariViewerServer, ) from openhcs.runtime.viewer_controls import ( - ViewerPointCoordinateAuthority, + ViewerPointCoordinates, ViewerLayerIsolationControlOptions, ViewerNavigationControlOptions, - ViewerResultElementCoordinateAuthority, + ViewerResultElementCoordinates, ) from openhcs.runtime.viewer_component_system import ( - ViewerComponentAxisSemanticsAuthority, + ViewerComponentAxisSemanticsFactory, ViewerComponentLayout, ViewerLayerAxisProjection, ViewerComponentValueDomainPayload, @@ -56,6 +55,8 @@ ViewerControlResponseField, ViewerLayerIsolationField, ) +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES, ordinal_axes class _NavigationResponseGateway(ViewerWindowGatewayABC): @@ -160,7 +161,7 @@ def _viewer_server(viewer, layer, route_key: str = "result-rois", group_binding= ), ) server.result_selection_controller = NapariResultSelectionController(server) - if NapariResultElementSelectionAuthority.state(layer).supported: + if NapariResultElementSelections.state(layer).supported: from napari.layers import Points server.component_groups.items_for(route_key).append( @@ -197,7 +198,7 @@ def test_native_navigation_acknowledges_exact_bound_linked_selection(qtbot): server, _, _, _ = _viewer_server(viewer, layer, group_binding=binding) server.result_selection_controller.bind(linked, group_binding=binding) original_data = [coordinates.copy() for coordinates in layer.data] - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, { "payload": ViewerNavigationControlOptions( @@ -221,7 +222,7 @@ def test_singleton_selection_expands_only_after_queued_navigation(qtbot): layer = viewer.add_shapes(paths, shape_type="path", features={"owner": [8, 8, 9]}) binding = NapariResultSelectionGroupBinding("native-test-subject", "owner") _viewer_server(viewer, layer, group_binding=binding) - state = NapariResultElementSelectionAuthority.select(layer, 1) + state = NapariResultElementSelections.select(layer, 1) assert state.selected_data_indices == (1,) assert layer.selected_data == {1} qtbot.waitUntil(lambda: layer.selected_data == {0, 1}, timeout=1000) @@ -364,7 +365,7 @@ def test_napari_navigation_selects_native_feature_row_and_projects_evidence(qtbo data_index=1, ) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, {ViewerControlResponseField.PAYLOAD.value: request}, ) @@ -428,7 +429,7 @@ def test_napari_layer_isolation_applies_all_routes_in_one_control_action() -> No selected_route_key="keep", ) - response = NapariLayerIsolationControlMessageAction().handle( + response = NapariLayerIsolationControlAction().handle( server, {ViewerControlResponseField.PAYLOAD.value: request}, ) @@ -470,7 +471,7 @@ def test_napari_layer_isolation_rejects_missing_routes_before_mutation() -> None visible_route_keys=("missing",), ) - response = NapariLayerIsolationControlMessageAction().handle( + response = NapariLayerIsolationControlAction().handle( server, {ViewerControlResponseField.PAYLOAD.value: request}, ) @@ -502,7 +503,7 @@ def test_napari_layer_isolation_preflights_navigation_before_mutation() -> None: axis_indices={"channel": 0}, ) - response = NapariLayerIsolationControlMessageAction().handle( + response = NapariLayerIsolationControlAction().handle( server, {ViewerControlResponseField.PAYLOAD.value: request}, ) @@ -550,7 +551,7 @@ def test_napari_navigation_moves_to_selected_roi_component_slice(qtbot) -> None: routed_component_values={"channel": [0, 2], "z": [1, 3]}, axis_offsets=(0, 0), ) - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() server.layer_route_state.set_dimension_state( "result-rois", NapariDimensionLayerState( @@ -562,10 +563,11 @@ def test_napari_navigation_moves_to_selected_roi_component_slice(qtbot) -> None: entries=semantics.entries, layout=ViewerComponentLayout.from_parts( component_modes={ - "channel": ViewerComponentMode.STACK, - "z": ViewerComponentMode.STACK, + "channel": NapariSlots.Stack.wire_value, + "z": NapariSlots.Stack.wire_value, }, component_order=("channel", "z"), + declared_axes=ordinal_axes("channel", "z"), ), route_key="result-rois", projection=projection, @@ -574,7 +576,7 @@ def test_napari_navigation_moves_to_selected_roi_component_slice(qtbot) -> None: ) viewer.dims.current_step = (0, 1, 0, 0) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( @@ -593,7 +595,7 @@ def test_napari_navigation_moves_to_selected_roi_component_slice(qtbot) -> None: (layer, (0, 1)), ) - conflicting_response = NapariNavigationControlMessageAction().handle( + conflicting_response = NapariNavigationControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( @@ -618,14 +620,14 @@ def test_result_element_coordinate_authority_derives_native_slice_indices() -> N dtype=float, ) - assert ViewerResultElementCoordinateAuthority.axis_indices( + assert ViewerResultElementCoordinates.axis_indices( coordinates=coordinates, axis_labels=("channel", "z", "y", "x"), displayed_axis_indices=(2, 3), spatial_axis_labels=("y", "x"), ) == {"channel": 2, "z": 3} assert ( - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=(4, 5), axis_labels=("y", "x"), displayed_axis_indices=(0, 1), @@ -637,7 +639,7 @@ def test_result_element_coordinate_authority_derives_native_slice_indices() -> N def test_result_element_coordinate_authority_rejects_cross_slice_geometry() -> None: with pytest.raises(ValueError, match="spans multiple 'z' slices"): - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=((2, 3, 4, 4), (2, 4, 4, 6), (2, 3, 6, 6)), axis_labels=("channel", "z", "y", "x"), displayed_axis_indices=(2, 3), @@ -648,14 +650,14 @@ def test_result_element_coordinate_authority_rejects_cross_slice_geometry() -> N def test_fractional_z_point_navigation_chooses_slice_without_rounding_geometry() -> ( None ): - assert ViewerPointCoordinateAuthority.axis_indices( + assert ViewerPointCoordinates.axis_indices( coordinates=(2.375, 1.25, 3.5), axis_labels=("z_index", "y", "x"), displayed_axis_indices=(1, 2), spatial_axis_labels=("z_index", "y", "x"), ) == {"z_index": 2} with pytest.raises(ValueError, match="integral slice"): - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=(2.375, 1.25, 3.5), axis_labels=("z_index", "y", "x"), displayed_axis_indices=(1, 2), @@ -666,7 +668,7 @@ def test_fractional_z_point_navigation_chooses_slice_without_rounding_geometry() @pytest.mark.parametrize("coordinate", [1.5, float("nan"), float("inf")]) def test_point_coordinate_owner_preserves_strict_acquisition_admission(coordinate): with pytest.raises(ValueError): - ViewerPointCoordinateAuthority.axis_indices( + ViewerPointCoordinates.axis_indices( coordinates=(coordinate, 1.5, 2.5, 3.5), axis_labels=("channel", "z_index", "y", "x"), displayed_axis_indices=(2, 3), @@ -677,7 +679,7 @@ def test_point_coordinate_owner_preserves_strict_acquisition_admission(coordinat @pytest.mark.parametrize("displayed", [(1, 2), (0, 2), (0, 1)]) def test_shapes_coordinate_owner_stays_strict_for_hidden_spatial_axes(displayed): with pytest.raises(ValueError, match="integral slice"): - ViewerResultElementCoordinateAuthority.axis_indices( + ViewerResultElementCoordinates.axis_indices( coordinates=((1.5, 2.5, 3.5),), axis_labels=("z_index", "y", "x"), displayed_axis_indices=displayed, spatial_axis_labels=("z_index", "y", "x"), ) @@ -700,7 +702,7 @@ def test_napari_navigation_selects_fractional_z_point_feature(qtbot) -> None: routed_component_values={"z_index": [0, 1, 2, 3]}, axis_offsets=(0,), ) - semantics = ViewerComponentAxisSemanticsAuthority.empty() + semantics = ViewerComponentAxisSemanticsFactory.empty() server.layer_route_state.set_dimension_state( "centres", NapariDimensionLayerState( @@ -708,8 +710,9 @@ def test_napari_navigation_selects_fractional_z_point_feature(qtbot) -> None: presentation=NapariAxisPresentation( entries=semantics.entries, layout=ViewerComponentLayout.from_parts( - component_modes={"z_index": ViewerComponentMode.STACK}, + component_modes={"z_index": NapariSlots.Stack.wire_value}, component_order=("z_index",), + declared_axes=STREAM_AXES, ), route_key="centres", projection=projection, @@ -717,7 +720,7 @@ def test_napari_navigation_selects_fractional_z_point_feature(qtbot) -> None: ), ) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( @@ -748,7 +751,7 @@ def test_napari_navigation_rejects_out_of_range_data_index(qtbot) -> None: "result-points", ) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( @@ -773,7 +776,7 @@ def test_napari_navigation_rejects_layer_without_native_data_selection(qtbot) -> "result-image", ) - response = NapariNavigationControlMessageAction().handle( + response = NapariNavigationControlAction().handle( server, { ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( diff --git a/tests/unit/test_viewer_retirement_contract.py b/tests/unit/test_viewer_retirement_contract.py index 61ef40165c..2546f87a0c 100644 --- a/tests/unit/test_viewer_retirement_contract.py +++ b/tests/unit/test_viewer_retirement_contract.py @@ -22,7 +22,7 @@ ) from openhcs.mcp.server import McpCapabilityBinder, build_server from openhcs.mcp.dev_client_commands.viewer import RetireViewerCommandSpec -from openhcs.runtime.viewer_protocol import ViewerLayerRetirementReceipt +from openhcs.runtime.viewer_protocol import ViewerLayerRetirementResult from python_introspect import to_jsonable @@ -172,10 +172,10 @@ def _send_control_message(self, value, message): def test_receipt_uses_original_declaration_codec_and_envelope_error_mro(): - receipt = ViewerLayerRetirementReceipt.from_wire_mapping({ + receipt = ViewerLayerRetirementResult.from_wire_mapping({ "applied": True, "retired_route_keys": ["exact-route"], "remaining_route_keys": ["raw"], }) assert receipt.retired_route_keys == ("exact-route",) with pytest.raises(ValueError, match="undeclared"): - ViewerLayerRetirementReceipt.from_wire_mapping({"extra": 1}) + ViewerLayerRetirementResult.from_wire_mapping({"extra": 1}) assert request().start_operation().control_deadline().timeout_ms == 5000 diff --git a/tests/unit/viewer_axes_fixture.py b/tests/unit/viewer_axes_fixture.py new file mode 100644 index 0000000000..cfec1bdcfb --- /dev/null +++ b/tests/unit/viewer_axes_fixture.py @@ -0,0 +1,26 @@ +"""The microscopy axis declarations a viewer stream carries, for viewer tests.""" + +from openhcs.domains.microscopy.axes import Microscopy +from openhcs.core.axes import OrdinalValued +from openhcs.runtime.viewer_display import DECLARED_AXES_FIELD, DeclaredAxes, DeclaredAxis + +STREAM_AXES = DeclaredAxes.of(Microscopy.axes) + + +def display_config_mapping(component_modes, component_order, **extra): + """A display-config wire section declaring the microscopy axes.""" + + return { + "component_modes": dict(component_modes), + "component_order": list(component_order), + DECLARED_AXES_FIELD: STREAM_AXES.to_wire(), + **extra, + } + + +def ordinal_axes(*names: str) -> DeclaredAxes: + """Role-less ordinal axes, for viewer tests that use synthetic axis names.""" + + return DeclaredAxes( + tuple(DeclaredAxis(name, name.title(), (), OrdinalValued) for name in names) + )