From 523cd3e83c407945a092361cba080668d279a493 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 02:22:36 -0400 Subject: [PATCH 1/7] G6: receipt for domain-blind viewers Co-Authored-By: Claude Opus 5.5 --- .../G6-domain-blind-viewers.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md 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..23d38eafd8 --- /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` 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. From 4a22e1018dd542bfb1049295caddfe20055ccffa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 02:24:44 -0400 Subject: [PATCH 2/7] G6: viewer DTOs stop importing the execution DTO module ExecutionConnectionProjection (renamed ExecutionConnectionFields: it carries a connection, nothing is projected) moves beside ExecutionConnectionSpec, so agent/dto/viewer.py no longer pulls zmq_execution_client, the orchestrator and the microscope handlers into the napari server (203 -> 75 openhcs modules). Co-Authored-By: Claude Opus 5.5 --- .../G6-domain-blind-viewers.md | 4 +- openhcs/agent/dto/execution.py | 48 +++++++------------ openhcs/agent/dto/execution_connection.py | 21 +++++++- openhcs/agent/dto/ui_bridge.py | 16 +++---- openhcs/agent/dto/viewer.py | 34 ++++++------- 5 files changed, 64 insertions(+), 59 deletions(-) diff --git a/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md index 23d38eafd8..92d633618c 100644 --- a/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md +++ b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md @@ -13,7 +13,7 @@ - **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. +- **Import reach.** `agent/dto/viewer.py:44` imports `agent/dto/execution.py` for `ExecutionConnectionFields`; through `zmq_execution_client` that loads 203 openhcs modules, 16 of them `openhcs.microscopes`, plus `core.orchestrator`, into the napari server process. ## Target @@ -42,7 +42,7 @@ class FijiSlots(ViewerSlotFamily): Channel(ColourAxis), Slice(StackAxis), Fram - 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` moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`. +- `ExecutionConnectionFields` moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`. ## Guards diff --git a/openhcs/agent/dto/execution.py b/openhcs/agent/dto/execution.py index 801bbb20c9..50bc3789de 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 @@ -434,7 +420,7 @@ class ArtifactPlanInspection(AgentResultEnvelope): @dataclass(frozen=True, slots=True) -class RuntimeServerInfo(ExecutionConnectionProjection): +class RuntimeServerInfo(ExecutionConnectionFields): schema_version: str reachable: bool ready: bool | None = None @@ -478,7 +464,7 @@ def from_pong( @dataclass(frozen=True, slots=True) -class RuntimeServerScanResult(ExecutionConnectionProjection): +class RuntimeServerScanResult(ExecutionConnectionFields): schema_version: str ports: tuple[int, ...] timeout_ms: int @@ -586,7 +572,7 @@ def as_tool_arguments(self) -> JsonObject: @dataclass(frozen=True, slots=True) class RuntimeServerInfoRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request a read-only runtime-server snapshot.""" @@ -730,7 +716,7 @@ class RuntimeBootstrapCloseResult(AgentResultEnvelope): @dataclass(frozen=True, slots=True) -class RuntimeExecutionStatus(ExecutionConnectionProjection): +class RuntimeExecutionStatus(ExecutionConnectionFields): schema_version: str execution_id: str | None status: str @@ -741,7 +727,7 @@ class RuntimeExecutionStatus(ExecutionConnectionProjection): @dataclass(frozen=True, slots=True) class RuntimeServerExecutionStatusRequest( RuntimeServerConnectionToolRequest, - ExecutionConnectionProjection, + ExecutionConnectionFields, ): """Request bounded execution status from a runtime server.""" @@ -781,7 +767,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 @@ -793,7 +779,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.""" @@ -845,7 +831,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. @@ -914,7 +900,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 @@ -928,7 +914,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. @@ -1019,7 +1005,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/ui_bridge.py b/openhcs/agent/dto/ui_bridge.py index 816347dc27..1e3c73103a 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..4d6dcf3a08 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 @@ -98,7 +98,7 @@ def from_wire_fields( @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" @@ -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 From 864e35b3d3366bad7a1e89219117f88323f56ab3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 03:55:37 -0400 Subject: [PATCH 3/7] =?UTF-8?q?G6:=20domain-blind=20viewers=20=E2=80=94=20?= =?UTF-8?q?declared=20axes,=20slot=20families,=20one=20control=20family,?= =?UTF-8?q?=20ViewerFamily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The display-config wire section carries the stream's DeclaredAxis list (name, label, roles, value kind). Viewers lay a stream out and validate it against its own declarations; no viewer module reads AxisFamily.active(). The exact-set rejection and from_display_payload copies in config.py are deleted; the viewer decodes NapariDisplaySettings/FijiDisplaySettings and no longer imports openhcs.core.config. - Each viewer owns a ViewerSlotFamily (NapariSlots: layer/stack; FijiSlots: window, C, Z, T by role, FRAME otherwise). NapariDimensionMode and FijiDimensionMode are views derived from them, and the per-role config defaults derive from the slot families. Partition and stack special cases in napari are role queries on the declared axes. Labels come from Axis.label. - One ViewerControlAction family: every viewer inherits shutdown, force-shutdown, clear-state, process-launch and settle over a ViewerServerPort; unknown messages answer ERROR (Fiji answered SUCCESS); pong capabilities derive from the registry for both viewers. The FijiUnsupported* plans are deleted. - One ViewerFamily class per viewer (NapariViewer, FijiViewer) owns wire name, backend, display settings and visualizer; ViewerType is the boundary enum derived from the registry; ViewerDeclarationABC and the StreamingConfig forwarding properties are deleted. start_viewer/stop_viewer live once in ManagedViewerLifecycleMixin. - Lockstep: zmqruntime 0.4.2 (flat ViewerComponentMode deleted) and PolyStore 0.3.5 (receivers take window/layer components). - Rule 1b renames in the viewer modules (Authority/admission/receipt names). - Guards in tests/unit/test_g6_viewer_guards.py; one-shot cutover tool for the moved NapariVariableSizeHandling import. Co-Authored-By: Claude Opus 5.5 --- .../G6-domain-blind-viewers.md | 4 +- .../streaming_boundary_and_wrappers.rst | 20 +- external/PolyStore | 2 +- external/zmqruntime | 2 +- openhcs/agent/dto/plate.py | 10 +- openhcs/agent/dto/viewer.py | 8 +- .../services/execution_session_service.py | 4 +- .../agent/services/plate_streaming_service.py | 4 +- openhcs/core/axes.py | 4 + openhcs/core/config.py | 417 +++--------- openhcs/core/context/processing_context.py | 8 +- openhcs/core/orchestrator/orchestrator.py | 4 +- openhcs/core/pipeline/compiler.py | 4 +- .../function_artifact_materialization.py | 2 +- openhcs/core/steps/function_outputs.py | 2 +- .../core/steps/stream_component_semantics.py | 15 +- openhcs/core/streaming_config_declarations.py | 205 +++--- openhcs/core/streaming_config_factory.py | 37 +- openhcs/core/viewer_streaming_service.py | 46 +- openhcs/demo/basic_pipeline.py | 5 - openhcs/demo/synthetic_plate_pipeline.py | 2 +- openhcs/domains/microscopy/axes.py | 3 + openhcs/mcp/installed_demo.py | 4 +- openhcs/pyqt_gui/main.py | 2 +- .../pyqt_gui/services/reactor_providers.py | 2 +- .../services/ui_bridge_live_overview.py | 4 +- openhcs/pyqt_gui/widgets/image_browser.py | 12 +- .../shared/server_browser/live_tree_sync.py | 4 +- .../services/pipeline_editor_workflows.py | 2 +- .../windows/debug_inspector_window.py | 2 +- openhcs/runtime/fiji_stream_visualizer.py | 59 +- openhcs/runtime/fiji_viewer_server.py | 436 +++--------- openhcs/runtime/napari_orthogonal_widget.py | 4 +- openhcs/runtime/napari_stream_visualizer.py | 101 +-- openhcs/runtime/napari_streaming_handlers.py | 54 +- openhcs/runtime/napari_viewer_server.py | 634 ++++++------------ openhcs/runtime/viewer_component_system.py | 303 ++++----- openhcs/runtime/viewer_control_actions.py | 319 +++++++++ openhcs/runtime/viewer_controls.py | 8 +- openhcs/runtime/viewer_display.py | 421 ++++++++++++ openhcs/runtime/viewer_measurements.py | 4 +- openhcs/runtime/viewer_protocol.py | 145 ++-- openhcs/runtime/viewer_snapshot.py | 4 +- pyproject.toml | 4 +- .../check_viewer_feature_measurement_live.py | 4 +- tests/fixtures/mcp/mcp_tool_contract.json | 2 +- .../test_cellprofiler_official30_zmq.py | 4 +- tests/integration/test_main.py | 2 +- .../test_image_browser_filter_controller.py | 2 +- .../agent/test_native_presentation_451452.py | 28 +- .../test_native_presentation_mcp_451452.py | 4 +- .../test_selected_plane_reopen_stream.py | 4 +- .../pyqt_gui/test_live_server_tree_sync.py | 4 +- .../test_ui_bridge_lifecycle_config.py | 4 +- ...est_aggregate_layer_materialization_515.py | 38 +- tests/unit/test_axis_family_witness.py | 67 +- .../test_configuration_mirror_deletion.py | 35 +- tests/unit/test_fiji_viewer_server.py | 173 ++--- .../test_function_artifact_materialization.py | 11 +- tests/unit/test_function_outputs.py | 9 +- tests/unit/test_g6_cutover_tool.py | 40 ++ tests/unit/test_g6_viewer_guards.py | 155 +++++ tests/unit/test_materialization_core.py | 7 +- .../test_napari_accepted_work_settlement.py | 14 +- .../unit/test_napari_orthogonal_navigation.py | 34 +- tests/unit/test_napari_render_snapshot.py | 18 +- tests/unit/test_napari_reset_transitions.py | 55 +- .../test_napari_shared_axis_navigation.py | 10 +- tests/unit/test_napari_streaming_handlers.py | 289 ++++---- tests/unit/test_napari_transport_ownership.py | 13 +- tests/unit/test_native_viewer_presentation.py | 24 +- .../test_rescale_stream_plane_declaration.py | 8 +- .../test_streaming_config_factory_registry.py | 46 +- tests/unit/test_streaming_service.py | 30 +- .../test_viewer_channel_display_metadata.py | 17 +- tests/unit/test_viewer_control_actions.py | 95 +++ tests/unit/test_viewer_feature_measurement.py | 16 +- tests/unit/test_viewer_listener_binding.py | 2 +- tests/unit/test_viewer_protocol.py | 61 +- tests/unit/test_viewer_receipt_replay.py | 38 +- .../test_viewer_result_element_selection.py | 65 +- tests/unit/test_viewer_retirement_contract.py | 6 +- tests/unit/viewer_axes_fixture.py | 26 + tools/cutover/g6_viewer_display.py | 80 +++ 84 files changed, 2563 insertions(+), 2313 deletions(-) create mode 100644 openhcs/runtime/viewer_control_actions.py create mode 100644 openhcs/runtime/viewer_display.py create mode 100644 tests/unit/test_g6_cutover_tool.py create mode 100644 tests/unit/test_g6_viewer_guards.py create mode 100644 tests/unit/test_viewer_control_actions.py create mode 100644 tests/unit/viewer_axes_fixture.py create mode 100644 tools/cutover/g6_viewer_display.py diff --git a/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md index 92d633618c..a1611987f3 100644 --- a/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md +++ b/docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md @@ -13,7 +13,7 @@ - **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 `ExecutionConnectionFields`; through `zmq_execution_client` that loads 203 openhcs modules, 16 of them `openhcs.microscopes`, plus `core.orchestrator`, into the napari server process. +- **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 @@ -42,7 +42,7 @@ class FijiSlots(ViewerSlotFamily): Channel(ColourAxis), Slice(StackAxis), Fram - 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. -- `ExecutionConnectionFields` moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`. +- `ExecutionConnectionProjection` (renamed `ExecutionConnectionFields`, rule 1b) moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`. ## Guards 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..8d59883761 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 3938236ecd1ed09e73c31c89304247b1409eff9b +Subproject commit 8d59883761f1ecc7b21abb81bbff68f364ec77d8 diff --git a/external/zmqruntime b/external/zmqruntime index e1047d0fad..d75612e1fb 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit e1047d0fad9397bde1f5faa0092ce9f5d99904ba +Subproject commit d75612e1fbb4a65cb8c9b97c0355b46ad27a81a8 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/viewer.py b/openhcs/agent/dto/viewer.py index 4d6dcf3a08..91e4f38581 100644 --- a/openhcs/agent/dto/viewer.py +++ b/openhcs/agent/dto/viewer.py @@ -71,7 +71,7 @@ OpenHCSViewerControlMessageType, ViewerProtocolStatus, ViewerImageColorControlOptions, ViewerNativeImageColorPresentation, ViewerNativeWindowControlOptions, ViewerNativeWindowState, - ViewerLayerRetirementReceipt, + ViewerLayerRetirementResult, ) VIEWER_WINDOW_CONTROL_TIMEOUT_MS_DEFAULT = 5000 @@ -92,7 +92,7 @@ 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, ) @@ -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 diff --git a/openhcs/agent/services/execution_session_service.py b/openhcs/agent/services/execution_session_service.py index afa9f75453..66c2b49c57 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 2480216fc8..6f3fbefcad 100644 --- a/openhcs/core/axes.py +++ b/openhcs/core/axes.py @@ -210,6 +210,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).""" sort_key: ClassVar # supplied by the axis's AxisValueKind normalize_value: ClassVar # supplied by the axis's AxisValueKind @@ -220,6 +222,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 45d4156b6f..088232efab 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, Callable @@ -42,11 +41,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 @@ -239,12 +253,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 @@ -259,320 +274,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") @@ -1072,8 +863,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 @@ -1082,35 +874,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 @@ -1142,15 +909,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 @@ -1158,19 +928,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 ( @@ -1189,7 +959,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.""" @@ -1221,7 +991,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.""" @@ -1287,7 +1057,6 @@ def runtime_config_parameter( # ============================================================================ # Import streaming port utility from factory module -from openhcs.core.streaming_config_factory import get_all_streaming_ports # ============================================================================ # Configuration Framework Initialization diff --git a/openhcs/core/context/processing_context.py b/openhcs/core/context/processing_context.py index 9f9bf3130a..9f391f4e98 100644 --- a/openhcs/core/context/processing_context.py +++ b/openhcs/core/context/processing_context.py @@ -38,7 +38,7 @@ VirtualWorkspaceSourceProjectionAuthority, ) 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 @@ -54,13 +54,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 902556d274..099bda6bca 100644 --- a/openhcs/core/orchestrator/orchestrator.py +++ b/openhcs/core/orchestrator/orchestrator.py @@ -277,7 +277,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( @@ -302,7 +302,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 6c8302d2c4..6fc4a15b16 100644 --- a/openhcs/core/pipeline/compiler.py +++ b/openhcs/core/pipeline/compiler.py @@ -453,7 +453,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", @@ -466,7 +466,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 197b736dee..4ad978a050 100644 --- a/openhcs/core/steps/function_artifact_materialization.py +++ b/openhcs/core/steps/function_artifact_materialization.py @@ -225,7 +225,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 c06b5e2723..b424beb255 100644 --- a/openhcs/core/steps/function_outputs.py +++ b/openhcs/core/steps/function_outputs.py @@ -638,7 +638,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 6722dc0645..8e9c9ba31b 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, @@ -86,10 +86,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 bd58eed424..4febfa4575 100644 --- a/openhcs/core/viewer_streaming_service.py +++ b/openhcs/core/viewer_streaming_service.py @@ -213,19 +213,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, @@ -247,12 +247,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() ), ) @@ -585,14 +585,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( @@ -601,15 +601,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: @@ -619,7 +619,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( @@ -630,7 +630,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: @@ -642,7 +642,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}" @@ -653,8 +653,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( @@ -767,7 +767,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", @@ -782,7 +782,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: @@ -794,7 +794,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( @@ -804,14 +804,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", @@ -866,7 +866,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", @@ -981,7 +981,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 a86ce99ccf..3fd41c435a 100644 --- a/openhcs/domains/microscopy/axes.py +++ b/openhcs/domains/microscopy/axes.py @@ -25,15 +25,18 @@ class Site(Axis, TileAxis, DefaultVariable, OrdinalValued): class Channel(Axis, ColourAxis, DefaultGroupBy, OrdinalValued): name = "channel" + label = "Ch" filename_prefix = "w" class ZIndex(Axis, StackAxis, OrdinalValued): name = "z_index" + label = "Z" filename_prefix = "z" filename_padding = 3 class Timepoint(Axis, TimeAxis, OrdinalValued): name = "timepoint" + label = "T" filename_prefix = "t" filename_padding = 3 diff --git a/openhcs/mcp/installed_demo.py b/openhcs/mcp/installed_demo.py index 4ae3f4b0c8..28fe17311c 100644 --- a/openhcs/mcp/installed_demo.py +++ b/openhcs/mcp/installed_demo.py @@ -34,7 +34,7 @@ from openhcs.core.pipeline_document import PipelineDocumentAuthority from openhcs.core.plate_file_inventory import PlateFileKind from openhcs.core.steps.function_step import FunctionStep -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import NapariViewer from openhcs.mcp.bootstrap import MCP_VERBOSE_ENVIRONMENT_VARIABLE from openhcs.mcp.dev_client import McpDevClient, McpDevCommandExecution from openhcs.mcp.dev_client_commands.knowledge_pipeline import ( @@ -667,7 +667,7 @@ def _validate_viewer(client: McpDevClient, viewer_port: int) -> dict[str, Any]: or payload["mounted_layer_count"] < 1 or not isinstance(payload.get("nonzero_payload_count"), int) or payload["nonzero_payload_count"] < 1 - or viewer_type != ViewerType.NAPARI.wire_value + or viewer_type != NapariViewer.wire_value ): raise InstalledDemoFailure( f"Installed Napari viewer validation did not pass: {payload}" diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index 79a7d7219a..7f0ae17106 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -581,7 +581,7 @@ def zmq_server_manager_ports_to_scan( self, ui_config: UIConfig | None = None, ) -> list[int]: - from openhcs.core.config import get_all_streaming_ports + from openhcs.core.streaming_config_factory import get_all_streaming_ports config = self.runtime_context.ui_config if ui_config is None else ui_config zmq_config = config.zmq diff --git a/openhcs/pyqt_gui/services/reactor_providers.py b/openhcs/pyqt_gui/services/reactor_providers.py index 1759a2b70c..c79a3fc648 100644 --- a/openhcs/pyqt_gui/services/reactor_providers.py +++ b/openhcs/pyqt_gui/services/reactor_providers.py @@ -146,7 +146,7 @@ def scan_for_server_logs(self) -> list[LogFileInfo]: ) from zmqruntime.execution.logs import ExecutionWorkerLogObservation - from openhcs.core.config import get_all_streaming_ports + from openhcs.core.streaming_config_factory import get_all_streaming_ports from openhcs.core.log_utils import classify_log_file config = self._config_provider() 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 784828db5c..6d16ca6484 100644 --- a/openhcs/pyqt_gui/widgets/image_browser.py +++ b/openhcs/pyqt_gui/widgets/image_browser.py @@ -721,7 +721,7 @@ def _create_right_panel(self): def _create_instance_manager_panel(self): """Create the viewer instance manager panel using ZMQServerManagerWidget.""" - from openhcs.core.config import get_all_streaming_ports + from openhcs.core.streaming_config_factory import get_all_streaming_ports from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ( ZMQServerManagerWidget, ) @@ -749,7 +749,7 @@ def set_zmq_config(self, config: OpenHCSZMQConfig) -> None: self._zmq_config = config if self.zmq_manager is None: return - from openhcs.core.config import get_all_streaming_ports + from openhcs.core.streaming_config_factory import get_all_streaming_ports self.zmq_manager.set_zmq_config( config, @@ -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 ac3f18a1f4..e285e398f7 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..f5ac257411 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.Channel), + z_axis_components=layout.components_in(FijiSlots.Slice), + frame=layout.components_in(FijiSlots.Frame), ), 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 6be3577fc9..3e6019051a 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, @@ -266,7 +266,7 @@ class NapariPendingLayerUpdate(ViewerComponentAxisSemantics): timer: NapariTimerHandle data_type: StreamingDataType - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings items: list[NapariStreamLayerItem] = field(default_factory=list) @classmethod @@ -276,7 +276,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( @@ -311,7 +311,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 @@ -705,7 +705,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 @@ -716,9 +716,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 ) @@ -1051,7 +1049,7 @@ class NapariLayerSelectionSnapshot: replacing_active_layer: bool -class NapariLayerSelectionAuthority: +class NapariLayerSelection: """Preserve user layer selection across automatic streaming updates.""" @classmethod @@ -1096,7 +1094,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( @@ -1110,7 +1108,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), ) @@ -1146,14 +1144,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, @@ -1181,7 +1179,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": @@ -1292,7 +1290,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, ...]: @@ -1423,7 +1421,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", ) @@ -1482,7 +1480,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", ) @@ -1778,7 +1776,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, @@ -1859,12 +1857,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.""" @@ -1900,7 +1898,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) @@ -2070,7 +2068,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) @@ -2090,7 +2088,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}." @@ -2122,10 +2120,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 e75033bcc9..521f3b47fe 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, @@ -216,7 +214,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 @@ -430,7 +428,7 @@ def value( @dataclass(frozen=True) -class NapariBatchPayload(ViewerDisplayBatchContext[NapariDisplayConfig]): +class NapariBatchPayload(ViewerDisplayBatchContext[NapariDisplaySettings]): """Typed view of an incoming batch message.""" images: NapariBatchImagePayloads @@ -450,15 +448,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, ) @@ -490,7 +490,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) @@ -511,21 +511,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(): @@ -570,10 +570,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) @@ -643,14 +643,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: @@ -688,6 +688,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, @@ -699,14 +700,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 = ( @@ -714,8 +713,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, @@ -763,7 +762,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( @@ -825,7 +824,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 @@ -922,7 +920,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( @@ -1010,7 +1008,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 @@ -1021,24 +1019,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 + }, + } ), ) @@ -1265,6 +1265,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. @@ -1302,10 +1303,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 @@ -1435,7 +1437,7 @@ def _build_nd_image_array( return stacked_array -class NapariLayerTitleAuthority: +class NapariLayerTitles: """Build visible titles from producer identity and layer-routed components.""" @classmethod @@ -1449,10 +1451,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", @@ -1510,7 +1510,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 @@ -1857,7 +1857,7 @@ class NapariLayerDisplayRequest: pipeline: "NapariLayerDisplayPipeline" items: list[NapariStreamLayerItem] presentation: NapariAxisPresentation - display_config: NapariDisplayConfig + display_config: NapariDisplaySettings def create_or_update_layer( self, @@ -1946,7 +1946,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. @@ -2005,8 +2005,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 @@ -2354,8 +2354,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( @@ -2374,10 +2374,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." @@ -2388,7 +2388,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", @@ -2421,6 +2421,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 @@ -2495,7 +2496,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() @@ -2539,11 +2540,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: @@ -2590,7 +2591,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, @@ -2640,7 +2641,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, ) @@ -2758,7 +2759,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, @@ -2855,7 +2856,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 ( @@ -2973,13 +2974,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( @@ -3033,59 +3032,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: @@ -3097,7 +3057,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: @@ -3113,187 +3073,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.""" @@ -3471,7 +3250,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 @@ -3559,7 +3338,7 @@ def index( ) -class NapariResultElementSelectionAuthority: +class NapariResultElementSelections: """Own native feature-row selection without layer-kind or assay dispatch.""" @classmethod @@ -3694,7 +3473,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) @@ -3705,7 +3484,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) @@ -3727,7 +3506,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: @@ -3741,7 +3520,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: @@ -3756,15 +3535,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(): @@ -3875,7 +3654,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( @@ -3897,13 +3676,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: @@ -3941,7 +3720,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: @@ -3957,16 +3736,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, ) @@ -3982,14 +3761,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: @@ -4046,7 +3825,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, @@ -4066,7 +3845,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 @@ -4082,7 +3861,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( @@ -4116,7 +3895,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 @@ -4136,7 +3915,7 @@ def _apply_resident_selection( ) return try: - navigation = NapariNavigationControlMessageAction() + navigation = NapariNavigationControlAction() axis_indices = navigation.result_element_axis_indices( self.server, native_layer, @@ -4250,7 +4029,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: @@ -4308,7 +4087,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 @@ -4375,7 +4154,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), @@ -4419,7 +4198,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( @@ -5214,7 +4993,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 @@ -5245,7 +5024,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 @@ -5277,7 +5056,7 @@ def handle( ).to_wire_mapping() -class NapariMountedRouteControlMessageAction(NapariAppliedFrameControlMessageAction): +class NapariMountedRouteControlAction(NapariAppliedFrameControlAction): """Shared exact mounted-route boundary for routed native commands.""" def _mounted_layer( @@ -5356,8 +5135,8 @@ def matched_image_records( return tuple(records) -class NapariFeatureMeasurementControlMessageAction( - NapariMountedRouteControlMessageAction +class NapariFeatureMeasurementControlAction( + NapariMountedRouteControlAction ): """Read-only bounded measurement; executes on the owning Qt thread.""" @@ -5490,8 +5269,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 @@ -5502,8 +5281,8 @@ def measure( return plane.polyline(request) -class NapariRegionMeasurementControlMessageAction( - NapariFeatureMeasurementControlMessageAction +class NapariRegionMeasurementControlAction( + NapariFeatureMeasurementControlAction ): message_type = OpenHCSViewerControlMessageType.MEASURE_REGION.value request_type = ViewerRegionControlOptions @@ -5514,8 +5293,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] @@ -5546,7 +5325,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 @@ -5559,8 +5338,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.""" @@ -5579,7 +5358,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 @@ -5590,7 +5369,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 @@ -5665,7 +5444,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 @@ -5869,8 +5648,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.""" @@ -5973,7 +5752,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, ) @@ -6139,7 +5918,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 {} @@ -6258,7 +6037,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 @@ -6269,14 +6048,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( @@ -6285,7 +6064,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 @@ -6365,7 +6144,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 @@ -6383,8 +6162,8 @@ def _apply( return changed_route_count, () -class NapariScreenshotControlMessageAction( - NapariAppliedFrameControlMessageAction, ViewerWindowSnapshotService +class NapariScreenshotControlAction( + NapariAppliedFrameControlAction, ViewerWindowSnapshotService ): """Registered action that captures the Napari Qt window.""" @@ -6402,12 +6181,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) @@ -6423,9 +6202,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): @@ -6446,7 +6225,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 @@ -6463,7 +6242,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( @@ -6497,27 +6276,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.""" @@ -6737,11 +6495,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( @@ -6754,7 +6512,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. @@ -6762,17 +6520,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): """ @@ -6785,7 +6561,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, @@ -6978,7 +6754,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 @@ -7012,26 +6788,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 = { @@ -7047,8 +6803,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: @@ -7069,11 +6825,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: @@ -7119,7 +6875,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.""" @@ -7135,7 +6891,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..e6c4e2e633 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,8 @@ def from_wire_mapping( def merge(self, incoming: "ViewerComponentNameMetadata") -> None: self.store.merge_store(incoming.store) + if incoming.declared_axes: + object.__setattr__(self, "declared_axes", incoming.declared_axes) def clear(self) -> None: self.store.clear() @@ -868,12 +858,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 +935,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 +948,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 +961,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 +1236,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 +1263,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 +1291,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 +1457,7 @@ def projection_steps(self) -> tuple[ViewerLayerAxisProjectionStep, ...]: ) -class ViewerLayerAxisProjectionRequestAuthority: +class ViewerLayerAxisProjectionRequestBuilder: """Build viewer-axis projection requests from component-axis semantics.""" @staticmethod @@ -1507,8 +1472,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 +1498,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 +1550,7 @@ def route_coordinates( ), } coordinates.add( - ViewerComponentCoordinateAuthority.value_tuple( + ViewerComponentCoordinates.value_tuple( components, axis_components, context="viewer routed item", @@ -1633,7 +1598,7 @@ def project( ) -class ViewerComponentCoordinateAuthority: +class ViewerComponentCoordinates: """Fail-loud component coordinate lookup shared by viewer backends.""" @staticmethod @@ -1723,7 +1688,7 @@ def value_tuple( ) -class ViewerDimensionValueAuthority: +class ViewerDimensionValues: """Collect and index tuple-valued dimensions for viewer coordinate systems.""" @classmethod @@ -1761,7 +1726,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..4c5cc2cf96 --- /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).""" + + choices = Enum( + name, + [(slot.__name__.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 Channel(ViewerSlot): + wire_value = "channel" + roles = (ColourAxis,) + + class Slice(ViewerSlot): + wire_value = "slice" + roles = (StackAxis,) + + class Frame(ViewerSlot): + wire_value = "frame" + roles = (TimeAxis,) + + default_slot = Frame + + +# --------------------------------------------------------------------------- +# 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..df48f39f9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,13 +99,13 @@ dependencies = [ "psutil>=5.9.0", # External dependencies (PyPI versions for production builds) - "zmqruntime>=0.4.1,<0.5", + "zmqruntime>=0.4.2,<0.5", "pycodify>=0.1.3,<0.2", "objectstate>=1.3.1,<2", "python-introspect>=0.2.3,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.3.8,<0.4", - "polystore>=0.3.3,<0.4", + "polystore>=0.3.5,<0.4", "pyqt-reactive>=0.3.28,<0.4", ] diff --git a/tests/diagnostics/check_viewer_feature_measurement_live.py b/tests/diagnostics/check_viewer_feature_measurement_live.py index 1775a9231f..b5c3e639be 100644 --- a/tests/diagnostics/check_viewer_feature_measurement_live.py +++ b/tests/diagnostics/check_viewer_feature_measurement_live.py @@ -199,7 +199,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 @@ -300,7 +300,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..d34da4dc93 100644 --- a/tests/integration/test_cellprofiler_official30_zmq.py +++ b/tests/integration/test_cellprofiler_official30_zmq.py @@ -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 diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index aa3cf69487..d290b9fc09 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 479d6f5223..2d97ce07d8 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 e37a15b935..ecdcfa3b7d 100644 --- a/tests/unit/agent/test_selected_plane_reopen_stream.py +++ b/tests/unit/agent/test_selected_plane_reopen_stream.py @@ -35,7 +35,7 @@ from openhcs.core.virtual_workspace_metadata import AtomicMetadataWriter from openhcs.microscopes.source_schema import SourceSchemaFilenameParser from openhcs.runtime.napari_streaming_handlers import ( - NapariAggregateAxisBindingAuthority, + NapariAggregateAxisBindingBuilder, NapariImagePayloadAxisLabelPolicy, NapariStreamLayerItem, ) @@ -93,7 +93,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/pyqt_gui/test_ui_bridge_lifecycle_config.py b/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py index d68860d015..03169cf3c7 100644 --- a/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py +++ b/tests/unit/pyqt_gui/test_ui_bridge_lifecycle_config.py @@ -114,7 +114,7 @@ def test_main_window_zmq_scan_ports_include_actual_ui_bridge_binding( monkeypatch, ) -> None: monkeypatch.setattr( - "openhcs.core.config.get_all_streaming_ports", + "openhcs.core.streaming_config_factory.get_all_streaming_ports", lambda num_ports_per_type: [5555], ) ui_config = replace( @@ -147,7 +147,7 @@ def test_main_window_zmq_scan_ports_exclude_unstarted_configured_bridge( monkeypatch, ) -> None: monkeypatch.setattr( - "openhcs.core.config.get_all_streaming_ports", + "openhcs.core.streaming_config_factory.get_all_streaming_ports", lambda num_ports_per_type: [5555], ) ui_config = replace( diff --git a/tests/unit/test_aggregate_layer_materialization_515.py b/tests/unit/test_aggregate_layer_materialization_515.py index 76de7488c1..8df4eb008d 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 787c388d77..35b722bb81 100644 --- a/tests/unit/test_axis_family_witness.py +++ b/tests/unit/test_axis_family_witness.py @@ -98,8 +98,71 @@ 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.Channel) == ("band",) + assert fiji.components_in(FijiSlots.Slice) == () + assert fiji.components_in(FijiSlots.Frame) == ("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): + 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 a3e3fdf196..48a41cd8b0 100644 --- a/tests/unit/test_configuration_mirror_deletion.py +++ b/tests/unit/test_configuration_mirror_deletion.py @@ -417,45 +417,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 c8ace42675..9a27d7bffc 100644 --- a/tests/unit/test_function_artifact_materialization.py +++ b/tests/unit/test_function_artifact_materialization.py @@ -134,7 +134,6 @@ IncompleteFunctionOutputFilenameIdentityError, ) -from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, StreamingViewerSurface, @@ -158,8 +157,6 @@ ) from openhcs.processing.materialization.core import ( MaterializationSpec, - Output, - SavedMaterializationOutputs, materialization_outputs, ) from openhcs.processing.materialization.options import ( @@ -168,10 +165,12 @@ ) 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 class StreamingConfigStub(ViewerDisplayConfigABC): - backend = SimpleNamespace(value="napari_stream") + viewer_family = NapariViewer COMPONENT_ORDER = AxisFamily.active().names() host = "127.0.0.1" transport_mode = "tcp" @@ -186,6 +185,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, } @@ -199,7 +199,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), @@ -2926,6 +2926,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 cf46727479..f3e37340c8 100644 --- a/tests/unit/test_function_outputs.py +++ b/tests/unit/test_function_outputs.py @@ -82,7 +82,6 @@ StreamOutputBatch, finalize_function_step_outputs, ) -from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.streaming_config_factory import ( StreamingViewerRuntimeConfig, StreamingViewerSurface, @@ -106,6 +105,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 def _publish_saved_step(context, plan, *, artifact_materializations=()): @@ -249,7 +250,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 @@ -259,7 +260,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( @@ -270,7 +271,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_cutover_tool.py b/tests/unit/test_g6_cutover_tool.py new file mode 100644 index 0000000000..5d2ffcebec --- /dev/null +++ b/tests/unit/test_g6_cutover_tool.py @@ -0,0 +1,40 @@ +"""The one-shot G6 cutover moves saved imports of relocated viewer names. + +This test is deleted with ``tools/cutover/g6_viewer_display.py``. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import openhcs + +TOOL = Path(openhcs.__file__).resolve().parents[1] / "tools/cutover/g6_viewer_display.py" + + +def _tool(): + spec = importlib.util.spec_from_file_location("g6_viewer_display", TOOL) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_saved_config_imports_variable_size_handling_from_viewer_display() -> None: + source = ( + "from openhcs.core.config import (\n" + " LazyNapariStreamingConfig,\n" + " NapariVariableSizeHandling,\n" + ")\n" + "config = LazyNapariStreamingConfig(\n" + " variable_size_handling=NapariVariableSizeHandling.SEPARATE_LAYERS,\n" + ")\n" + ) + + rewritten = _tool().rewrite_source(source) + namespace: dict[str, object] = {} + exec(compile(rewritten, "", "exec"), namespace) + + assert "from openhcs.runtime.viewer_display import NapariVariableSizeHandling" in rewritten + assert namespace["config"].variable_size_handling.value == "separate_layers" + assert _tool().rewrite_source(rewritten) == rewritten 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 554d7193d9..1a3bd2124c 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 def _memory_materialize(spec, data, path, filemanager): @@ -347,7 +348,7 @@ def component_modes(self): return {} def display_payload_extra(self): - return {} + return {"declared_axes": STREAM_AXES.to_wire()} class _TestViewerFilenameParser(ViewerFilenameParserABC): @@ -856,9 +857,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] @@ -926,6 +928,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_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..c618efd91c 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), ) @@ -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 5c509cf786..44831170fb 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,18 +88,22 @@ ViewerStateControlOptions, ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG -from openhcs.domains.microscopy.axes import Microscopy +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()} 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( @@ -171,7 +170,7 @@ def test_napari_stream_context_reconstructs_exact_source_spatial_domain(): }, "test image payload", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) @@ -211,13 +210,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, @@ -399,6 +399,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() == { @@ -785,7 +786,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", @@ -959,7 +960,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: ( @@ -1178,7 +1179,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: ( @@ -1208,7 +1209,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: ( @@ -1282,7 +1283,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: ( @@ -1304,7 +1305,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, @@ -1365,7 +1366,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, @@ -1387,7 +1388,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, @@ -1422,7 +1423,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( @@ -1581,9 +1582,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( @@ -1608,9 +1611,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( @@ -1700,7 +1705,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") @@ -1751,7 +1756,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: @@ -1776,11 +1781,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, @@ -1826,11 +1832,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]}), @@ -1874,8 +1881,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") @@ -1975,6 +1983,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", @@ -2059,6 +2077,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, ) @@ -2066,10 +2085,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(): @@ -2096,8 +2115,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" @@ -2160,11 +2180,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, @@ -2429,7 +2450,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",), @@ -2442,7 +2463,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(): @@ -2581,7 +2602,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", @@ -2660,7 +2681,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, @@ -2837,7 +2858,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",), @@ -2849,7 +2870,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",), @@ -2865,13 +2886,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(): @@ -2889,7 +2910,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",), @@ -2942,7 +2963,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", @@ -2963,11 +2984,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( @@ -2987,7 +3008,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(): @@ -3022,7 +3043,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",), @@ -3034,7 +3055,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",), @@ -3049,7 +3070,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(): @@ -3084,7 +3105,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",), @@ -3097,7 +3118,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",), @@ -3111,13 +3132,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(): @@ -3284,7 +3305,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): @@ -3330,7 +3351,7 @@ def stop(self): NapariPendingLayerUpdate.from_semantics( timer=pending_timer, data_type=StreamingDataType.IMAGE, - semantics=ViewerComponentAxisSemanticsAuthority.empty(), + semantics=ViewerComponentAxisSemanticsFactory.empty(), display_config=NapariDisplayConfig(), ), ) @@ -3347,7 +3368,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"]) == { @@ -3389,7 +3410,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 @@ -3399,7 +3420,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 @@ -3938,7 +3959,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" @@ -4295,31 +4316,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 @@ -4364,13 +4366,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 @@ -4389,50 +4391,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(): @@ -4477,7 +4439,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, @@ -4538,7 +4500,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}, @@ -4653,9 +4615,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"]}), ) @@ -4737,11 +4700,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]}), @@ -4812,11 +4776,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"]}), @@ -4882,7 +4847,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( @@ -4924,7 +4889,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={}, @@ -4953,7 +4918,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", @@ -4991,7 +4956,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) @@ -5036,7 +5001,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), ) @@ -5097,14 +5062,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) @@ -5174,7 +5139,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), ) @@ -5182,7 +5147,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, {})) @@ -5232,7 +5197,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), ) @@ -5289,7 +5254,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 @@ -5297,7 +5262,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( { @@ -5322,7 +5287,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", @@ -5422,11 +5387,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), @@ -5644,7 +5610,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"] @@ -5851,11 +5817,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, @@ -5872,7 +5839,7 @@ def test_napari_aggregate_axis_binding_uses_declared_component_not_equal_extent( ) ] - bindings = NapariAggregateAxisBindingAuthority.bindings( + bindings = NapariAggregateAxisBindingBuilder.bindings( items, component_axis_semantics, ) @@ -5905,11 +5872,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, @@ -5917,7 +5885,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}, @@ -5944,11 +5912,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, @@ -5956,7 +5925,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}, @@ -6227,8 +6196,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( @@ -6363,8 +6333,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( @@ -6380,7 +6351,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,) @@ -6404,7 +6375,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(): @@ -6440,7 +6413,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 d025cbb91e..920efc15f0 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,13 +57,15 @@ ViewerControlMessageType, ViewerControlResponseField, ) +from openhcs.runtime.viewer_display import NapariSlots +from tests.unit.viewer_axes_fixture import STREAM_AXES 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=(), @@ -119,7 +121,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 @@ -135,7 +137,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 @@ -165,7 +167,7 @@ def test_malformed_new_metadata_never_falls_back_to_legacy(value): }, "test", ), - ViewerComponentAxisSemanticsAuthority.empty(), + ViewerComponentAxisSemanticsFactory.empty(), NapariDisplayConfig(), ) @@ -303,13 +305,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( @@ -354,7 +356,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 765578732d..6910326574 100644 --- a/tests/unit/test_rescale_stream_plane_declaration.py +++ b/tests/unit/test_rescale_stream_plane_declaration.py @@ -34,10 +34,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. @@ -45,6 +45,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]) @@ -126,10 +127,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 image_payload_data(result).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 4b76eb0838..4310a5f3fd 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -34,8 +34,8 @@ NapariStreamingConfig, PipelineConfig, StreamingConfig, - get_all_streaming_ports, ) +from openhcs.core.streaming_config_factory import get_all_streaming_ports from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, image_payload_data, @@ -82,7 +82,7 @@ from openhcs.runtime.viewer_component_system import ( ViewerComponentAxisSemantics, ViewerComponentValueDomainPayload, - ViewerLayerAxisProjectionRequestAuthority, + ViewerLayerAxisProjectionRequestBuilder, ViewerLayerAxisProjector, ViewerObjectDisplayConfigInput, ViewerRouteComponentValueTracker, @@ -93,6 +93,7 @@ materialize, ) from openhcs.domains.microscopy.axes import Microscopy +from tests.unit.viewer_axes_fixture import STREAM_AXES class FakeFileManager: @@ -173,14 +174,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() @@ -191,7 +192,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: @@ -1069,6 +1070,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, @@ -1085,7 +1087,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], @@ -1097,7 +1099,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] == [ @@ -1361,7 +1363,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: @@ -1399,7 +1401,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, @@ -1422,5 +1424,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 c843882d67..558ecbad19 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 be7f100b6c..8d05fa3e6f 100644 --- a/tests/unit/test_viewer_receipt_replay.py +++ b/tests/unit/test_viewer_receipt_replay.py @@ -43,6 +43,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 def receipt_state(path, *, summary=None): @@ -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_payload_data(image), ) - 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) + ) diff --git a/tools/cutover/g6_viewer_display.py b/tools/cutover/g6_viewer_display.py new file mode 100644 index 0000000000..980462efdf --- /dev/null +++ b/tools/cutover/g6_viewer_display.py @@ -0,0 +1,80 @@ +"""One-shot G6 cutover: saved OpenHCS Python sources import the moved viewer names. + +Surface G6 moved ``NapariVariableSizeHandling`` from ``openhcs.core.config`` +to ``openhcs.runtime.viewer_display``, beside the viewer-side display settings. +Pipelines and configs are saved as pycodify-generated Python (``.py`` +pipelines, ``global_config.config`` and plate configs); this tool rewrites +the imports in any such source. + +Usage:: + + python tools/cutover/g6_viewer_display.py PATH [PATH ...] + +Each file is rewritten in place after a ``.pre-g6`` backup is written +beside it. This tool is deleted once the owner has migrated; nothing in +``openhcs/`` reads the pre-G6 spelling. +""" + +from __future__ import annotations + +import ast +import shutil +import sys +from pathlib import Path + +MOVED = { + "NapariVariableSizeHandling": ("openhcs.core.config", "openhcs.runtime.viewer_display"), +} + + +def rewrite_source(source: str) -> str: + """Return ``source`` with moved names imported from their new modules.""" + + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + edits = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + moved = [ + alias + for alias in node.names + if alias.name in MOVED and MOVED[alias.name][0] == node.module + ] + if not moved: + continue + keep = [alias for alias in node.names if alias not in moved] + first = lines[node.lineno - 1] + indent = first[: len(first) - len(first.lstrip())] + + def spell(alias: ast.alias) -> str: + return alias.name if alias.asname is None else f"{alias.name} as {alias.asname}" + + text = "" + if keep: + text += f"{indent}from {node.module} import {', '.join(spell(a) for a in keep)}\n" + for alias in moved: + text += f"{indent}from {MOVED[alias.name][1]} import {spell(alias)}\n" + edits.append((node.lineno, node.end_lineno, text)) + for start, end, text in sorted(edits, reverse=True): + lines[start - 1 : end] = [text] + rewritten = "".join(lines) + ast.parse(rewritten) + return rewritten + + +def main(paths: list[str]) -> int: + for raw in paths: + path = Path(raw) + source = path.read_text(encoding="utf-8") + rewritten = rewrite_source(source) + if rewritten == source: + continue + shutil.copy2(path, path.with_name(path.name + ".pre-g6")) + path.write_text(rewritten, encoding="utf-8") + print(f"rewrote {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 76bf36ec689ae8bab23f762a922ffebe10da3ea9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 04:03:07 -0400 Subject: [PATCH 4/7] G6: pin zmqruntime 0.5.0, PolyStore 0.4.0, pyqt-reactive 0.3.29; drop the cutover tool The owner's files do not reference NapariVariableSizeHandling, so the one-shot import cutover is not needed. Co-Authored-By: Claude Opus 5.5 --- external/PolyStore | 2 +- external/pyqt-reactive | 2 +- external/zmqruntime | 2 +- pyproject.toml | 6 +-- tests/unit/test_g6_cutover_tool.py | 40 --------------- tools/cutover/g6_viewer_display.py | 80 ------------------------------ 6 files changed, 6 insertions(+), 126 deletions(-) delete mode 100644 tests/unit/test_g6_cutover_tool.py delete mode 100644 tools/cutover/g6_viewer_display.py diff --git a/external/PolyStore b/external/PolyStore index 8d59883761..32366a0908 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 8d59883761f1ecc7b21abb81bbff68f364ec77d8 +Subproject commit 32366a0908717cebca0b9968333e31e53b479c0d diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 29d2ad3196..d7d90cf81a 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 29d2ad319642de047410bca00c978a115e3f3452 +Subproject commit d7d90cf81a0fd573b0759df32d026bbf3b195c37 diff --git a/external/zmqruntime b/external/zmqruntime index d75612e1fb..8bd1798af7 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit d75612e1fbb4a65cb8c9b97c0355b46ad27a81a8 +Subproject commit 8bd1798af7119405dba5ac75cfacae36fb39f9e6 diff --git a/pyproject.toml b/pyproject.toml index df48f39f9a..633ee1c7e5 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.2,<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", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.3.8,<0.4", - "polystore>=0.3.5,<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/tests/unit/test_g6_cutover_tool.py b/tests/unit/test_g6_cutover_tool.py deleted file mode 100644 index 5d2ffcebec..0000000000 --- a/tests/unit/test_g6_cutover_tool.py +++ /dev/null @@ -1,40 +0,0 @@ -"""The one-shot G6 cutover moves saved imports of relocated viewer names. - -This test is deleted with ``tools/cutover/g6_viewer_display.py``. -""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import openhcs - -TOOL = Path(openhcs.__file__).resolve().parents[1] / "tools/cutover/g6_viewer_display.py" - - -def _tool(): - spec = importlib.util.spec_from_file_location("g6_viewer_display", TOOL) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_saved_config_imports_variable_size_handling_from_viewer_display() -> None: - source = ( - "from openhcs.core.config import (\n" - " LazyNapariStreamingConfig,\n" - " NapariVariableSizeHandling,\n" - ")\n" - "config = LazyNapariStreamingConfig(\n" - " variable_size_handling=NapariVariableSizeHandling.SEPARATE_LAYERS,\n" - ")\n" - ) - - rewritten = _tool().rewrite_source(source) - namespace: dict[str, object] = {} - exec(compile(rewritten, "", "exec"), namespace) - - assert "from openhcs.runtime.viewer_display import NapariVariableSizeHandling" in rewritten - assert namespace["config"].variable_size_handling.value == "separate_layers" - assert _tool().rewrite_source(rewritten) == rewritten diff --git a/tools/cutover/g6_viewer_display.py b/tools/cutover/g6_viewer_display.py deleted file mode 100644 index 980462efdf..0000000000 --- a/tools/cutover/g6_viewer_display.py +++ /dev/null @@ -1,80 +0,0 @@ -"""One-shot G6 cutover: saved OpenHCS Python sources import the moved viewer names. - -Surface G6 moved ``NapariVariableSizeHandling`` from ``openhcs.core.config`` -to ``openhcs.runtime.viewer_display``, beside the viewer-side display settings. -Pipelines and configs are saved as pycodify-generated Python (``.py`` -pipelines, ``global_config.config`` and plate configs); this tool rewrites -the imports in any such source. - -Usage:: - - python tools/cutover/g6_viewer_display.py PATH [PATH ...] - -Each file is rewritten in place after a ``.pre-g6`` backup is written -beside it. This tool is deleted once the owner has migrated; nothing in -``openhcs/`` reads the pre-G6 spelling. -""" - -from __future__ import annotations - -import ast -import shutil -import sys -from pathlib import Path - -MOVED = { - "NapariVariableSizeHandling": ("openhcs.core.config", "openhcs.runtime.viewer_display"), -} - - -def rewrite_source(source: str) -> str: - """Return ``source`` with moved names imported from their new modules.""" - - tree = ast.parse(source) - lines = source.splitlines(keepends=True) - edits = [] - for node in ast.walk(tree): - if not isinstance(node, ast.ImportFrom): - continue - moved = [ - alias - for alias in node.names - if alias.name in MOVED and MOVED[alias.name][0] == node.module - ] - if not moved: - continue - keep = [alias for alias in node.names if alias not in moved] - first = lines[node.lineno - 1] - indent = first[: len(first) - len(first.lstrip())] - - def spell(alias: ast.alias) -> str: - return alias.name if alias.asname is None else f"{alias.name} as {alias.asname}" - - text = "" - if keep: - text += f"{indent}from {node.module} import {', '.join(spell(a) for a in keep)}\n" - for alias in moved: - text += f"{indent}from {MOVED[alias.name][1]} import {spell(alias)}\n" - edits.append((node.lineno, node.end_lineno, text)) - for start, end, text in sorted(edits, reverse=True): - lines[start - 1 : end] = [text] - rewritten = "".join(lines) - ast.parse(rewritten) - return rewritten - - -def main(paths: list[str]) -> int: - for raw in paths: - path = Path(raw) - source = path.read_text(encoding="utf-8") - rewritten = rewrite_source(source) - if rewritten == source: - continue - shutil.copy2(path, path.with_name(path.name + ".pre-g6")) - path.write_text(rewritten, encoding="utf-8") - print(f"rewrote {path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) From 6bb2c2e0a09249f2ea88f81b57ca805c3ef2cd55 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 13:32:44 -0400 Subject: [PATCH 5/7] G6: repin pyqt-reactive 0.3.29 (cccf947); witness family declares its payload spatial rank; napari tests read _view_indices Co-Authored-By: Claude Opus 5.5 --- external/pyqt-reactive | 2 +- tests/unit/test_axis_family_witness.py | 2 ++ tests/unit/test_napari_reset_transitions.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/external/pyqt-reactive b/external/pyqt-reactive index d7d90cf81a..cccf947ad9 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit d7d90cf81a0fd573b0759df32d026bbf3b195c37 +Subproject commit cccf947ad9ae7ae21f1055de6f82d36bdf2d2b7c diff --git a/tests/unit/test_axis_family_witness.py b/tests/unit/test_axis_family_witness.py index 0e6fd4b051..aefacacc7c 100644 --- a/tests/unit/test_axis_family_witness.py +++ b/tests/unit/test_axis_family_witness.py @@ -164,6 +164,8 @@ 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" diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index c618efd91c..31bd0d8bd3 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -924,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() @@ -947,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"] From b6ebafb9c1d7b5e7d6bf0a1a2542f336194d74a3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 13:49:56 -0400 Subject: [PATCH 6/7] G6: name-less streams still label their axes from the route's declarations The viewer took axis labels only from merged value-name metadata, so a stream without value names showed raw axis names. The display batch now declares the route's axes on the viewer's name metadata. The official30 test reads ViewerType members by value and their config key through the family. Co-Authored-By: Claude Opus 5.5 --- openhcs/runtime/napari_viewer_server.py | 1 + openhcs/runtime/viewer_component_system.py | 9 +++++++-- .../integration/test_cellprofiler_official30_zmq.py | 12 ++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index 30baadd8f0..0ccce21643 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -2964,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.") diff --git a/openhcs/runtime/viewer_component_system.py b/openhcs/runtime/viewer_component_system.py index e6c4e2e633..2325870642 100644 --- a/openhcs/runtime/viewer_component_system.py +++ b/openhcs/runtime/viewer_component_system.py @@ -839,8 +839,13 @@ def from_wire_mapping( def merge(self, incoming: "ViewerComponentNameMetadata") -> None: self.store.merge_store(incoming.store) - if incoming.declared_axes: - object.__setattr__(self, "declared_axes", incoming.declared_axes) + 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() diff --git a/tests/integration/test_cellprofiler_official30_zmq.py b/tests/integration/test_cellprofiler_official30_zmq.py index d34da4dc93..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.") @@ -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: From 5ceda1b3ae1c8a6fbb16dd456187043f5911a034 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 14:25:44 -0400 Subject: [PATCH 7/7] G6: Fiji slots are named after hyperstack dimensions; demo script imports NapariVariableSizeHandling from viewer_display FijiSlots.Channel collided with the microscopy member name the kernel guard forbids; the slots are HyperstackChannel/Slice/Frame and the form-choice enum members derive from the slot wire values (unchanged CHANNEL/SLICE/FRAME). Co-Authored-By: Claude Opus 5.5 --- openhcs/runtime/fiji_viewer_server.py | 6 +++--- openhcs/runtime/viewer_display.py | 12 ++++++------ scripts/mcp_thesis_demo_live.py | 2 +- tests/unit/test_axis_family_witness.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openhcs/runtime/fiji_viewer_server.py b/openhcs/runtime/fiji_viewer_server.py index f5ac257411..5ae570cc1d 100644 --- a/openhcs/runtime/fiji_viewer_server.py +++ b/openhcs/runtime/fiji_viewer_server.py @@ -1419,9 +1419,9 @@ def from_items( return cls( window_components=projection.window_components, coordinate_components=FijiHyperstackCoordinateComponents( - channel=layout.components_in(FijiSlots.Channel), - z_axis_components=layout.components_in(FijiSlots.Slice), - frame=layout.components_in(FijiSlots.Frame), + 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, diff --git a/openhcs/runtime/viewer_display.py b/openhcs/runtime/viewer_display.py index 4c5cc2cf96..46792d72bc 100644 --- a/openhcs/runtime/viewer_display.py +++ b/openhcs/runtime/viewer_display.py @@ -264,11 +264,11 @@ def named(cls, wire_value: object) -> type[ViewerSlot]: @classmethod @cache def choice_enum(cls, name: str, module: str) -> type[Enum]: - """The form-choice view of this family (members named after the slots).""" + """The form-choice view of this family (members named after the slots' wire values).""" choices = Enum( name, - [(slot.__name__.upper(), slot.wire_value) for slot in cls.slots], + [(slot.wire_value.upper(), slot.wire_value) for slot in cls.slots], module=module, ) choices.__doc__ = cls.__doc__.splitlines()[0] @@ -298,19 +298,19 @@ class Window(ViewerSlot): wire_value = "window" separates_values = True - class Channel(ViewerSlot): + class HyperstackChannel(ViewerSlot): wire_value = "channel" roles = (ColourAxis,) - class Slice(ViewerSlot): + class HyperstackSlice(ViewerSlot): wire_value = "slice" roles = (StackAxis,) - class Frame(ViewerSlot): + class HyperstackFrame(ViewerSlot): wire_value = "frame" roles = (TimeAxis,) - default_slot = Frame + default_slot = HyperstackFrame # --------------------------------------------------------------------------- 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/unit/test_axis_family_witness.py b/tests/unit/test_axis_family_witness.py index aefacacc7c..40428b68aa 100644 --- a/tests/unit/test_axis_family_witness.py +++ b/tests/unit/test_axis_family_witness.py @@ -138,9 +138,9 @@ def test_viewer_decodes_a_stream_by_its_own_declared_axes(remote_sensing) -> Non Microscopy.activate() fiji = ViewerMappingDisplayConfigInput(fiji_payload).layout() assert fiji.declared_axes.names() == ("tile", "band", "date", "scene") - assert fiji.components_in(FijiSlots.Channel) == ("band",) - assert fiji.components_in(FijiSlots.Slice) == () - assert fiji.components_in(FijiSlots.Frame) == ("tile", "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" )