Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/plans/typed-3d-point-result-20260929.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Typed 3-D point result: implementation boundary

Status: measurement payload retention, typed point writer, native ROI ZIP
round-trip and exact-Z Napari projection implemented in this draft. The native
reopen path now derives its ordered Z domain from the table's declared source
planes, including distinct physical files, and retains scalar measurement
fields as Napari point features. The fresh-source MCP result-directory route
inventoried and streamed a native ROI ZIP to an isolated viewer. Raw-image
streaming is blocked by #201; installed entrypoint and biological result are
not claimed.

Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`.
Issue: OpenHCSDev/openhcs#134. Integration owner: blind-analysis coordinator.
Coordinate the worker/export crossing with open PR #157; do not edit its
`function_artifact_materialization.py` or worker/export paths competitively.

## Current contract and failure

- `MeasurementTable` owns schema-bearing rows, an object subject and optional
measurement-feature owner, plus source provenance
(`openhcs/core/runtime_measurements.py:41`). The subject's declared ID field
is supplied by `ObjectMeasurementSubjectRelation`
(`openhcs/core/artifacts.py:1344`).
- Before this draft, `MeasurementsArtifactType.materialization_payload` handed
only `table.rows` to every writer (`openhcs/core/artifacts.py:704`). This
draft retains the complete table through the existing `ColumnarRows`
contract, while CSV and JSON still serialize only rows. Its provenance,
subject and feature owner are now available to a measurement writer.
`MaterializationSpec` already admits multiple writer options for the same
artifact; a separate hand-built file bundle is not required.
- The existing ROI writer extracts image-label contours, not measurement
coordinates. `PointROIOptions` owns the Z/Y/X measurement-feature roles;
its writer uses the existing ROI ZIP backend. `ROIArchiveSourceMetadata` stores an existing
`ImagePayloadMetadata` declaration in the native archive sidecar; it is not
a second source schema (`openhcs/core/roi_source_metadata.py:15`).
- OpenHCS pins PolyStore `e430c331ad931edc92dfe9d4fcd0d837a3cfeea8`.
Its native `PointShape(y, x)` codec preserves fractional XY, but has no Z
member. Standard ImageJ native Z is a discrete plane. OpenHCS's Napari
`_build_nd_points` prepends route component indices to 2-D coordinates
(`openhcs/runtime/napari_viewer_server.py:1089`); this draft replaces the
projected Z coordinate with typed `ROIFractionalZ` when present and rejects
a fractional-Z ROI if no Z axis is projected.
- Frozen H002 hand-wrote an ImageJ POINT ZIP with rounded Z and no source
sidecar. It remains AMBIGUOUS and must not be rewritten or rerun as a repair.
- The live desktop MCP process is healthy but uses an older dirty checkout whose
query tool does not expose `result_directory`. Remote main has the route;
source-worktree tests cannot substitute for a reviewed install and fresh MCP
handshake.
- Fresh-source MCP on isolated display :91 streamed the native point ROI to
port 5791. BioFormats raw streaming then failed for both a four-plane OME
TIFF (ambiguous physical path) and four separate TIFF planes (fabricated
escaped-and-suffixed load path). Issue #201 has the reproducer and owner;
raw/result/combined visual QA remains open.

## Required closure

1. Keep coordinate roles and object ID on typed declarations: a producer must
declare which measurement features mean Z, Y, X and which subject owns the
object ID. Do not infer them from column spellings or filenames. Retain the
complete `MeasurementTable` through writer dispatch, adapting existing CSV
and JSON writers coherently rather than adding a side channel.
2. Persist exact fractional ZYX, stable object ID and canonical source binding
in one inspectable typed point result. If the ImageJ ROI ZIP is retained as
the external format, its metadata sidecar must carry information the native
format cannot express, under an owning typed contract. No rounded-Z-only
fallback or legacy reader for the frozen malformed archive.
3. Reopen the persisted result through the installed public MCP
`result_directory` route with the declared raw source, not a guessed plate
path. The managed viewer must show the exact fractional 3-D point location,
feature-row/object identity and orthogonal views. Preserve path policy and
reject foreign source identities and escaping paths.
4. Verify one continuous synthetic compile/run/materialize/inspect/reopen
journey, then a new bounded context-isolated blind trial with raw-only,
result-only and combined captures at spatially distributed matched native
coordinates. Keep held-out answers sealed until pipeline freeze. A focused
source test or synthetic viewer does not establish biological acceptance.

## Current stop conditions

Do not claim a complete ownership proof from a partial scan or duplicate
PR #157's worker/export owner. The resource guard currently warns on swap;
do not start parallel agents, a large scan/test, or a new blind image load
until headroom is restored. Source inspection and small isolated edits may
continue, but a coherent change still needs complete ownership and native
behavior evidence before merge.
28 changes: 25 additions & 3 deletions openhcs/agent/services/plate_streaming_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,15 @@ def stream_files(
)
if request.result_directory is not None:
read_backend = Backend.DISK.value
source_projection, producer = self._receipt_source_projection(
request, resolved_records, stream_context
)
if request.source_receipt is None:
source_projection = self._inventory_source_projection(
resolved_records, stream_context
)
producer = None
else:
source_projection, producer = self._receipt_source_projection(
request, resolved_records, stream_context
)

viewer = StreamingViewerLifecycle.get_or_create_visualizer(
filemanager=stream_context.filemanager,
Expand Down Expand Up @@ -322,6 +328,22 @@ def stream_files(
warnings=all_warnings,
)

@staticmethod
def _inventory_source_projection(
records: tuple[PlateFileRecord, ...],
context: PlateInspectionContext,
) -> VirtualWorkspaceSourceProjection | None:
"""Carry inventory-owned physical image identities into viewer loading."""
builder = VirtualWorkspaceSourceProjectionBuilder(Path(context.plate_path))
for record in records:
image_path = record.streamable_image_path
if image_path is None or record.source_ref is None:
continue
builder.record_workspace_source_path(image_path, record.source_ref)
if record.metadata:
builder.record_source_metadata(image_path, record.metadata)
return builder.projection() if builder.workspace_source_refs else None

def _receipt_source_projection(
self,
request: PlateFileStreamRequest,
Expand Down
2 changes: 1 addition & 1 deletion openhcs/core/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ def materialization_payload(cls, value: "RuntimeValue") -> object:
table = value.data
if not isinstance(table, MeasurementTable):
cls.validate_runtime_payload(value.name, table)
return table.rows
return table

@classmethod
def uses_aggregate_materialization_identity(cls, data: object) -> bool:
Expand Down
93 changes: 93 additions & 0 deletions openhcs/core/roi_point_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Exact fractional Z retained beside ImageJ's integer-plane point ROI."""

from __future__ import annotations

from dataclasses import dataclass, replace
from math import isfinite
from typing import ClassVar, Mapping

from polystore.roi import ROI

from openhcs.constants.constants import AllComponents
from openhcs.core.runtime_image_values import ImagePayloadMetadata
from openhcs.core.source_image_provenance import SourceComponentMetadata


@dataclass(frozen=True, slots=True)
class ROIFractionalZ:
"""The Z coordinate ImageJ's 2D point shape cannot represent."""

value: float
FIELD: ClassVar[str] = "openhcs_fractional_z"

def __post_init__(self) -> None:
if isinstance(self.value, bool) or not isfinite(self.value):
raise ValueError("ROI fractional Z must be a finite real coordinate.")

def bind(self, roi: ROI) -> ROI:
return replace(roi, metadata={**roi.metadata, self.FIELD: float(self.value)})

@classmethod
def decode(cls, metadata: Mapping[str, object]) -> ROIFractionalZ | None:
value = metadata.get(cls.FIELD)
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("ROI fractional Z must be numeric.")
return cls(float(value))

@classmethod
def source_component_domain(
cls,
rois: list[ROI],
metadata: ImagePayloadMetadata,
) -> tuple[SourceComponentMetadata, ...] | None:
"""Derive the point route's ordered Z domain from source-plane provenance."""
coordinates = tuple(cls.decode(roi.metadata) for roi in rois)
if not any(coordinate is not None for coordinate in coordinates):
return None
if any(coordinate is None for coordinate in coordinates):
raise ValueError("Point ROI archive mixes 3D and 2D point metadata.")
planes = metadata.source_provenance.source_image_provenance_planes
domain = planes.component_metadata
if not domain or any(plane is None for plane in domain):
raise ValueError("3D point ROI requires an exact source-plane Z domain.")
z_field = AllComponents.Z_INDEX.value
if any(z_field not in plane for plane in domain):
raise ValueError("3D point ROI source planes require z_index metadata.")
z_values = tuple(plane[z_field] for plane in domain)
if any(
isinstance(value, bool) or not isinstance(value, (str, int))
for value in z_values
):
raise ValueError("3D point ROI source z_index values must be integers.")
try:
integer_z = tuple(int(value) for value in z_values)
except ValueError as exc:
raise ValueError(
"3D point ROI source z_index values must be integers."
) from exc
if any(
str(value).strip() != str(index) and not str(value).strip().isdigit()
for value, index in zip(z_values, integer_z, strict=True)
):
raise ValueError("3D point ROI source z_index values must be integers.")
if integer_z != tuple(range(integer_z[0], integer_z[0] + len(domain))):
raise ValueError(
"3D point ROI source Z planes must be consecutive and ordered."
)
if any(
coordinate.value < 0 or coordinate.value > len(domain) - 1
for coordinate in coordinates
):
raise ValueError(
"3D point ROI Z coordinate lies outside its source planes."
)
common_keys = set(domain[0]) - {z_field}
if any(set(plane) - {z_field} != common_keys for plane in domain):
raise ValueError("3D point ROI source planes have inconsistent components.")
if any(
any(plane[key] != domain[0][key] for key in common_keys) for plane in domain
):
raise ValueError("3D point ROI source planes vary outside Z.")
return tuple(domain)
26 changes: 25 additions & 1 deletion openhcs/core/runtime_measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from openhcs.core.artifacts import (
NamedArtifactPayload,
)
from openhcs.core.runtime_tabular_values import ColumnarRows
from openhcs.core.runtime_tabular_values import (
ColumnarRows,
FieldSpec,
MeasurementObjectRowIdentity,
)
from openhcs.core.source_image_provenance import (
SourceImageProvenance,
SourceImageProvenanceFields,
Expand Down Expand Up @@ -41,6 +45,7 @@
class MeasurementTable(
SourceImageProvenanceFields,
NamedArtifactPayload,
ColumnarRows,
):
"""Native OpenHCS measurement table value."""

Expand All @@ -50,6 +55,25 @@ class MeasurementTable(
subject: MeasurementSubject
measurement_feature_owner: type[RuntimeMeasurementFeatureOwner] | None = None

@property
def columns(self) -> Any:
return self.rows.columns

@property
def fields(self) -> tuple[FieldSpec, ...]:
return self.rows.fields

@property
def object_row_identity(self) -> MeasurementObjectRowIdentity | None:
return self.rows.object_row_identity

@property
def covers_declared_object_measurement_domain(self) -> bool:
return self.rows.covers_declared_object_measurement_domain

def iter_row_mappings(self) -> Iterable[Mapping[str, object]]:
return self.rows.iter_row_mappings()

def __post_init__(self, *source_provenance_values: object) -> None:
self.absorb_explicit_source_provenance(
SourceImageProvenance.from_init_values(source_provenance_values)
Expand Down
46 changes: 39 additions & 7 deletions openhcs/core/viewer_streaming_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@

from openhcs.core.runtime_image_loading import ImagePayloadSourceMetadataContext
from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata
from openhcs.core.roi_point_metadata import ROIFractionalZ
from openhcs.core.runtime_image_values import (
ImagePayloadMetadata,
image_payload_data,
image_payload_mask,
image_payload_metadata,
)
from openhcs.core.source_image_provenance import SourceImageIdentity
from openhcs.core.source_image_provenance import (
SourceComponentMetadata,
SourceImageIdentity,
)
from openhcs.core.source_metadata import SourceVoxelSpacing
from openhcs.core.source_workspace_projection import (
VirtualWorkspacePathLookup,
Expand Down Expand Up @@ -359,7 +363,20 @@ def load_image(
source_path = source_projection.resolved_source_path_for(
lookup, self.filemanager
)
image = self.filemanager.load(source_path, backend)
source_address = (
source_path
if source_ref is None
else str(
self.filemanager.resolve_address(
source_ref.backend_address,
backend,
base_path=Path(self.plate_path),
)
)
)
image = self.filemanager.load(source_address, backend)
if source_ref is not None:
image = source_ref.project_source_axes(image)
image = source_projection.project_unbound_payload(lookup, image)
metadata = ImagePayloadSourceMetadataContext(
source_identity=SourceImageIdentity(
Expand All @@ -368,7 +385,7 @@ def load_image(
),
read_backend=backend,
filemanager=self.filemanager,
source_address=source_path,
source_address=source_address,
).metadata(image)
metadata = self.calibrated_metadata(metadata)
return metadata.payload_with(
Expand Down Expand Up @@ -662,7 +679,13 @@ def stream_images(
source_projection=source_projection,
component_metadata=all_metadata_by_path[filename],
)
if request.source_projection is not None:
lookup = VirtualWorkspacePathLookup.from_paths(
filename, str(Path(self.source.plate_path) / filename)
)
if (
request.source_projection is not None
and source_projection.source_projection_for(lookup) is not None
):
self.source.require_projected_image_window(
filename, image_data, source_projection
)
Expand Down Expand Up @@ -778,6 +801,7 @@ def stream_rois(
paths: list[str] = []
loaded_indices: list[int] = []
archive_metadata: list[ImagePayloadMetadata | None] = []
point_domains: dict[str, tuple[SourceComponentMetadata, ...]] = {}

for i, filename in enumerate(request.roi_filenames, 1):
file_path = Path(self.source.plate_path) / filename
Expand All @@ -796,6 +820,10 @@ def stream_rois(
"artifact filenames do not establish source identity."
)
archive_metadata.append(metadata)
if metadata is not None:
point_domain = ROIFractionalZ.source_component_domain(rois, metadata)
if point_domain is not None:
point_domains[filename] = point_domain
data_list.append(ROIArchiveSourceMetadata.geometry(rois))
paths.append(filename)
loaded_indices.append(i - 1)
Expand Down Expand Up @@ -866,8 +894,13 @@ def stream_rois(
for path, metadata in zip(paths, archive_metadata, strict=True)
if metadata is not None
)
metadata_by_path.update(
(path, dict(domain[0])) for path, domain in point_domains.items()
)
source_metadata_items = StreamSourceComponentMetadataItems.from_values(
metadata_by_path[path] for path in paths
component_metadata
for path in paths
for component_metadata in point_domains.get(path, (metadata_by_path[path],))
)
message_authority = StreamComponentMessageExtraAuthority.from_viewer_surface(
viewer_surface,
Expand All @@ -884,8 +917,7 @@ def stream_rois(
request.status_callback(message)

payload_metadata = [
self.source.calibrated_metadata(metadata)
for metadata in archive_metadata
self.source.calibrated_metadata(metadata) for metadata in archive_metadata
]
component_order = message_authority.layout.component_order
for indices in StreamImagePayloadMetadataProjector.partition_indices(
Expand Down
Loading
Loading