From 0aae676b87cfa9f91f499334a4c628f44f37b175 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sat, 4 Jul 2026 18:32:30 +0300 Subject: [PATCH] Materialize the Diffractometer Assembly at four more beamlines (Diamond + ESRF) Extends the catalog Diffractometer blueprint from the hard X-ray six-circle it was earned on (4-ID + 8-ID) and the SwissFEL platforms to four further independent bindings from real descriptor data: Diamond i06 (diffraction- dichroism), ESRF ID32 (E4CH RIXS), Diamond i10 (RASOR), and Diamond i19 (Newport kappa four-circle). Each binds a goniometer + reciprocal-space PseudoAxis, with the detector arm folded into the goniometer circle set (detector_arm at count 0, as at Bernina GPS). Proves the one composition contract spans soft X-ray resonant-scattering and kappa geometries, not just the six-circle it started on. The RotaryStage detector_arm Family is defined so the blueprint can reference it even where no arm Asset is bound; a Family is a federation-shared device class, so defining it does not assert the beamline owns a RotaryStage device. Co-Authored-By: Claude --- .../test_i06_diffractometer_setup.py | 195 +++++++++++++++++ .../test_i10_diffractometer_setup.py | 196 +++++++++++++++++ .../test_i19_diffractometer_setup.py | 197 ++++++++++++++++++ .../test_id32_diffractometer_setup.py | 195 +++++++++++++++++ 4 files changed, 783 insertions(+) create mode 100644 apps/api/tests/integration/scenarios/test_i06_diffractometer_setup.py create mode 100644 apps/api/tests/integration/scenarios/test_i10_diffractometer_setup.py create mode 100644 apps/api/tests/integration/scenarios/test_i19_diffractometer_setup.py create mode 100644 apps/api/tests/integration/scenarios/test_id32_diffractometer_setup.py diff --git a/apps/api/tests/integration/scenarios/test_i06_diffractometer_setup.py b/apps/api/tests/integration/scenarios/test_i06_diffractometer_setup.py new file mode 100644 index 0000000000..672a3cb295 --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_i06_diffractometer_setup.py @@ -0,0 +1,195 @@ +"""Soft X-ray diffractometer deployment at Diamond i06 (Assembly + Fixture). + +cluster: Commissioning +archetype: setup +bc_primary: Equipment +bc_touches: Equipment + +Materializes Diamond i06's diffraction-dichroism diffractometer as a binding of +the catalog Diffractometer Assembly, end-to-end against Postgres, from the assets +i06's deployment descriptor carries (`deployments/i06/beamline.yaml`, DIFF-1). + +This extends the blueprint beyond the hard X-ray six-circle it was earned on +(4-ID + 8-ID) and the SwissFEL platforms (Bernina + Cristallina) to a soft X-ray +dichroism instrument: proof the one composition contract spans a different +diffractometer family. + + - Diffractometer (Family Goniometer) -> Exactly1 goniometer slot + (sample x/y/z, theta incidence, chi / phi orientation) + - ReciprocalSpace (Family PseudoAxis) -> Exactly1 reciprocal_space slot + +The 2-theta / DET:Y detector arm is folded into the goniometer's circle set in the +descriptor rather than modelled as a separate RotaryStage Asset, so the ZeroOrMore +detector_arm slot binds at count 0 (as at Bernina GPS). The reciprocal-space solver +partition (DIFF-2) is left unset, matching the earlier fixtures. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.assembly import ( + SlotCardinality, + SlotName, + TemplateSlot, +) +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.role import SEED_ROLE_POSITIONER_ID +from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture +from cora.equipment.features.attach_asset_to_fixture import bind as bind_attach_asset_to_fixture +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_assembly import bind as bind_define_assembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_family import bind as bind_define_family +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.features.register_fixture import bind as bind_register_fixture +from cora.infrastructure.adapters.in_memory_role_lookup import InMemoryRoleLookup +from tests.integration._equipment_helpers import install_existing_asset_into_fresh_mount +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import ( + DeviceSpec, + facility_id_prefix, + install_aps_unit, + operator_for, +) + +_NOW = datetime(2026, 5, 28, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000600bb") + +# Facility hierarchy (scenario tag 06 = i06). +_I06_UNIT_ID = UUID("01900000-0000-7000-8000-000000060a01") + +# Family ids (deterministic uuid5 from the name). RotaryStage is the detector_arm +# slot's family in the catalog blueprint; i06 binds no arm Asset (count 0) but the +# slot's required family stays RotaryStage so the blueprint matches the catalog. +_CAP_GONIOMETER_ID = family_stream_id(FamilyName("Goniometer")) +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_PSEUDO_AXIS_ID = family_stream_id(FamilyName("PseudoAxis")) + +# The two constituent Assets (scenario-supplied ids), mirroring the named devices +# in deployments/i06/beamline.yaml. +_ASSET_GONIOMETER_ID = UUID("01900000-0000-7000-8000-000000060a11") +_ASSET_RECIPROCAL_SPACE_ID = UUID("01900000-0000-7000-8000-000000060a21") + +_DEVICES = ( + DeviceSpec("Diffractometer", _ASSET_GONIOMETER_ID, "Goniometer", _CAP_GONIOMETER_ID), + DeviceSpec("ReciprocalSpace", _ASSET_RECIPROCAL_SPACE_ID, "PseudoAxis", _CAP_PSEUDO_AXIS_ID), +) + +_BINDINGS: tuple[tuple[str, UUID], ...] = ( + ("goniometer", _ASSET_GONIOMETER_ID), + ("reciprocal_space", _ASSET_RECIPROCAL_SPACE_ID), +) + + +def _id_queue() -> list[UUID]: + return [ + *facility_id_prefix(unit_id=_I06_UNIT_ID, devices=_DEVICES), + *[uuid4() for _ in range(200)], + ] + + +@pytest.mark.integration +async def test_diffractometer_blueprint_materializes_at_i06(db_pool: asyncpg.Pool) -> None: + """Compose i06's diffraction-dichroism diffractometer as the catalog Diffractometer + Assembly materialized by one Fixture binding a goniometer and a reciprocal-space + PseudoAxis (detector_arm at count 0). Assert the Assembly stream, the Fixture + stream and its binding, and the per-Asset fixture back-references.""" + role_lookup = InMemoryRoleLookup() + role_lookup.register(SEED_ROLE_POSITIONER_ID, "Positioner") + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue(), role_lookup=role_lookup) + + await install_aps_unit( + deps, + profile_store=make_pg_profile_store(db_pool), + correlation_id=_CORRELATION_ID, + unit_id=_I06_UNIT_ID, + devices=_DEVICES, + unit_name="i06", + ) + + # The detector_arm slot references the RotaryStage Family, which i06 binds no + # instance of (count 0). Define the Family so the blueprint can reference it + # (a Family is a federation-shared device class; defining it does not assert + # i06 owns a RotaryStage device). + await bind_define_family(deps)( + DefineFamily(name="RotaryStage", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + def _slot( + name: str, fam_id: UUID, cardinality: SlotCardinality = SlotCardinality.EXACTLY_1 + ) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({fam_id}), + cardinality=cardinality, + ) + + assembly_id = await bind_define_assembly(deps)( + DefineAssembly( + name="Diffractometer", + presents_as=frozenset({SEED_ROLE_POSITIONER_ID}), + required_slots=frozenset( + { + _slot("goniometer", _CAP_GONIOMETER_ID), + _slot("detector_arm", _CAP_ROTARY_STAGE_ID, SlotCardinality.ZERO_OR_MORE), + _slot("reciprocal_space", _CAP_PSEUDO_AXIS_ID), + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + for i, (slot_name, asset_id) in enumerate(_BINDINGS): + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"i06_{slot_name}_{i}" + ) + + fixture_id = await bind_register_fixture(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name=slot_name, asset_id=asset_id) + for slot_name, asset_id in _BINDINGS + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + for _, asset_id in _BINDINGS: + await bind_attach_asset_to_fixture(deps)( + AttachAssetToFixture(asset_id=asset_id, fixture_id=fixture_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ===== Assertions ===== + + assembly_events, _ = await deps.event_store.load("Assembly", assembly_id) + assert [e.event_type for e in assembly_events] == ["AssemblyDefined"] + payload = assembly_events[0].payload + assert payload["presents_as"] == [str(SEED_ROLE_POSITIONER_ID)] + assert len(payload["required_slots"]) == 3 + assert payload["required_sub_assemblies"] == [] + + fixture_events, _ = await deps.event_store.load("Fixture", fixture_id) + assert [e.event_type for e in fixture_events] == ["FixtureRegistered"] + bindings = fixture_events[0].payload["slot_asset_bindings"] + assert len(bindings) == 2 + assert sum(1 for b in bindings if b["slot_name"] == "detector_arm") == 0 + assert {b["slot_name"] for b in bindings} == {"goniometer", "reciprocal_space"} + assert fixture_events[0].payload["assembly_id"] == str(assembly_id) + + for slot_name, asset_id in _BINDINGS: + events, _ = await deps.event_store.load("Asset", asset_id) + types = [e.event_type for e in events] + assert "AssetAttachedToFixture" in types, f"{slot_name}: expected fixture attach" diff --git a/apps/api/tests/integration/scenarios/test_i10_diffractometer_setup.py b/apps/api/tests/integration/scenarios/test_i10_diffractometer_setup.py new file mode 100644 index 0000000000..1912198225 --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_i10_diffractometer_setup.py @@ -0,0 +1,196 @@ +"""Soft X-ray diffractometer deployment at Diamond i10 (Assembly + Fixture). + +cluster: Commissioning +archetype: setup +bc_primary: Equipment +bc_touches: Equipment + +Materializes Diamond i10's RASOR sample diffractometer (two-theta scattering arm, +sample theta / chi, chamber X, alpha) as a binding of the catalog Diffractometer +Assembly, end-to-end against Postgres, from the assets i10's deployment descriptor +carries (`deployments/i10/beamline.yaml`, DIFF-1). + +This extends the blueprint beyond the hard X-ray six-circle it was earned on +(4-ID + 8-ID) and the SwissFEL platforms (Bernina + Cristallina) to a soft X-ray +resonant-scattering instrument: proof the one composition contract spans a +different diffractometer family. + + - Diffractometer (Family Goniometer) -> Exactly1 goniometer slot (RASOR circles) + - ReciprocalSpace (Family PseudoAxis) -> Exactly1 reciprocal_space slot + +The RASOR two-theta scattering arm is folded into the goniometer's circle set in +the descriptor rather than modelled as a separate RotaryStage Asset, so the +ZeroOrMore detector_arm slot binds at count 0 (as at Bernina GPS). The +reciprocal-space solver partition (DIFF-2) is left unset, matching the earlier +fixtures. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.assembly import ( + SlotCardinality, + SlotName, + TemplateSlot, +) +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.role import SEED_ROLE_POSITIONER_ID +from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture +from cora.equipment.features.attach_asset_to_fixture import bind as bind_attach_asset_to_fixture +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_assembly import bind as bind_define_assembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_family import bind as bind_define_family +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.features.register_fixture import bind as bind_register_fixture +from cora.infrastructure.adapters.in_memory_role_lookup import InMemoryRoleLookup +from tests.integration._equipment_helpers import install_existing_asset_into_fresh_mount +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import ( + DeviceSpec, + facility_id_prefix, + install_aps_unit, + operator_for, +) + +_NOW = datetime(2026, 5, 28, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000001000bb") + +# Facility hierarchy (scenario tag 10 = i10). +_I10_UNIT_ID = UUID("01900000-0000-7000-8000-000000100a01") + +# Family ids (deterministic uuid5 from the name). RotaryStage is the detector_arm +# slot's family in the catalog blueprint; i10 binds no arm Asset (count 0) but the +# slot's required family stays RotaryStage so the blueprint matches the catalog. +_CAP_GONIOMETER_ID = family_stream_id(FamilyName("Goniometer")) +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_PSEUDO_AXIS_ID = family_stream_id(FamilyName("PseudoAxis")) + +# The two constituent Assets (scenario-supplied ids), mirroring the named devices +# in deployments/i10/beamline.yaml. +_ASSET_GONIOMETER_ID = UUID("01900000-0000-7000-8000-000000100a11") +_ASSET_RECIPROCAL_SPACE_ID = UUID("01900000-0000-7000-8000-000000100a21") + +_DEVICES = ( + DeviceSpec("Diffractometer", _ASSET_GONIOMETER_ID, "Goniometer", _CAP_GONIOMETER_ID), + DeviceSpec("ReciprocalSpace", _ASSET_RECIPROCAL_SPACE_ID, "PseudoAxis", _CAP_PSEUDO_AXIS_ID), +) + +_BINDINGS: tuple[tuple[str, UUID], ...] = ( + ("goniometer", _ASSET_GONIOMETER_ID), + ("reciprocal_space", _ASSET_RECIPROCAL_SPACE_ID), +) + + +def _id_queue() -> list[UUID]: + return [ + *facility_id_prefix(unit_id=_I10_UNIT_ID, devices=_DEVICES), + *[uuid4() for _ in range(200)], + ] + + +@pytest.mark.integration +async def test_diffractometer_blueprint_materializes_at_i10(db_pool: asyncpg.Pool) -> None: + """Compose i10's RASOR diffractometer as the catalog Diffractometer + Assembly materialized by one Fixture binding a goniometer and a reciprocal-space + PseudoAxis (detector_arm at count 0). Assert the Assembly stream, the Fixture + stream and its binding, and the per-Asset fixture back-references.""" + role_lookup = InMemoryRoleLookup() + role_lookup.register(SEED_ROLE_POSITIONER_ID, "Positioner") + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue(), role_lookup=role_lookup) + + await install_aps_unit( + deps, + profile_store=make_pg_profile_store(db_pool), + correlation_id=_CORRELATION_ID, + unit_id=_I10_UNIT_ID, + devices=_DEVICES, + unit_name="i10", + ) + + # The detector_arm slot references the RotaryStage Family, which i10 binds no + # instance of (count 0). Define the Family so the blueprint can reference it + # (a Family is a federation-shared device class; defining it does not assert + # i10 owns a RotaryStage device). + await bind_define_family(deps)( + DefineFamily(name="RotaryStage", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + def _slot( + name: str, fam_id: UUID, cardinality: SlotCardinality = SlotCardinality.EXACTLY_1 + ) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({fam_id}), + cardinality=cardinality, + ) + + assembly_id = await bind_define_assembly(deps)( + DefineAssembly( + name="Diffractometer", + presents_as=frozenset({SEED_ROLE_POSITIONER_ID}), + required_slots=frozenset( + { + _slot("goniometer", _CAP_GONIOMETER_ID), + _slot("detector_arm", _CAP_ROTARY_STAGE_ID, SlotCardinality.ZERO_OR_MORE), + _slot("reciprocal_space", _CAP_PSEUDO_AXIS_ID), + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + for i, (slot_name, asset_id) in enumerate(_BINDINGS): + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"i10_{slot_name}_{i}" + ) + + fixture_id = await bind_register_fixture(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name=slot_name, asset_id=asset_id) + for slot_name, asset_id in _BINDINGS + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + for _, asset_id in _BINDINGS: + await bind_attach_asset_to_fixture(deps)( + AttachAssetToFixture(asset_id=asset_id, fixture_id=fixture_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ===== Assertions ===== + + assembly_events, _ = await deps.event_store.load("Assembly", assembly_id) + assert [e.event_type for e in assembly_events] == ["AssemblyDefined"] + payload = assembly_events[0].payload + assert payload["presents_as"] == [str(SEED_ROLE_POSITIONER_ID)] + assert len(payload["required_slots"]) == 3 + assert payload["required_sub_assemblies"] == [] + + fixture_events, _ = await deps.event_store.load("Fixture", fixture_id) + assert [e.event_type for e in fixture_events] == ["FixtureRegistered"] + bindings = fixture_events[0].payload["slot_asset_bindings"] + assert len(bindings) == 2 + assert sum(1 for b in bindings if b["slot_name"] == "detector_arm") == 0 + assert {b["slot_name"] for b in bindings} == {"goniometer", "reciprocal_space"} + assert fixture_events[0].payload["assembly_id"] == str(assembly_id) + + for slot_name, asset_id in _BINDINGS: + events, _ = await deps.event_store.load("Asset", asset_id) + types = [e.event_type for e in events] + assert "AssetAttachedToFixture" in types, f"{slot_name}: expected fixture attach" diff --git a/apps/api/tests/integration/scenarios/test_i19_diffractometer_setup.py b/apps/api/tests/integration/scenarios/test_i19_diffractometer_setup.py new file mode 100644 index 0000000000..aabf4df2ee --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_i19_diffractometer_setup.py @@ -0,0 +1,197 @@ +"""Kappa four-circle diffractometer deployment at Diamond i19 (Assembly + Fixture). + +cluster: Commissioning +archetype: setup +bc_primary: Equipment +bc_touches: Equipment + +Materializes Diamond i19's Newport kappa four-circle diffractometer (phi / omega / +kappa sample circles + the 2THETA detector arm + det_z) as a binding of the catalog +Diffractometer Assembly, end-to-end against Postgres, from the assets i19's +deployment descriptor carries (`deployments/i19/beamline.yaml`, DIFF-1). + +This is a fifth independent binding of the one blueprint (after 4-ID + 8-ID + +Bernina + Cristallina), a hard X-ray kappa-geometry small-molecule instrument: +proof the composition contract spans the kappa family too. + + - Diffractometer (Family Goniometer) -> Exactly1 goniometer slot + (phi / omega / kappa circles; kappa is a setting) + - ReciprocalSpace (Family PseudoAxis) -> Exactly1 reciprocal_space slot + +The 2THETA detector arm is folded into the goniometer's circle set in the +descriptor rather than modelled as a separate RotaryStage Asset, so the ZeroOrMore +detector_arm slot binds at count 0 (as at Bernina GPS). The serial / microfocus +fixed-target arm (a second sample-orientation stage, SERIAL-1) is a distinct +platform and is not bound here. The reciprocal-space solver partition (DIFF-2) is +left unset, matching the earlier fixtures. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.assembly import ( + SlotCardinality, + SlotName, + TemplateSlot, +) +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.role import SEED_ROLE_POSITIONER_ID +from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture +from cora.equipment.features.attach_asset_to_fixture import bind as bind_attach_asset_to_fixture +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_assembly import bind as bind_define_assembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_family import bind as bind_define_family +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.features.register_fixture import bind as bind_register_fixture +from cora.infrastructure.adapters.in_memory_role_lookup import InMemoryRoleLookup +from tests.integration._equipment_helpers import install_existing_asset_into_fresh_mount +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import ( + DeviceSpec, + facility_id_prefix, + install_aps_unit, + operator_for, +) + +_NOW = datetime(2026, 5, 28, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000001900bb") + +# Facility hierarchy (scenario tag 19 = i19). +_I19_UNIT_ID = UUID("01900000-0000-7000-8000-000000190a01") + +# Family ids (deterministic uuid5 from the name). RotaryStage is the detector_arm +# slot's family in the catalog blueprint; i19 binds no arm Asset (count 0) but the +# slot's required family stays RotaryStage so the blueprint matches the catalog. +_CAP_GONIOMETER_ID = family_stream_id(FamilyName("Goniometer")) +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_PSEUDO_AXIS_ID = family_stream_id(FamilyName("PseudoAxis")) + +# The two constituent Assets (scenario-supplied ids), mirroring the named devices +# in deployments/i19/beamline.yaml. +_ASSET_GONIOMETER_ID = UUID("01900000-0000-7000-8000-000000190a11") +_ASSET_RECIPROCAL_SPACE_ID = UUID("01900000-0000-7000-8000-000000190a21") + +_DEVICES = ( + DeviceSpec("Diffractometer", _ASSET_GONIOMETER_ID, "Goniometer", _CAP_GONIOMETER_ID), + DeviceSpec("ReciprocalSpace", _ASSET_RECIPROCAL_SPACE_ID, "PseudoAxis", _CAP_PSEUDO_AXIS_ID), +) + +_BINDINGS: tuple[tuple[str, UUID], ...] = ( + ("goniometer", _ASSET_GONIOMETER_ID), + ("reciprocal_space", _ASSET_RECIPROCAL_SPACE_ID), +) + + +def _id_queue() -> list[UUID]: + return [ + *facility_id_prefix(unit_id=_I19_UNIT_ID, devices=_DEVICES), + *[uuid4() for _ in range(200)], + ] + + +@pytest.mark.integration +async def test_diffractometer_blueprint_materializes_at_i19(db_pool: asyncpg.Pool) -> None: + """Compose i19's Newport kappa four-circle diffractometer as the catalog Diffractometer + Assembly materialized by one Fixture binding a goniometer and a reciprocal-space + PseudoAxis (detector_arm at count 0). Assert the Assembly stream, the Fixture + stream and its binding, and the per-Asset fixture back-references.""" + role_lookup = InMemoryRoleLookup() + role_lookup.register(SEED_ROLE_POSITIONER_ID, "Positioner") + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue(), role_lookup=role_lookup) + + await install_aps_unit( + deps, + profile_store=make_pg_profile_store(db_pool), + correlation_id=_CORRELATION_ID, + unit_id=_I19_UNIT_ID, + devices=_DEVICES, + unit_name="i19", + ) + + # The detector_arm slot references the RotaryStage Family, which i19 binds no + # instance of (count 0). Define the Family so the blueprint can reference it + # (a Family is a federation-shared device class; defining it does not assert + # i19 owns a RotaryStage device). + await bind_define_family(deps)( + DefineFamily(name="RotaryStage", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + def _slot( + name: str, fam_id: UUID, cardinality: SlotCardinality = SlotCardinality.EXACTLY_1 + ) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({fam_id}), + cardinality=cardinality, + ) + + assembly_id = await bind_define_assembly(deps)( + DefineAssembly( + name="Diffractometer", + presents_as=frozenset({SEED_ROLE_POSITIONER_ID}), + required_slots=frozenset( + { + _slot("goniometer", _CAP_GONIOMETER_ID), + _slot("detector_arm", _CAP_ROTARY_STAGE_ID, SlotCardinality.ZERO_OR_MORE), + _slot("reciprocal_space", _CAP_PSEUDO_AXIS_ID), + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + for i, (slot_name, asset_id) in enumerate(_BINDINGS): + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"i19_{slot_name}_{i}" + ) + + fixture_id = await bind_register_fixture(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name=slot_name, asset_id=asset_id) + for slot_name, asset_id in _BINDINGS + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + for _, asset_id in _BINDINGS: + await bind_attach_asset_to_fixture(deps)( + AttachAssetToFixture(asset_id=asset_id, fixture_id=fixture_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ===== Assertions ===== + + assembly_events, _ = await deps.event_store.load("Assembly", assembly_id) + assert [e.event_type for e in assembly_events] == ["AssemblyDefined"] + payload = assembly_events[0].payload + assert payload["presents_as"] == [str(SEED_ROLE_POSITIONER_ID)] + assert len(payload["required_slots"]) == 3 + assert payload["required_sub_assemblies"] == [] + + fixture_events, _ = await deps.event_store.load("Fixture", fixture_id) + assert [e.event_type for e in fixture_events] == ["FixtureRegistered"] + bindings = fixture_events[0].payload["slot_asset_bindings"] + assert len(bindings) == 2 + assert sum(1 for b in bindings if b["slot_name"] == "detector_arm") == 0 + assert {b["slot_name"] for b in bindings} == {"goniometer", "reciprocal_space"} + assert fixture_events[0].payload["assembly_id"] == str(assembly_id) + + for slot_name, asset_id in _BINDINGS: + events, _ = await deps.event_store.load("Asset", asset_id) + types = [e.event_type for e in events] + assert "AssetAttachedToFixture" in types, f"{slot_name}: expected fixture attach" diff --git a/apps/api/tests/integration/scenarios/test_id32_diffractometer_setup.py b/apps/api/tests/integration/scenarios/test_id32_diffractometer_setup.py new file mode 100644 index 0000000000..66209c11b9 --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_id32_diffractometer_setup.py @@ -0,0 +1,195 @@ +"""Soft X-ray diffractometer deployment at ESRF ID32 (Assembly + Fixture). + +cluster: Commissioning +archetype: setup +bc_primary: Equipment +bc_touches: Equipment + +Materializes ESRF ID32's four-circle sample diffractometer (BLISS DiffE4CH, E4CH +geometry) as a binding of the catalog Diffractometer Assembly, end-to-end against +Postgres, from the assets ID32's deployment descriptor carries +(`deployments/id32/beamline.yaml`, DIFF-1). + +This extends the blueprint beyond the hard X-ray six-circle it was earned on +(4-ID + 8-ID) and the SwissFEL platforms (Bernina + Cristallina) to an ESRF soft +X-ray RIXS instrument: proof the one composition contract spans a different +diffractometer family. + + - Diffractometer (Family Goniometer) -> Exactly1 goniometer slot (E4CH circles) + - ReciprocalSpace (Family PseudoAxis) -> Exactly1 reciprocal_space slot (hkl) + +The detector arm is not modelled as a separate RotaryStage Asset in the descriptor +(the RIXS spectrometer arm is a distinct device), so the ZeroOrMore detector_arm +slot binds at count 0 (as at Bernina GPS). The reciprocal-space solver partition +(DIFF-2) is left unset, matching the earlier fixtures. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.assembly import ( + SlotCardinality, + SlotName, + TemplateSlot, +) +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.role import SEED_ROLE_POSITIONER_ID +from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture +from cora.equipment.features.attach_asset_to_fixture import bind as bind_attach_asset_to_fixture +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_assembly import bind as bind_define_assembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_family import bind as bind_define_family +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.features.register_fixture import bind as bind_register_fixture +from cora.infrastructure.adapters.in_memory_role_lookup import InMemoryRoleLookup +from tests.integration._equipment_helpers import install_existing_asset_into_fresh_mount +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import ( + DeviceSpec, + facility_id_prefix, + install_aps_unit, + operator_for, +) + +_NOW = datetime(2026, 5, 28, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000003200bb") + +# Facility hierarchy (scenario tag 32 = ID32). +_ID32_UNIT_ID = UUID("01900000-0000-7000-8000-000000320a01") + +# Family ids (deterministic uuid5 from the name). RotaryStage is the detector_arm +# slot's family in the catalog blueprint; id32 binds no arm Asset (count 0) but the +# slot's required family stays RotaryStage so the blueprint matches the catalog. +_CAP_GONIOMETER_ID = family_stream_id(FamilyName("Goniometer")) +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_PSEUDO_AXIS_ID = family_stream_id(FamilyName("PseudoAxis")) + +# The two constituent Assets (scenario-supplied ids), mirroring the named devices +# in deployments/id32/beamline.yaml. +_ASSET_GONIOMETER_ID = UUID("01900000-0000-7000-8000-000000320a11") +_ASSET_RECIPROCAL_SPACE_ID = UUID("01900000-0000-7000-8000-000000320a21") + +_DEVICES = ( + DeviceSpec("Diffractometer", _ASSET_GONIOMETER_ID, "Goniometer", _CAP_GONIOMETER_ID), + DeviceSpec("ReciprocalSpace", _ASSET_RECIPROCAL_SPACE_ID, "PseudoAxis", _CAP_PSEUDO_AXIS_ID), +) + +_BINDINGS: tuple[tuple[str, UUID], ...] = ( + ("goniometer", _ASSET_GONIOMETER_ID), + ("reciprocal_space", _ASSET_RECIPROCAL_SPACE_ID), +) + + +def _id_queue() -> list[UUID]: + return [ + *facility_id_prefix(unit_id=_ID32_UNIT_ID, devices=_DEVICES), + *[uuid4() for _ in range(200)], + ] + + +@pytest.mark.integration +async def test_diffractometer_blueprint_materializes_at_id32(db_pool: asyncpg.Pool) -> None: + """Compose ID32's E4CH four-circle diffractometer as the catalog Diffractometer + Assembly materialized by one Fixture binding a goniometer and a reciprocal-space + PseudoAxis (detector_arm at count 0). Assert the Assembly stream, the Fixture + stream and its binding, and the per-Asset fixture back-references.""" + role_lookup = InMemoryRoleLookup() + role_lookup.register(SEED_ROLE_POSITIONER_ID, "Positioner") + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue(), role_lookup=role_lookup) + + await install_aps_unit( + deps, + profile_store=make_pg_profile_store(db_pool), + correlation_id=_CORRELATION_ID, + unit_id=_ID32_UNIT_ID, + devices=_DEVICES, + unit_name="id32", + ) + + # The detector_arm slot references the RotaryStage Family, which id32 binds no + # instance of (count 0). Define the Family so the blueprint can reference it + # (a Family is a federation-shared device class; defining it does not assert + # id32 owns a RotaryStage device). + await bind_define_family(deps)( + DefineFamily(name="RotaryStage", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + def _slot( + name: str, fam_id: UUID, cardinality: SlotCardinality = SlotCardinality.EXACTLY_1 + ) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({fam_id}), + cardinality=cardinality, + ) + + assembly_id = await bind_define_assembly(deps)( + DefineAssembly( + name="Diffractometer", + presents_as=frozenset({SEED_ROLE_POSITIONER_ID}), + required_slots=frozenset( + { + _slot("goniometer", _CAP_GONIOMETER_ID), + _slot("detector_arm", _CAP_ROTARY_STAGE_ID, SlotCardinality.ZERO_OR_MORE), + _slot("reciprocal_space", _CAP_PSEUDO_AXIS_ID), + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + for i, (slot_name, asset_id) in enumerate(_BINDINGS): + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"id32_{slot_name}_{i}" + ) + + fixture_id = await bind_register_fixture(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name=slot_name, asset_id=asset_id) + for slot_name, asset_id in _BINDINGS + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + for _, asset_id in _BINDINGS: + await bind_attach_asset_to_fixture(deps)( + AttachAssetToFixture(asset_id=asset_id, fixture_id=fixture_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ===== Assertions ===== + + assembly_events, _ = await deps.event_store.load("Assembly", assembly_id) + assert [e.event_type for e in assembly_events] == ["AssemblyDefined"] + payload = assembly_events[0].payload + assert payload["presents_as"] == [str(SEED_ROLE_POSITIONER_ID)] + assert len(payload["required_slots"]) == 3 + assert payload["required_sub_assemblies"] == [] + + fixture_events, _ = await deps.event_store.load("Fixture", fixture_id) + assert [e.event_type for e in fixture_events] == ["FixtureRegistered"] + bindings = fixture_events[0].payload["slot_asset_bindings"] + assert len(bindings) == 2 + assert sum(1 for b in bindings if b["slot_name"] == "detector_arm") == 0 + assert {b["slot_name"] for b in bindings} == {"goniometer", "reciprocal_space"} + assert fixture_events[0].payload["assembly_id"] == str(assembly_id) + + for slot_name, asset_id in _BINDINGS: + events, _ = await deps.event_store.load("Asset", asset_id) + types = [e.event_type for e in events] + assert "AssetAttachedToFixture" in types, f"{slot_name}: expected fixture attach"