diff --git a/apps/api/src/cora/operation/acquisitions.py b/apps/api/src/cora/operation/acquisitions.py index a24055b0ff..3594df4cdb 100644 --- a/apps/api/src/cora/operation/acquisitions.py +++ b/apps/api/src/cora/operation/acquisitions.py @@ -118,14 +118,15 @@ def _check_trigger_constraints(self) -> CollectParams: return self -async def _run_collect_cycle(ctx: ActionContext, params: CollectParams) -> Mapping[str, Any]: +async def run_collect_cycle(ctx: ActionContext, params: CollectParams) -> Mapping[str, Any]: """One collect cycle: configure detector, arm, poll until Done, read state. - Internal helper shared by the `collect` action body and the - composing `discrete` / `continuous` bodies. Takes a validated - `CollectParams` (or any subclass that exposes the - same fields, e.g., `DiscreteParams` inherits all of them), so the - callers don't re-validate or re-wrap `ActionContext` per cycle. + Shared helper used by the `collect` action body and the composing + bodies `discrete` / `continuous` (this module) and `flats` + (`cora.operation.staging`). Takes a validated `CollectParams` (or any + subclass that exposes the same fields, e.g., `DiscreteParams` / + `FlatsParams` inherit all of them), so the callers don't re-validate or + re-wrap `ActionContext` per cycle. Returns the same evidence Mapping the `collect` action body returns, so per-point composition stays uniform. """ @@ -179,7 +180,7 @@ async def collect(ctx: ActionContext) -> Mapping[str, Any]: `source` as evidence-only fields (the trigger EMITTER is configured by caller-authored setpoint steps before this action step). """ - return await _run_collect_cycle(ctx, CollectParams.model_validate(ctx.params)) + return await run_collect_cycle(ctx, CollectParams.model_validate(ctx.params)) class DiscreteParams(CollectParams): @@ -236,7 +237,7 @@ async def discrete(ctx: ActionContext) -> Mapping[str, Any]: await ctx.control_port.write(params.axis, point, wait=True) if params.wait > 0: await asyncio.sleep(params.wait) - cycle = await _run_collect_cycle(ctx, params) + cycle = await run_collect_cycle(ctx, params) results.append({"point": point, "collect": cycle}) return { "axis": params.axis, @@ -362,4 +363,5 @@ async def continuous(ctx: ActionContext) -> Mapping[str, Any]: "collect", "continuous", "discrete", + "run_collect_cycle", ] diff --git a/apps/api/src/cora/operation/ports/control_port.py b/apps/api/src/cora/operation/ports/control_port.py index ed5c5416d1..268ea88f9d 100644 --- a/apps/api/src/cora/operation/ports/control_port.py +++ b/apps/api/src/cora/operation/ports/control_port.py @@ -224,13 +224,26 @@ def __init__(self, address: str, reason: str) -> None: class ControlValueCoercionError(Exception): - """Adapter cannot unpack the substrate value into a `Reading` shape. - - Triggered when an adapter sees a novel structured type (e.g., a - new EPICS V4 NT variant) or a substrate primitive that does not - fit the closed `ReadingKind` set. Carries the substrate's raw - type label so operators can extend the kind set in a follow-up - rather than silently dropping data. + """A control value cannot be coerced to the kind a consumer requires. + + `target_kind` names the kind the value could not satisfy; it is a + free-form label, not constrained to `ReadingKind`. Three live cases, + all carrying the raw type label so the failure is debuggable rather + than a silently dropped value: + + - Adapter read-unpack: an adapter sees a structured type or a + substrate primitive that does not fit the closed `ReadingKind` + set; `target_kind` is the `ReadingKind` it was unpacking toward. + - Adapter write-coercion: a write value cannot be encoded for the + substrate; `target_kind` is an operation label (e.g. the PVA + adapter's `"pva put"`), not a `ReadingKind`. + - Consumer-side: a `Reading` unpacked cleanly but its `value` is + the wrong type for a downstream consumer (e.g., an action body + needs a numeric axis position but read a categorical leaf). Here + `target_kind` is a LOGICAL kind the consumer names (e.g. + `"number"`). Action bodies raise this so the Conductor records a + structured step failure (it is a member of the Conductor's caught + `_CONTROL_ERRORS`) instead of letting a bare `TypeError` escape. """ def __init__(self, address: str, raw_type: str, target_kind: str) -> None: diff --git a/apps/api/src/cora/operation/staging.py b/apps/api/src/cora/operation/staging.py new file mode 100644 index 0000000000..09d5bbbdc8 --- /dev/null +++ b/apps/api/src/cora/operation/staging.py @@ -0,0 +1,150 @@ +"""Sample-staging action bodies for the Conductor (composition, not primitives). + +The scan primitives in `cora.operation.acquisitions` (`collect` / +`discrete` / `continuous`) are the acquisition-MOTION taxonomy: single / +stepped / swept capture. This module holds a different KIND of action: a +ceremony STAGING composition that brackets an acquisition with a +save-and-restore of an axis. `flats` is `collect` plus sample retraction, +not a fourth scan primitive, so it lives here rather than alongside the +primitives. + +## Why staging needs a body at all (the conduct variable-binding gap) + +A flat-field capture retracts the sample off the beam, collects, then +restores the sample to its aligned centre. The restore target is the +position read at runtime, and CORA has no relative-move primitive, so the +restore is an absolute write of the read-back value. The conduct step +model is static: `RecipeSetpointStep.value` is a literal or a `BindingRef`, +never "the value I just read at runtime". So a read-then-restore cannot be +expressed as recipe steps today and must live inside a body. + +`flats` is therefore a pragmatic stand-in for a missing conduct capability. +The principled fix is conduct-level runtime VARIABLE BINDING (read a value +into a binding, reference it in a later setpoint step), which would turn +the save-and-restore into three ordinary recipe steps (read axis -> collect +-> setpoint from the read) and RETIRE this body. Variable binding is the +designated next design; see [[project_flat_dark_prologue_design]]. Until it +lands, keep staging compositions here and out of the scan-primitive family. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from pydantic import Field, model_validator + +from cora.operation.acquisitions import CollectParams, run_collect_cycle +from cora.operation.ports.control_port import ControlValueCoercionError + +if TYPE_CHECKING: + from collections.abc import Mapping + + from cora.operation.conductor import ActionContext + + +def _require_numeric(value: Any, address: str) -> float: + """Return `value` as a number or raise a Conductor-recordable failure. + + The save-and-restore arithmetic (`saved + clearance`) needs a numeric + axis read. `Reading.value` is typed `Any`; a non-numeric read (a + mis-addressed or categorical leaf) would otherwise raise a bare + `TypeError` that escapes the Conductor's `_CONTROL_ERRORS`-only catch + and strands the Procedure in Running. Mapping it to + `ControlValueCoercionError` (in `_CONTROL_ERRORS`) lets the Conductor + record a structured step failure instead. The read precedes any axis + move, so nothing has actuated when this raises. + + Non-finite floats (NaN / +-inf) are also rejected: a NaN/inf axis read + (EPICS UDF, uninitialized record) would otherwise propagate through the + `saved + clearance` arithmetic into an absolute write of an undefined + setpoint with no recorded failure. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ControlValueCoercionError(address, type(value).__name__, "number") + if not math.isfinite(value): + raise ControlValueCoercionError(address, repr(value), "finite number") + return value + + +class FlatsParams(CollectParams): + """Validated parameters for the `flats` staging action body. + + Extends `CollectParams` with the sample-retraction definition (`axis` + + `clearance`). Inherits the detector / trigger / dwell / repetitions + fields and the three conditional `@model_validator` rules unchanged: a + flat capture runs the same collect cycle as `collect`, just with the + sample retracted. + + `clearance` is a SIGNED NONZERO offset added to the saved position to + form the off-centre target. CORA has no relative-move primitive, so the + retract and the restore are both absolute writes (read the position, add + the clearance, write it; later write the saved value back). The sign + chooses the retraction direction; the magnitude is the clear-of-beam + travel. Zero is rejected (it would leave the sample in the beam and + label an in-beam capture a flat-field), mirroring + `ContinuousParams._check_sweep_range`'s zero-range guard. Per + [[project_units_design]] both `dwell` (inherited) and `clearance` carry + the canonical unit annotation; `clearance` is mm. + """ + + axis: str + clearance: float = Field( + ..., + json_schema_extra={"unit": {"system": "udunits", "code": "mm"}}, + ) + + @model_validator(mode="after") + def _check_clearance_nonzero(self) -> FlatsParams: + if self.clearance == 0: + raise ValueError("clearance must be nonzero (zero leaves the sample in the beam)") + return self + + +async def flats(ctx: ActionContext) -> Mapping[str, Any]: + """Flat-field capture with the sample retracted, then restored. + + Save-and-restore around one `collect` cycle: read the current `axis` + position, drive it off the beam centre by `clearance` (an absolute + write, since CORA has no relative-move primitive), run the collect + cycle to capture the flat frames, then restore the axis to the saved + position. Both the retract and the restore are blocking writes + (`wait=True`) so the frames land at the settled off-centre position and + the sample is back at its aligned centre before the body returns. + + Runs the shared `run_collect_cycle` with the already-validated + `FlatsParams` (a `CollectParams` subclass), the same way `discrete` and + `continuous` compose a collect cycle without re-validating. + + On a `Control*Error` mid-cycle the exception propagates unchanged (no + rollback try/finally, matching `collect` / `discrete` / `continuous`): + the Conductor records the step failure and the axis is left retracted + (off the beam), an acceptable fault state for a sample-out capture. + Operators reconcile the retracted axis via state inspection, as with + any halted conduct. + + Evidence shape carries the save-and-restore positions (`saved_value`, + `offcenter_target`) plus the nested `collect` cycle evidence. The + restore landing is proven by re-reading the axis (see the integration + test), not by an echoed field here. + """ + params = FlatsParams.model_validate(ctx.params) + saved = await ctx.control_port.read(params.axis) + saved_value = _require_numeric(saved.value, params.axis) + offcenter_target = saved_value + params.clearance + await ctx.control_port.write(params.axis, offcenter_target, wait=True) + cycle = await run_collect_cycle(ctx, params) + await ctx.control_port.write(params.axis, saved_value, wait=True) + return { + "axis": params.axis, + "saved_value": saved_value, + "clearance": params.clearance, + "offcenter_target": offcenter_target, + "collect": cycle, + } + + +__all__ = [ + "FlatsParams", + "flats", +] diff --git a/apps/api/src/cora/operation/wire.py b/apps/api/src/cora/operation/wire.py index ed2c664c58..d6f7bfb22e 100644 --- a/apps/api/src/cora/operation/wire.py +++ b/apps/api/src/cora/operation/wire.py @@ -87,6 +87,7 @@ try_conduct_procedure, ) from cora.operation.ports.control_port import ControlPort +from cora.operation.staging import flats _BC = "operation" @@ -150,7 +151,9 @@ def wire_operation(deps: Kernel, *, control_port: ControlPort | None = None) -> `ControlPortRegistry` with the configured substrate adapters per prefix. The action registry is hand-seeded with the three substrate-neutral scan-acquisition primitives `collect` + - `discrete` + `continuous`. Per-deployment registry-from-config + `discrete` + `continuous`, plus the `flats` staging action (a + save-and-restore composition over `collect`, from + `cora.operation.staging`). Per-deployment registry-from-config plumbing remains deferred. """ step_store: ActivityStore = ( @@ -212,7 +215,7 @@ def wire_operation(deps: Kernel, *, control_port: ControlPort | None = None) -> else build_control_port(deps.settings.control_port_routes) ) action_registry = InMemoryActionRegistry( - {"collect": collect, "discrete": discrete, "continuous": continuous} + {"collect": collect, "discrete": discrete, "continuous": continuous, "flats": flats} ) conductor = Conductor( control_port=control_port, diff --git a/apps/api/tests/architecture/test_procedure_kind_naming.py b/apps/api/tests/architecture/test_procedure_kind_naming.py index cecedb235d..d74075fe6d 100644 --- a/apps/api/tests/architecture/test_procedure_kind_naming.py +++ b/apps/api/tests/architecture/test_procedure_kind_naming.py @@ -48,14 +48,20 @@ ) # Whole-kind carve-outs, with rationale: -# first_light - whole-system milestone, no single subject -# {dark,flat}_baseline - capture-and-store; the trailing noun is the -# produced artifact, not the operation +# first_light - whole-system milestone, no single subject +# {dark,flat}_baseline - capture-and-store; the trailing noun is the +# produced artifact, not the operation +# normalization_baseline - the combined darks+flats normalization +# ceremony; same capture-and-store idiom as +# its two predecessors (trailing noun is the +# produced artifact), composing them rather +# than superseding them CARVE_OUT_KINDS = frozenset( { "first_light", "dark_baseline", "flat_baseline", + "normalization_baseline", } ) @@ -66,8 +72,18 @@ def _scenario_files() -> list[Path]: ) +_REGISTRATION_COMMANDS = frozenset({"RegisterProcedure", "RegisterProcedureFromRecipe"}) +"""The two procedure-registration commands that exist today, both carrying +an identically-shaped ``kind`` field that the noun-LAST convention governs. +A recipe-driven Procedure (``RegisterProcedureFromRecipe``) must satisfy it +too, else a verb-first recipe-path kind would slip past the guard. This is +an explicit enumeration, NOT a verb-agnostic match: a third +procedure-registration command with a ``kind`` field must be added here, or +its scenarios escape the scan.""" + + def _register_procedure_kinds(tree: ast.AST) -> list[tuple[int, str]]: - """(lineno, kind) for every ``RegisterProcedure(kind="")`` call. + """(lineno, kind) for every ``Register[Procedure|ProcedureFromRecipe](kind="")`` call. Non-literal kinds (variables, f-strings) cannot be checked statically and are skipped; scenario tests use string literals. @@ -84,7 +100,7 @@ def _register_procedure_kinds(tree: ast.AST) -> list[tuple[int, str]]: if isinstance(func, ast.Attribute) else None ) - if name != "RegisterProcedure": + if name not in _REGISTRATION_COMMANDS: continue kind_kw = next((kw for kw in node.keywords if kw.arg == "kind"), None) if kind_kw is None: @@ -112,7 +128,7 @@ def test_scenario_procedure_kinds_follow_noun_last_convention() -> None: violations.append(f" {path.name}:{lineno}: kind={kind!r}") assert seen, ( - "no RegisterProcedure(kind=...) literals found under " + "no Register[Procedure|ProcedureFromRecipe](kind=...) literals found under " "tests/integration/scenarios/ -- scope regression?" ) diff --git a/apps/api/tests/integration/scenarios/test_2bm_normalization_baseline.py b/apps/api/tests/integration/scenarios/test_2bm_normalization_baseline.py new file mode 100644 index 0000000000..c928104f81 --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_2bm_normalization_baseline.py @@ -0,0 +1,337 @@ +"""Normalization baseline (darks + flats) at APS 2-BM, CORA-conducted from a Recipe. + +cluster: Commissioning +archetype: routine +bc_primary: Operation +bc_touches: Data, Operation, Recipe + +Scenario test for the combined dark + flat normalization ceremony, +modeled as a deployment Recipe and run as the first deliberate consumer +of the Procedure Conductor. The ceremony is a Recipe (a templated step +list) realizing the existing `cora.capability.acquisition`; an operator +registers a Procedure from it (`register_procedure_from_recipe`), and the +conduct handler re-expands the recipe into conduct steps and drives them +through the ControlPort against a soft IOC. It composes the two shipped +record-path captures (`dark_baseline` + `flat_baseline`) into one +conducted, modeled ceremony that produces the normalization baseline +every tomographic Run normalizes against (darks are subtracted, flats +divide). + +See [[project_flat_dark_prologue_design]] for the design lock and +[[project_resumable_conduct_design]] for the re-establishment-prologue +model this ceremony is the buildable-now first instance of. See +[[project_seam_model]] for why this is a deliberate Actuate-axis move +(CORA conducts), not the record-path the live 2-BM seam uses today. + +## Why this scenario exists + +A conduct-path "101" that exercises the whole modeled ladder end to end: +Capability -> Recipe (step template) -> Procedure (register-from-recipe) +-> expand -> Conductor -> ControlPort -> soft IOC, with zero +live-hardware risk, before the more complex consumers (fly-scan resume, +autonomous control) ride the same rails. + + 1. First scenario that drives the Procedure Conductor, AND the first + end-to-end exercise of the recipe-driven conduct path (define recipe + -> register-from-recipe -> conduct re-expands the pinned template). + Every other 2-BM scenario is record-path (hand-built + `append_activities` entries, no Conductor). + 2. First use of the `flats` staging action: a save-and-restore capture + that reads the sample axis, retracts it off the beam by a clearance, + collects, and restores the axis. It lives in `cora.operation.staging` + (a composition over `collect`), not among the scan primitives. + 3. First Dataset whose `producing_actuation_kind` is derived from a + conducted Procedure: the soft IOC is a declared simulator, so the + conduct observes `Simulated` and that provenance rides through the + terminal event onto the registered baseline Dataset. + +## Domain shape + +The DATA need is universal across CT facilities: every pipeline (tomopy, +ASTRA, plain numpy) flat-field corrects raw projections against dark + +flat references. The CONCRETE sequence below follows TomoScan / 2-BM +practice but is staff-confirm-pending per the design lock's open +questions on transit-safety ordering and ceremony ORDER / FREQUENCY: + + 1. Close the shutter; acquire N dark frames (detector dark current). + 2. Open the shutter; retract the sample off the beam; acquire N flat + frames (the beam profile through empty optics); restore the sample. + 3. Close the shutter (return to the safe state). + 4. Store the baseline so future Runs can normalize against it. + +The ceremony starts sample-in and ends sample-in (the `flats` body +restores), and ends shutter-closed (matching the `flat_baseline` sibling +and recipes.md return-to-safe). The sample transits the live beam during +both the flat retraction and the restore (the shutter closes only after +the flats step), which follows TomoScan's open-beam practice at 2-BM +(SBS as the per-scan fast shutter); confirm before any live wiring. + +## Stand-in PVs (illustrative-pending-staff) + +The soft IOC carries generic test PVs, NOT production 2-BM addresses. +This mapping is illustrative and MUST be confirmed with staff before any +live-EPICS wiring: + + - shutter -> `long_value` (0 = closed, 1 = open). The real station + shutter is a PSS-owned categorical leaf (S02BM-PSS:SBS family) with + an INVERTED sense; its leaf name and closed-code are unconfirmed and + safety-load-bearing, so this scenario uses a neutral binary stand-in. + - sample-out axis -> `double_value` (the SampleTop_X analog). The real + retract axis, clearance, and any theta-park coupling are unconfirmed. + - detector -> `cam1` (the areaDetector ADCore PV family). + +Frame counts and dwell are illustrative (tiny, to keep the test fast); +real per-campaign values are operator-bound. + +## What this scenario surfaces (gap-finding intent) + + - **The ceremony is a modeled Recipe, not an inline step list.** The + darks-then-flats template lives in a Recipe realizing the acquisition + Capability; conduct re-expands the pinned template. This is the + artifact the design lock specified. + - **The save-and-restore needs a body today.** `flats` brackets a + collect with an absolute read-then-restore because conduct steps are + static (no runtime variable binding). Conduct variable binding is the + designated next design; it would retire the body. + - **Conducted provenance flows to the artifact.** The Dataset carries + `producing_actuation_kind="Simulated"` derived from the conduct, the + fact that gates `promote_dataset` later. A live (non-simulated) + conduct would carry `Physical` and clear that gate. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from collections import Counter +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.data.features.register_dataset import RegisterDataset +from cora.data.features.register_dataset import bind as bind_register_dataset +from cora.operation.acquisitions import collect +from cora.operation.adapters.control_port_registry import ControlPortRegistry +from cora.operation.adapters.epics_ca_control_port import EpicsCaControlPort +from cora.operation.adapters.in_memory_recipe_expander import InMemoryRecipeExpander +from cora.operation.aggregates.procedure import PostgresActivityStore +from cora.operation.conductor import Conductor, InMemoryActionRegistry +from cora.operation.features.abort_procedure import bind as bind_abort +from cora.operation.features.append_activities import bind as bind_append +from cora.operation.features.complete_procedure import bind as bind_complete +from cora.operation.features.conduct_procedure import ConductProcedure +from cora.operation.features.conduct_procedure import bind as bind_conduct +from cora.operation.features.register_procedure_from_recipe import RegisterProcedureFromRecipe +from cora.operation.features.register_procedure_from_recipe import bind as bind_register_from_recipe +from cora.operation.features.start_procedure import bind as bind_start +from cora.operation.ports.control_port import ActuationKind +from cora.operation.staging import flats +from cora.recipe.aggregates.recipe import ( + RecipeActionStep, + RecipeCheckStep, + RecipeSetpointStep, +) +from cora.recipe.features.define_recipe import DefineRecipe +from cora.recipe.features.define_recipe import bind as bind_define_recipe +from tests.integration._helpers import build_postgres_deps, seed_capability_postgres + +_NOW = datetime(2026, 6, 22, 10, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-0000020e0099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000020e00aa") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-0000020e0c01") + +# Illustrative-pending-staff stand-in codes (see module docstring). +_SHUTTER_CLOSED = 0 +_SHUTTER_OPEN = 1 +_DARK_FRAMES = 3 +_FLAT_FRAMES = 3 +_DWELL_S = 0.05 +_CLEARANCE_MM = 5.0 + + +@pytest.mark.integration +async def test_normalization_baseline_recipe_conducts_darks_and_flats_against_softioc( + db_pool: asyncpg.Pool, + softioc: str, +) -> None: + """Define the ceremony Recipe, register a Procedure from it, conduct it to + Completed against the soft IOC, then register the baseline Dataset with the + conduct's Simulated provenance derived onto it.""" + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(80)]) + + shutter = f"{softioc}long_value" + axis = f"{softioc}double_value" + detector = f"{softioc}cam1" + + # ----- Recipe BC: the acquisition Capability + the ceremony Recipe ----- + # + # The Recipe realizes the EXISTING cora.capability.acquisition (seeded + # with both executor shapes; register-from-recipe requires Procedure). + # The template is all-literal (no BindingRef), so no parameters_schema + # or operator bindings are needed for this calibration ceremony. + await seed_capability_postgres( + deps.event_store, + _CAPABILITY_ID, + code="cora.capability.acquisition", + name="Acquisition", + ) + recipe_id = await bind_define_recipe(deps)( + DefineRecipe( + name="2BM_normalization_baseline_recipe", + capability_id=_CAPABILITY_ID, + steps=( + # darks: shutter closed, then collect + RecipeSetpointStep(address=shutter, value=_SHUTTER_CLOSED, verify=True), + RecipeCheckStep( + address=shutter, criterion={"kind": "equals", "expected": _SHUTTER_CLOSED} + ), + RecipeActionStep( + name="collect", + params={ + "detector": detector, + "trigger_mode": "Internal", + "repetitions": _DARK_FRAMES, + "dwell": _DWELL_S, + }, + ), + # flats: shutter open, then retract sample, collect, restore + RecipeSetpointStep(address=shutter, value=_SHUTTER_OPEN, verify=True), + RecipeCheckStep( + address=shutter, criterion={"kind": "equals", "expected": _SHUTTER_OPEN} + ), + RecipeActionStep( + name="flats", + params={ + "detector": detector, + "trigger_mode": "Internal", + "axis": axis, + "clearance": _CLEARANCE_MM, + "repetitions": _FLAT_FRAMES, + "dwell": _DWELL_S, + }, + ), + # return to safe: shutter closed + RecipeSetpointStep(address=shutter, value=_SHUTTER_CLOSED, verify=True), + RecipeCheckStep( + address=shutter, criterion={"kind": "equals", "expected": _SHUTTER_CLOSED} + ), + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ----- Operation BC: register a Procedure from the Recipe (lands Defined) ----- + expander = InMemoryRecipeExpander() + procedure_id = await bind_register_from_recipe(deps, expansion_port=expander)( + RegisterProcedureFromRecipe( + name="2-BM normalization baseline (darks + flats, illustrative campaign)", + kind="normalization_baseline", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe_id, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ----- Conduct: the handler re-expands the pinned recipe + drives the soft IOC ----- + # + # The soft IOC is a declared simulator (a real CA speaker that is not + # real hardware); routing through the registry with is_simulated=True is + # what makes the conduct observe Simulated. + port = EpicsCaControlPort() + registry = ControlPortRegistry() + registry.register(softioc, port, is_simulated=True) + step_store = PostgresActivityStore(db_pool) + conductor = Conductor( + control_port=registry, + append_step=bind_append(deps, step_store=step_store), + clock=deps.clock, + id_generator=deps.id_generator, + action_registry=InMemoryActionRegistry({"collect": collect, "flats": flats}), + start_procedure=bind_start(deps), + complete_procedure=bind_complete(deps), + abort_procedure=bind_abort(deps), + ) + conduct = bind_conduct(deps, conductor=conductor, expansion_port=expander) + + try: + # Recipe-driven: caller steps are empty; the handler re-expands the + # pinned template (non-empty caller steps are forbidden here). + result = await conduct( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + finally: + await registry.aclose() + + # ----- Conduct outcome: all eight steps ran, conduct observed Simulated ----- + + assert result.succeeded is True + assert result.completed_count == 8 + assert result.actuation_kind == ActuationKind.SIMULATED.value + + # ----- Procedure FSM stream: Defined -> Running -> (logbook) -> Completed ----- + + events, _ = await deps.event_store.load("Procedure", procedure_id) + event_types = [e.event_type for e in events] + assert event_types[0] == "ProcedureRegistered" + # the recipe-driven genesis pins a template-expansion provenance event + assert "RecipeExpansionRecorded" in event_types + assert "ProcedureStarted" in event_types + assert event_types[-1] == "ProcedureCompleted" + + # ----- Journal: eight logical step entries + five pre-effect markers ----- + # + # The clock is frozen so all entries share sampled_at; assert on the + # order-independent multiset AND the clock/id-independent step_index set + # (closing the kind-preserving-misorder gap). Setpoint + action are + # side-effecting (one pre-effect in_flight marker each); checks are pure + # reads (no marker). Eight steps: 3 setpoints (idx 0,3,6), 3 checks + # (idx 1,4,7), 2 actions (idx 2,5); markers on the 5 side-effecting ones. + async with db_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT step_kind, payload FROM entries_operation_procedure_activities " + "WHERE procedure_id = $1", + procedure_id, + ) + logical = [r for r in rows if r["payload"]["result"] != "in_flight"] + markers = [r for r in rows if r["payload"]["result"] == "in_flight"] + assert Counter(r["step_kind"] for r in logical) == {"setpoint": 3, "check": 3, "action": 2} + assert {r["payload"]["step_index"] for r in logical} == {0, 1, 2, 3, 4, 5, 6, 7} + assert {r["payload"]["step_index"] for r in markers} == {0, 2, 3, 5, 6} + + # ----- Data BC: the normalization baseline Dataset (terminal-gated) ----- + # + # producing_procedure_id links the artifact to the conduct and exercises + # the register_dataset terminal gate (the Procedure is Completed, so it + # passes). subject_id=None is the calibration idiom (no sample Subject). + dataset_id = await bind_register_dataset(deps)( + RegisterDataset( + name="2BM_normalization_baseline_2026-06-22", + uri="file:///data/2bm/2026-06/normalization_baseline.h5", + checksum_algorithm="sha256", + checksum_value="a" * 64, + byte_size=2448 * 2048 * 2 * (_DARK_FRAMES + _FLAT_FRAMES), + media_type="application/x-hdf5", + conforms_to=frozenset(), + producing_procedure_id=procedure_id, + producing_run_id=None, + subject_id=None, + derived_from=frozenset(), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + dataset_events, _ = await deps.event_store.load("Dataset", dataset_id) + assert [e.event_type for e in dataset_events] == ["DatasetRegistered"] + payload = dataset_events[0].payload + assert payload["producing_procedure_id"] == str(procedure_id) + assert payload["subject_id"] is None + # The conduct's Simulated provenance is derived onto the Dataset (the + # fact promote_dataset gates on). + assert payload["producing_actuation_kind"] == ActuationKind.SIMULATED.value diff --git a/apps/api/tests/integration/test_staging_against_softioc_postgres.py b/apps/api/tests/integration/test_staging_against_softioc_postgres.py new file mode 100644 index 0000000000..39759a4202 --- /dev/null +++ b/apps/api/tests/integration/test_staging_against_softioc_postgres.py @@ -0,0 +1,165 @@ +"""Integration test: the `flats` staging body + Conductor + EpicsCaControlPort + Postgres. + +Mirrors `test_acquisitions_against_softioc_postgres.py` for the sample- +staging composition `flats` (a `collect` cycle bracketed by an axis +save-and-restore). Proves the read-offcentre-collect-restore sequence +against real Channel Access framing: the axis is driven off its saved +position for the capture and restored afterwards, verified by re-reading +the PV with a fresh port. + +`flats` lives in `cora.operation.staging`, not the scan-primitives module, +because it is a composition (collect + sample staging), not an acquisition +motion. See that module's docstring for the conduct variable-binding gap +this body stands in for. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.infrastructure.event_envelope import to_new_event +from cora.operation.adapters.epics_ca_control_port import EpicsCaControlPort +from cora.operation.aggregates.procedure import ( + PostgresActivityStore, + ProcedureRegistered, + event_type_name, + to_payload, +) +from cora.operation.conductor import ActionStep, Conductor, InMemoryActionRegistry +from cora.operation.features.abort_procedure import bind as bind_abort +from cora.operation.features.append_activities import bind as bind_append +from cora.operation.features.complete_procedure import bind as bind_complete +from cora.operation.features.start_procedure import bind as bind_start +from cora.operation.staging import flats +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 22, 9, 30, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-0000020f0099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000020f00aa") + +_ACTION_REGISTRY = InMemoryActionRegistry({"flats": flats}) + + +async def _seed_defined_procedure(deps_event_store: object, procedure_id: UUID) -> None: + """Seed a single ProcedureRegistered event so the Procedure exists in Defined.""" + registered = ProcedureRegistered( + procedure_id=procedure_id, + name="2-BM staging smoke", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + occurred_at=_NOW, + ) + stored = to_new_event( + event_type=event_type_name(registered), + payload=to_payload(registered), + occurred_at=registered.occurred_at, + event_id=UUID("01900000-0000-7000-8000-0000020f0001"), + command_name="RegisterProcedure", + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + ) + await deps_event_store.append( # type: ignore[attr-defined] + stream_type="Procedure", + stream_id=procedure_id, + expected_version=0, + events=[stored], + ) + + +@pytest.mark.integration +async def test_conductor_runs_flats_action_retracts_then_restores_axis_against_softioc( + db_pool: asyncpg.Pool, + softioc: str, +) -> None: + """`flats` reads the axis, drives off-centre by clearance, collects, restores.""" + procedure_id = UUID("01900000-0000-7000-8000-0000020f0100") + started_event_id = UUID("01900000-0000-7000-8000-0000020f0101") + logbook_id = UUID("01900000-0000-7000-8000-0000020f0102") + open_event_id = UUID("01900000-0000-7000-8000-0000020f0103") + flats_marker_id = UUID("01900000-0000-7000-8000-0000020f0104") + flats_step_id = UUID("01900000-0000-7000-8000-0000020f0105") + completed_event_id = UUID("01900000-0000-7000-8000-0000020f0106") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + started_event_id, + logbook_id, + open_event_id, + flats_marker_id, + flats_step_id, + completed_event_id, + ], + ) + await _seed_defined_procedure(deps.event_store, procedure_id) + step_store = PostgresActivityStore(db_pool) + control_port = EpicsCaControlPort() + conductor = Conductor( + control_port=control_port, + append_step=bind_append(deps, step_store=step_store), + clock=deps.clock, + id_generator=deps.id_generator, + action_registry=_ACTION_REGISTRY, + start_procedure=bind_start(deps), + complete_procedure=bind_complete(deps), + abort_procedure=bind_abort(deps), + ) + + try: + # The aligned-centre position the body must read then restore to. + await control_port.write(f"{softioc}double_value", 12.5, wait=True) + result = await conductor.conduct( + procedure_id=procedure_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + steps=( + ActionStep( + name="flats", + params={ + "detector": f"{softioc}cam1", + "trigger_mode": "Internal", + "axis": f"{softioc}double_value", + "clearance": 5.0, + "repetitions": 3, + "dwell": 0.05, + }, + ), + ), + ) + finally: + await control_port.aclose() + + assert result.succeeded is True + assert result.completed_count == 1 + + async with db_pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT payload + FROM entries_operation_procedure_activities + WHERE procedure_id = $1 AND payload->>'result' IS DISTINCT FROM 'in_flight' + """, + procedure_id, + ) + payload = rows[0]["payload"] + assert payload["name"] == "flats" + assert payload["result"] == "ok" + result_data = payload["result_data"] + assert result_data["axis"] == f"{softioc}double_value" + assert result_data["saved_value"] == pytest.approx(12.5) + assert result_data["offcenter_target"] == pytest.approx(17.5) + assert result_data["collect"]["repetitions_requested"] == 3 + + # The axis is back at the aligned centre the body saved (the restore landed). + fresh_port = EpicsCaControlPort() + try: + axis_final = await fresh_port.read(f"{softioc}double_value") + assert axis_final.value == pytest.approx(12.5) + finally: + await fresh_port.aclose() diff --git a/apps/api/tests/unit/operation/test_flats_action_body.py b/apps/api/tests/unit/operation/test_flats_action_body.py new file mode 100644 index 0000000000..e7222bb6a2 --- /dev/null +++ b/apps/api/tests/unit/operation/test_flats_action_body.py @@ -0,0 +1,369 @@ +"""Unit tests for the `flats` action body and its `FlatsParams` schema. + +`flats` brackets one `collect` cycle with a save-and-restore of the +sample axis: read the current position, drive off the beam centre by +`clearance` (an absolute write, since CORA has no relative-move +primitive), run the collect cycle, then restore the axis to the saved +position. Tests verify that bracketing contract (retract before collect, +restore after, signed clearance, and the no-rollback-on-failure +behaviour) without re-asserting the inner collect contract that +`test_collect_action_body.py` covers. + + Params validation (FlatsParams.model_validate): + - Inherited CollectParams shape passes through (axis + clearance added) + - clearance is required + - clearance must be nonzero (zero leaves the sample in the beam) + - Inherited polarity-required rule still applies (ExternalEdge) + - clearance carries the canonical {system, code} mm unit annotation + + Body behaviour (flats called directly with ActionContext): + - Reads the axis, drives saved+clearance, collects, restores saved + - Final axis value is the saved value (restore landed) + - Negative clearance retracts the other direction + - Write order: axis off-centre, then the collect cycle, then axis restore + - A collect failure propagates AND leaves the axis retracted (no restore) + - A non-numeric axis read maps to ControlValueCoercionError, no move + - A non-finite (NaN/inf) axis read maps to ControlValueCoercionError + + End-to-end via Conductor: + - InMemoryActionRegistry({"flats": flats}) + ActionStep produces + ConductorResult.succeeded=True with the expected payload shape +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from pydantic import ValidationError + +from cora.infrastructure.ports.clock import FakeClock +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.operation.adapters.in_memory_control_port import InMemoryControlPort +from cora.operation.conductor import ( + ActionContext, + ActionStep, + Conductor, + InMemoryActionRegistry, +) +from cora.operation.ports.control_port import ( + ControlNotConnectedError, + ControlValueCoercionError, + Reading, +) +from cora.operation.staging import FlatsParams, flats + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping + + from cora.operation.features.append_activities.command import ( + AppendProcedureActivities, + ) + +_FIXED_NOW = datetime(2026, 6, 22, 11, 0, 0, tzinfo=UTC) +_DETECTOR = "2bma:cam1" +_AXIS = "2bma:sample:x" + + +def _seed_detector_and_axis(port: InMemoryControlPort, *, axis_value: float = 12.5) -> None: + """Seed the AD PVs `collect` touches plus the axis `flats` reads + restores.""" + port.simulate_connect(f"{_DETECTOR}:TriggerMode") + port.simulate_connect(f"{_DETECTOR}:AcquireTime") + port.simulate_connect(f"{_DETECTOR}:NumImages") + port.simulate_connect(f"{_DETECTOR}:Acquire") + port.simulate_connect(_AXIS) + port.set_reading( + _AXIS, + Reading(value=axis_value, kind="Scalar", quality="Good", sampled_at=_FIXED_NOW), + ) + port.set_reading( + f"{_DETECTOR}:Acquire_RBV", + Reading(value=0, kind="Scalar", quality="Good", sampled_at=_FIXED_NOW), + ) + port.set_reading( + f"{_DETECTOR}:DetectorState_RBV", + Reading(value="Idle", kind="Categorical", quality="Good", sampled_at=_FIXED_NOW), + ) + + +def _ctx(port: InMemoryControlPort, params: Mapping[str, Any]) -> ActionContext: + return ActionContext( + control_port=port, + clock=FakeClock(_FIXED_NOW), + params=params, + ) + + +def _params(**overrides: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "detector": _DETECTOR, + "trigger_mode": "Internal", + "axis": _AXIS, + "clearance": 5.0, + "dwell": 0.05, + } + base.update(overrides) + return base + + +# --- FlatsParams validation -------------------------------------------- + + +@pytest.mark.unit +def test_flats_params_internal_with_axis_and_clearance_accepted() -> None: + params = FlatsParams.model_validate(_params()) + assert params.axis == _AXIS + assert params.clearance == 5.0 + + +@pytest.mark.unit +def test_flats_params_clearance_required() -> None: + bad = _params() + del bad["clearance"] + with pytest.raises(ValidationError): + FlatsParams.model_validate(bad) + + +@pytest.mark.unit +def test_flats_params_zero_clearance_rejected() -> None: + """Zero clearance would leave the sample in the beam -> a flat with the + sample present, silently corrupting downstream normalization.""" + with pytest.raises(ValidationError, match="nonzero"): + FlatsParams.model_validate(_params(clearance=0.0)) + + +@pytest.mark.unit +def test_flats_params_inherits_collect_external_edge_constraint() -> None: + """FlatsParams reuses CollectParams's polarity-required rule.""" + with pytest.raises(ValidationError, match="polarity required"): + FlatsParams.model_validate(_params(trigger_mode="ExternalEdge", source="2bma:PCOMP1.OUT")) + + +@pytest.mark.unit +def test_flats_params_json_schema_carries_unit_annotation_on_clearance() -> None: + schema = FlatsParams.model_json_schema() + clearance_schema = schema["properties"]["clearance"] + assert clearance_schema["unit"] == {"system": "udunits", "code": "mm"} + + +# --- flats body behaviour ---------------------------------------------- + + +@pytest.mark.unit +async def test_flats_reads_axis_drives_offcenter_collects_and_restores() -> None: + """Positive clearance: retract to saved+clearance, collect, restore to saved.""" + port = InMemoryControlPort() + _seed_detector_and_axis(port, axis_value=12.5) + result = await flats(_ctx(port, _params(clearance=5.0))) + + assert result["axis"] == _AXIS + assert result["saved_value"] == 12.5 + assert result["offcenter_target"] == 17.5 + assert result["clearance"] == 5.0 + assert result["collect"]["trigger_mode"] == "Internal" + assert result["collect"]["detector_state_final"] == "Idle" + # The axis ended back at the saved aligned-centre position (restore landed). + assert (await port.read(_AXIS)).value == 12.5 + + +@pytest.mark.unit +async def test_flats_negative_clearance_retracts_other_direction() -> None: + port = InMemoryControlPort() + _seed_detector_and_axis(port, axis_value=12.5) + result = await flats(_ctx(port, _params(clearance=-5.0))) + assert result["offcenter_target"] == 7.5 + assert (await port.read(_AXIS)).value == 12.5 + + +@pytest.mark.unit +async def test_flats_write_order_offcenter_then_collect_then_restore() -> None: + """Retract write precedes the collect cycle; restore write follows it.""" + + @dataclass + class _RecordingPort: + delegate: InMemoryControlPort + writes: list[tuple[str, Any]] = field(default_factory=list[tuple[str, Any]]) + + async def write( + self, + address: str, + value: Any, + *, + wait: bool = True, + timeout_s: float = 30.0, + ) -> None: + self.writes.append((address, value)) + await self.delegate.write(address, value, wait=wait, timeout_s=timeout_s) + + async def read(self, address: str) -> Reading: + return await self.delegate.read(address) + + def subscribe(self, address: str) -> AsyncIterator[Reading]: + return self.delegate.subscribe(address) + + inner = InMemoryControlPort() + _seed_detector_and_axis(inner, axis_value=12.5) + port = _RecordingPort(delegate=inner) + await flats( + _ctx( + port, # type: ignore[arg-type] + _params(clearance=5.0), + ) + ) + addresses = [addr for addr, _ in port.writes] + assert addresses == [ + _AXIS, # retract to off-centre + f"{_DETECTOR}:TriggerMode", + f"{_DETECTOR}:AcquireTime", + f"{_DETECTOR}:NumImages", + f"{_DETECTOR}:Acquire", + _AXIS, # restore to saved + ] + axis_values = [value for addr, value in port.writes if addr == _AXIS] + assert axis_values == [17.5, 12.5] + + +@pytest.mark.unit +async def test_flats_collect_failure_leaves_axis_retracted_without_restore() -> None: + """A collect failure propagates and the axis stays off-centre (no rollback). + + Matches the collect / discrete / continuous no-try/finally contract: + the Conductor records the step failure and the operator reconciles the + retracted axis. Off-centre is the sample-out (out-of-beam) position, an + acceptable fault state. + """ + port = InMemoryControlPort() + _seed_detector_and_axis(port, axis_value=12.5) + port.simulate_disconnect(f"{_DETECTOR}:TriggerMode") # collect's first write fails + with pytest.raises(ControlNotConnectedError): + await flats(_ctx(port, _params(clearance=5.0))) + # The retract landed; the restore never ran. + assert (await port.read(_AXIS)).value == 17.5 + + +@pytest.mark.unit +async def test_flats_non_numeric_axis_read_raises_value_coercion_without_moving() -> None: + """A non-numeric axis read maps to a Conductor-recordable error, no move. + + The saved+clearance arithmetic needs a number; a categorical/string + read is mapped to ControlValueCoercionError (which the Conductor + catches and records as a structured failure) instead of a bare + TypeError that would escape the Conductor. The read precedes any + write, so the axis never moves. + """ + port = InMemoryControlPort() + _seed_detector_and_axis(port) + port.set_reading( + _AXIS, + Reading(value="parked", kind="Categorical", quality="Good", sampled_at=_FIXED_NOW), + ) + with pytest.raises(ControlValueCoercionError): + await flats(_ctx(port, _params(clearance=5.0))) + # Nothing actuated: the axis still reads the same categorical value. + assert (await port.read(_AXIS)).value == "parked" + + +@pytest.mark.unit +async def test_flats_non_finite_axis_read_raises_value_coercion() -> None: + """A NaN/inf axis read maps to ControlValueCoercionError, not a NaN write. + + NaN/inf is a float (passes the isinstance check) but `saved + clearance` + would carry it into an absolute write of an undefined setpoint. The + isfinite guard turns it into a Conductor-recordable failure before any + move (the read precedes the write). + """ + port = InMemoryControlPort() + _seed_detector_and_axis(port) + port.set_reading( + _AXIS, + Reading(value=float("nan"), kind="Scalar", quality="Good", sampled_at=_FIXED_NOW), + ) + with pytest.raises(ControlValueCoercionError): + await flats(_ctx(port, _params(clearance=5.0))) + + +# --- end-to-end via Conductor ------------------------------------------ + + +@dataclass +class _AppendCall: + command: AppendProcedureActivities + principal_id: UUID + correlation_id: UUID + causation_id: UUID | None + surface_id: UUID + + +@dataclass +class _FakeAppendStep: + calls: list[_AppendCall] = field(default_factory=list[_AppendCall]) + + async def __call__( + self, + command: AppendProcedureActivities, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> int: + self.calls.append( + _AppendCall( + command=command, + principal_id=principal_id, + correlation_id=correlation_id, + causation_id=causation_id, + surface_id=surface_id, + ) + ) + return len(command.entries) + + +@dataclass +class _SequenceIdGenerator: + ids: list[UUID] + _index: int = 0 + + def new_id(self) -> UUID: + if self._index >= len(self.ids): + raise RuntimeError("FixedIdGenerator exhausted") + out = self.ids[self._index] + self._index += 1 + return out + + +@pytest.mark.unit +async def test_conductor_executes_flats_action_and_records_step_entry() -> None: + """Conductor + registered `flats` body + ActionStep -> success + recorded evidence.""" + port = InMemoryControlPort() + _seed_detector_and_axis(port, axis_value=12.5) + appender = _FakeAppendStep() + registry = InMemoryActionRegistry({"flats": flats}) + conductor = Conductor( + control_port=port, + append_step=appender, + clock=FakeClock(_FIXED_NOW), + id_generator=_SequenceIdGenerator([uuid4(), uuid4()]), + action_registry=registry, + ) + result = await conductor.execute( + procedure_id=uuid4(), + principal_id=uuid4(), + correlation_id=uuid4(), + steps=(ActionStep(name="flats", params=_params(clearance=5.0)),), + ) + assert result.succeeded is True + assert result.completed_count == 1 + # calls[0] is the pre-effect in-flight marker; calls[1] is the outcome. + assert appender.calls[0].command.entries[0].payload["result"] == "in_flight" + entry = appender.calls[1].command.entries[0] + assert entry.step_kind == "action" + assert entry.payload["name"] == "flats" + assert entry.payload["result"] == "ok" + result_data = entry.payload["result_data"] + assert result_data["axis"] == _AXIS + assert result_data["saved_value"] == 12.5 + assert result_data["offcenter_target"] == 17.5 diff --git a/catalog/catalog.yaml b/catalog/catalog.yaml index 73e686dd18..b8d7670684 100644 --- a/catalog/catalog.yaml +++ b/catalog/catalog.yaml @@ -91,7 +91,7 @@ capabilities: - code: cora.capability.acquisition name: Acquisition description: "The bare frame-stack capture primitive (NeXus NXscan analogue), beneath the specific tomography technique." - executor_shapes: [Method] + executor_shapes: [Method, Procedure] - code: cora.capability.alignment name: Alignment description: "Iterative tuning toward a target metric; each Method is a step in the rotation-axis alignment chain." diff --git a/docs/architecture/modules/operation/index.md b/docs/architecture/modules/operation/index.md index 747af37b79..4d1b7bcf15 100644 --- a/docs/architecture/modules/operation/index.md +++ b/docs/architecture/modules/operation/index.md @@ -50,7 +50,7 @@ Two anti-patterns this rules out, with the corpus already normalized to match: - **Verb-phrase-first.** `center_and_close_slits` -> `slit_centering` (fold the steps into one operation noun); the coordinated moves `set_energy` -> `energy_setting` and `switch_to_mono` / `switch_to_pink` -> a single `beam_mode_change` (target mode as a parameter, not two verb-first kinds). - **Act named for its value.** A measuring act is a `*_characterization` (`blade_throw_characterization`); the value it produces is a Calibration with a value-noun (`blade_throw_scale`), or a new revision of an existing curve (`energy_characterization` re-saves the `energy_position_curve`), never a procedure named `*_calibration`. -Narrow carve-outs: whole-system milestones with no single subject keep a bare noun phrase (`first_light`), and capture-and-store procedures use `_baseline` (`dark_baseline`, `flat_baseline`) where the trailing noun is the produced artifact. The convention is enforced by `tests/architecture/test_procedure_kind_naming.py`, which scans every `RegisterProcedure(kind=...)` literal against an approved operation-noun set plus the carve-out allowlist. +Narrow carve-outs: whole-system milestones with no single subject keep a bare noun phrase (`first_light`), and capture-and-store procedures use `_baseline` or `_baseline` (`dark_baseline`, `flat_baseline`, and the combined darks-plus-flats `normalization_baseline`) where the trailing noun is the produced artifact (the capture condition for darks and flats, the downstream purpose for normalization). The convention is enforced by `tests/architecture/test_procedure_kind_naming.py`, which scans every `RegisterProcedure(kind=...)` and `RegisterProcedureFromRecipe(kind=...)` literal (the recipe-driven registration path carries the same `kind` field) against an approved operation-noun set plus the carve-out allowlist. ## FSM