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
137 changes: 137 additions & 0 deletions apps/api/tests/contract/test_mixed_role_family_method_binding.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 26 additions & 18 deletions catalog/catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand Down
Loading