From 1ac2dcda1fccf2ac5850227f3fb0b232bc3ad342 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 3 Jul 2026 16:06:40 +0300 Subject: [PATCH 1/2] feat(catalog): bind the new fleet technique-methods by Role Extends the role_kind binding to the ~20 technique-methods that landed on main from the fleet-expansion work, applying the same per-method abstraction assessment used for the original method set. Full role-mappable (14) -> required_roles, every family presents a Role: powder_diffraction, small_wide_angle_scattering -> [Detector] radiography, xray_emission_spectroscopy -> [Detector] (radiography: Scintillator drops -- detector-Assembly constituent) macromolecular_crystallography -> [Positioner, Detector] coherent_diffraction_imaging, ptychography -> [Detector, Positioner] xray_photoelectron_spectroscopy, hard_xray_photoelectron_spectroscopy, angle_resolved_photoemission -> [Detector, Positioner] serial_crystallography -> [Positioner, Detector, Controller] xray_fluorescence_mapping -> [Sensor, Positioner] resonant_inelastic_scattering, inelastic_scattering -> [Positioner] (SpectrometerArm presents Positioner) Mixed (4) -> Detector role + the no-Role family kept anatomical: single_crystal_diffraction, surface_diffraction, grazing_incidence_scattering -> roles [Detector] + needed [PseudoAxis] transmission_xray_microscopy -> roles [Detector] + needed [ZonePlate] The reciprocal-space PseudoAxis and the ZonePlate objective present no Role (computed coordinate / imaging optic, not functional contracts), so they stay on needed_families; each carries a note explaining the mix. Kept family_id (assessed, unchanged): absorption_spectroscopy + continuous_absorption_spectroscopy (Monochromator only, no Role), energy_characterization, beamline_energy_change (the deferred energy abstraction), hexapod_reboot (device-specific recovery). A Method may carry both required_roles and needed_families (independent fields, no XOR) -- that is what the mixed methods use. All Role targets verified against catalog presents_as (EmissionSpectrometer->Detector, SpectrometerArm->Positioner, EnergyDispersiveSpectrometer->Sensor, etc.). Co-Authored-By: Claude --- catalog/catalog.yaml | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/catalog/catalog.yaml b/catalog/catalog.yaml index 2f40511dae..19333fd619 100644 --- a/catalog/catalog.yaml +++ b/catalog/catalog.yaml @@ -358,40 +358,46 @@ methods: - name: powder_diffraction capability: cora.capability.diffraction purpose: "Powder / polycrystalline diffraction: an azimuthally-integrated ring pattern on an area or strip detector as the sample is spun, giving a one-dimensional intensity-vs-angle pattern for phase ID, Rietveld refinement, or total-scattering / PDF." - needed_families: [Camera] + required_roles: [Detector] - name: single_crystal_diffraction capability: cora.capability.diffraction purpose: "Small-molecule / chemical single-crystal diffraction: indexed Bragg reflections collected over sample orientations on an area detector, for small-unit-cell structure solution. Distinct from macromolecular_crystallography by sample scale and refinement pipeline." - needed_families: [Camera, PseudoAxis] + required_roles: [Detector] + needed_families: [PseudoAxis] + note: "Mixed binding: the area detector binds by the Detector Role, while the reciprocal-space PseudoAxis stays anatomical (needed_families) because it presents no Role (a computed hkl coordinate, not a functional contract)." - name: macromolecular_crystallography capability: cora.capability.diffraction purpose: "Macromolecular crystallography (MX) rotation data collection: a rotation series on a protein crystal mounted on a goniometer, area-detector frames per oscillation wedge, for macromolecular structure solution." - needed_families: [Goniometer, Camera] + required_roles: [Positioner, Detector] - name: serial_crystallography capability: cora.capability.diffraction purpose: "Serial crystallography (SSX / SFX): still or minimal-rotation single shots across many crystals delivered by fixed-target chip or injector, each a diffraction pattern, merged downstream. Distinct from macromolecular_crystallography by the fixed-target / serial delivery act rather than a rotation series." - needed_families: [Goniometer, Camera, TimingController] + required_roles: [Positioner, Detector, Controller] - name: surface_diffraction capability: cora.capability.diffraction purpose: "Surface / interface diffraction and reflectivity: specular and off-specular Bragg intensities measured with a multi-circle diffractometer at grazing incidence, for surface structure and layer profiles." - needed_families: [Camera, PseudoAxis] + required_roles: [Detector] + needed_families: [PseudoAxis] + note: "Mixed binding: the area detector binds by the Detector Role; the reciprocal-space PseudoAxis stays anatomical (no Role)." # -- Scattering -- - name: small_wide_angle_scattering capability: cora.capability.scattering purpose: "Small- and wide-angle X-ray scattering (SAXS/WAXS): a single exposure capturing both the small-angle and wide-angle diffuse pattern on area detectors, for nanostructure and packing. SAXS and WAXS are one simultaneous act, not two Methods." - needed_families: [Camera] + required_roles: [Detector] - name: grazing_incidence_scattering capability: cora.capability.scattering purpose: "Grazing-incidence small/wide-angle scattering (GISAXS / GIWAXS): SAXS/WAXS geometry at a grazing angle on a reflecting surface or thin film, probing in-plane and out-of-plane nanostructure. Distinct from small_wide_angle_scattering by the grazing surface-sensitive geometry." - needed_families: [Camera, PseudoAxis] + required_roles: [Detector] + needed_families: [PseudoAxis] + note: "Mixed binding: the area detector binds by the Detector Role; the reciprocal-space PseudoAxis stays anatomical (no Role)." - name: coherent_diffraction_imaging capability: cora.capability.scattering purpose: "Coherent diffraction imaging (CDI): oversampled coherent far-field diffraction patterns from an isolated or scanned sample, phase-retrieved into a real-space image. Distinct from ptychography by the single-view (non-scanning) act." - needed_families: [Camera, LinearStage] + required_roles: [Detector, Positioner] - name: ptychography capability: cora.capability.scattering purpose: "Ptychography: coherent diffraction patterns recorded at overlapping scan positions of a confined probe, jointly phase-retrieved into a high-resolution image and probe. Distinct from coherent_diffraction_imaging by the overlapping-scan act." - needed_families: [Camera, LinearStage] + required_roles: [Detector, Positioner] # -- Absorption spectroscopy -- - name: absorption_spectroscopy capability: cora.capability.absorption_spectroscopy @@ -405,41 +411,43 @@ methods: - name: xray_fluorescence_mapping capability: cora.capability.emission_spectroscopy purpose: "Scanning X-ray fluorescence mapping (XRF): a focused beam raster-scans the sample while an energy-dispersive detector records the fluorescence spectrum per pixel, giving element maps. Distinct from the crystal-analyzer emission Methods by the energy-dispersive point detector." - needed_families: [EnergyDispersiveSpectrometer, LinearStage] + required_roles: [Sensor, Positioner] - name: xray_emission_spectroscopy capability: cora.capability.emission_spectroscopy purpose: "X-ray emission spectroscopy (XES / HERFD): a crystal-analyzer spectrometer disperses the emitted fluorescence lines to high energy resolution at a fixed incident energy, resolving chemical state. Distinct from xray_fluorescence_mapping by the wavelength-dispersive crystal analyzer." - needed_families: [EmissionSpectrometer] + required_roles: [Detector] - name: resonant_inelastic_scattering capability: cora.capability.emission_spectroscopy purpose: "Resonant inelastic X-ray scattering (RIXS): the incident energy is tuned to an absorption edge and the energy-loss spectrum of scattered photons is dispersed on a long spectrometer arm, giving a two-dimensional incident-vs-emitted map of electronic excitations." - needed_families: [SpectrometerArm] + required_roles: [Positioner] - name: inelastic_scattering capability: cora.capability.emission_spectroscopy purpose: "Momentum-resolved (non-resonant) inelastic X-ray scattering (IXS): a multi-analyzer spectrometer arm resolves small energy losses versus momentum transfer, probing phonons and collective excitations. Distinct from RIXS by the non-resonant momentum-scan act." - needed_families: [SpectrometerArm] + required_roles: [Positioner] # -- Photoemission spectroscopy -- - name: xray_photoelectron_spectroscopy capability: cora.capability.photoemission_spectroscopy purpose: "X-ray photoelectron spectroscopy (XPS / soft-X-ray photoemission): an electron analyzer records the kinetic-energy distribution of photoemitted electrons at a fixed photon energy, giving core-level and valence spectra for chemical state." - needed_families: [ElectronAnalyzer, Manipulator] + required_roles: [Detector, Positioner] - name: hard_xray_photoelectron_spectroscopy capability: cora.capability.photoemission_spectroscopy purpose: "Hard X-ray photoelectron spectroscopy (HAXPES): XPS at multi-keV photon energy for greater probing depth, requiring a hard-X-ray-optimized analyzer and optics. Distinct from xray_photoelectron_spectroscopy by the depth-sensitive hard-X-ray act, not merely a photon-energy parameter." - needed_families: [ElectronAnalyzer, Manipulator] + required_roles: [Detector, Positioner] - name: angle_resolved_photoemission capability: cora.capability.photoemission_spectroscopy purpose: "Angle-resolved photoemission spectroscopy (ARPES): an electron analyzer resolves photoelectron intensity versus kinetic energy AND emission angle, mapping the electronic band structure. Distinct from XPS by the added angle-resolved dimension." - needed_families: [ElectronAnalyzer, Manipulator] + required_roles: [Detector, Positioner] # -- Transmission imaging (modality members beyond tomography) -- - name: radiography capability: cora.capability.tomography purpose: "Radiography: a single (or time-series) transmission projection through the sample onto a scintillator-coupled camera, without the rotation series that tomography adds. The projection primitive beneath tomography." - needed_families: [Camera, Scintillator] + required_roles: [Detector] - name: transmission_xray_microscopy capability: cora.capability.tomography purpose: "Full-field transmission X-ray microscopy (TXM): a zone-plate-magnified transmission image (optionally Zernike phase contrast), per projection, optionally rotated for nano-tomography. Distinct from tomography by the X-ray objective (zone plate) forming a magnified image rather than a scintillator-relay projection." - needed_families: [Camera, ZonePlate] + required_roles: [Detector] + needed_families: [ZonePlate] + note: "Mixed binding: the area detector binds by the Detector Role; the ZonePlate objective stays anatomical (needed_families) because it presents no Role (an imaging optic, not a functional contract a Method targets)." - name: beamline_energy_change capability: cora.capability.energy_change purpose: "Set the beam energy by driving the energy-tracking optic axes (the DMM Bragg arms + M2 offset, and the sample-slit vertical pair) to their per-energy positions as one coordinated move." From 5fadecc6b7b1e22a7eed3b8d4eb2d7d4ac20cd04 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 3 Jul 2026 16:19:53 +0300 Subject: [PATCH 2/2] test(recipe): prove the mixed role_kind + needed_family method binding at runtime The #539 catalog conversions introduced a "mixed" method shape (a Detector role_kind slot PLUS a needed_family coverage requirement for a no-Role family: single_crystal_diffraction / surface_diffraction / grazing_incidence_scattering keep PseudoAxis, transmission_xray_microscopy keeps ZonePlate). That coexistence was asserted from the schema (both fields, no XOR) but never exercised at runtime -- the #539 commit was catalog-only, and catalog methods are docs-only, so no binding fired. This adds the missing proof over the REST surface: - happy: a Camera (Detector) + a PseudoAxis asset -> define_plan accepts (needed_family coverage holds) AND the Detector role_kind slot binds. - negative: a Plan with only the Camera (no PseudoAxis) -> define_plan 409 (needed_family_ids not covered), even though the Detector slot could bind -- proving the role slot and the family-coverage set are independent and both enforced. Closes the coverage gap between #539 (catalog) and the runtime binding machinery, matching the proof discipline used for tomography (#534), xpcs/Shutter (#534), and the Diffractometer fixture (#538). Co-Authored-By: Claude --- .../test_mixed_role_family_method_binding.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 apps/api/tests/contract/test_mixed_role_family_method_binding.py diff --git a/apps/api/tests/contract/test_mixed_role_family_method_binding.py b/apps/api/tests/contract/test_mixed_role_family_method_binding.py new file mode 100644 index 0000000000..fb7f9018da --- /dev/null +++ b/apps/api/tests/contract/test_mixed_role_family_method_binding.py @@ -0,0 +1,137 @@ +"""Proof: a Method carrying BOTH required_roles and needed_family_ids binds. + +The #539 catalog conversions introduced a "mixed" method shape +(single_crystal_diffraction, surface_diffraction, grazing_incidence_scattering, +transmission_xray_microscopy): a positional role slot bound by role_kind +(the area detector -> Detector Role) PLUS a bare needed_family coverage +requirement for a Family that presents no Role (the reciprocal-space +PseudoAxis, or the ZonePlate imaging optic). + +These two mechanisms are distinct: + - required_roles / role_kind: a NAMED positional slot, satisfied by an + Asset whose Family presents the Role and covers its affordances. + - needed_family_ids: a bare COVERAGE set, satisfied when the Plan's + bound Assets collectively include that Family (no positional slot, + no wire) -- checked at define_plan, PlanFamiliesNotSatisfiedError. + +The catalog conversion asserted these coexist on one Method (no XOR). +This proves it at RUNTIME, which the catalog-only #539 commit did not: +the mixed method's Detector role_kind slot binds AND its PseudoAxis +coverage requirement is enforced at plan time. +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from httpx2 import Response + +from cora.api.main import create_app +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.aggregates.role import RoleName, role_stream_id +from tests.contract._helpers import create_capability_via_api + +pytestmark = pytest.mark.contract + +_DETECTOR_ID = role_stream_id(RoleName("Detector")) +_CAMERA_ID = family_stream_id(FamilyName("Camera")) +_PSEUDOAXIS_ID = family_stream_id(FamilyName("PseudoAxis")) + + +def _seed_lookups(app: FastAPI) -> None: + app.state.deps.role_lookup.register( + role_id=_DETECTOR_ID, name="Detector", required_affordances=["Imageable"] + ) + app.state.deps.family_lookup.register( + family_id=_CAMERA_ID, + name="Camera", + affordances=["Imageable", "Binnable", "Coolable", "Triggerable", "Streamable", "Recording"], + presents_as=[_DETECTOR_ID], + ) + app.state.deps.family_lookup.register( + family_id=_PSEUDOAXIS_ID, name="PseudoAxis", affordances=[], presents_as=[] + ) + + +def _register_asset(client: TestClient, name: str, family_id: UUID) -> str: + asset_id = client.post( + "/assets", + json={"name": name, "tier": "Unit", "parent_id": None, "facility_code": "cora"}, + ).json()["asset_id"] + r = client.post(f"/assets/{asset_id}/add-family", json={"family_id": str(family_id)}) + assert r.status_code == 204, r.text + return asset_id + + +def _mixed_method(client: TestClient) -> str: + """single_crystal_diffraction shape: needed_family_ids=[PseudoAxis] + (bare coverage) + one Detector role_kind slot.""" + cap_id = create_capability_via_api(client) + method_id = client.post( + "/methods", + json={ + "execution_pattern": "Batch", + "name": "SingleCrystalDiffraction", + "capability_id": cap_id, + "needed_family_ids": [str(_PSEUDOAXIS_ID)], + }, + ).json()["method_id"] + r = client.post( + f"/methods/{method_id}/add-required-role", + json={ + "requirement": { + "role_name": "detector", + "role_kind": str(_DETECTOR_ID), + "required_ports": [], + "optional": False, + } + }, + ) + assert r.status_code == 201, r.text + return method_id + + +def _plan(client: TestClient, method_id: str, asset_ids: list[str]) -> Response: + practice_id = client.post( + "/practices", + json={"name": "SxdPractice", "method_id": method_id, "site_id": str(uuid4())}, + ).json()["practice_id"] + return client.post( + "/plans", + json={"name": "SxdPlan", "practice_id": practice_id, "asset_ids": asset_ids}, + ) + + +def test_mixed_method_plan_binds_role_and_covers_needed_family() -> None: + """Happy path: a Camera (Detector) + a PseudoAxis asset satisfy the + mixed method -- the Plan is accepted (needed_family coverage holds) and + the Detector role_kind slot binds.""" + app = create_app() + with TestClient(app) as client: + _seed_lookups(app) + method_id = _mixed_method(client) + camera = _register_asset(client, "AreaDet", _CAMERA_ID) + recip = _register_asset(client, "HklAxis", _PSEUDOAXIS_ID) + plan_resp = _plan(client, method_id, [camera, recip]) + assert plan_resp.status_code == 201, plan_resp.text + plan_id = plan_resp.json()["plan_id"] + bind = client.post( + f"/plans/{plan_id}/bind-role", + json={"role_name": "detector", "asset_id": camera}, + ) + assert bind.status_code == 201, bind.text + + +def test_mixed_method_plan_rejected_when_needed_family_absent() -> None: + """The coverage half fires: a Plan with only the Camera (no PseudoAxis + asset) fails define_plan because needed_family_ids is not covered, + even though the Detector role slot could bind. Proves the two + requirements are independent and both enforced.""" + app = create_app() + with TestClient(app) as client: + _seed_lookups(app) + method_id = _mixed_method(client) + camera = _register_asset(client, "AreaDet", _CAMERA_ID) + plan_resp = _plan(client, method_id, [camera]) + assert plan_resp.status_code == 409, plan_resp.text