From b2fb1f136e7d0cbf3a31ecaa75e4d25fcfb57ef3 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Wed, 5 Aug 2026 10:41:39 +0000 Subject: [PATCH] Add ess.reduce.spec: minimal implementation-independent workflow specs WorkflowSpec describes a workflow's user-facing interface (identity, title/description, one pydantic params model, structural output descriptions) without factories, sciline keys, or registries, so generic UIs can be generated from it regardless of where compute happens. serialize() projects one-way onto SerializedWorkflowSpec (params as JSON Schema) for cross-process consumers; authoritative validation stays with the process owning the model class. Includes a scipp-free shared parameter vocabulary (unit enums, range/edges models with cross-field validation) with scipp conversions quarantined in spec.conversions, and ADR 0001 recording the design and its rationale (see scipp/ess#653, scipp/esslivedata#889). Adds pydantic as an essreduce dependency. The existing ess.reduce.parameter/workflow machinery is superseded but untouched; removal is a later hard break. Co-Authored-By: Claude Fable 5 --- .../adr/0001-minimal-workflow-spec.md | 158 ++++++++++++++ .../essreduce/docs/developer/adr/index.md | 17 ++ packages/essreduce/docs/developer/index.md | 1 + packages/essreduce/pyproject.toml | 1 + .../essreduce/src/ess/reduce/spec/__init__.py | 24 +++ .../src/ess/reduce/spec/_workflow_spec.py | 165 +++++++++++++++ .../src/ess/reduce/spec/conversions.py | 31 +++ .../src/ess/reduce/spec/parameters.py | 195 ++++++++++++++++++ .../essreduce/tests/spec/conversions_test.py | 32 +++ .../essreduce/tests/spec/parameters_test.py | 66 ++++++ .../tests/spec/workflow_spec_test.py | 109 ++++++++++ 11 files changed, 799 insertions(+) create mode 100644 packages/essreduce/docs/developer/adr/0001-minimal-workflow-spec.md create mode 100644 packages/essreduce/docs/developer/adr/index.md create mode 100644 packages/essreduce/src/ess/reduce/spec/__init__.py create mode 100644 packages/essreduce/src/ess/reduce/spec/_workflow_spec.py create mode 100644 packages/essreduce/src/ess/reduce/spec/conversions.py create mode 100644 packages/essreduce/src/ess/reduce/spec/parameters.py create mode 100644 packages/essreduce/tests/spec/conversions_test.py create mode 100644 packages/essreduce/tests/spec/parameters_test.py create mode 100644 packages/essreduce/tests/spec/workflow_spec_test.py diff --git a/packages/essreduce/docs/developer/adr/0001-minimal-workflow-spec.md b/packages/essreduce/docs/developer/adr/0001-minimal-workflow-spec.md new file mode 100644 index 000000000..b380d5e0b --- /dev/null +++ b/packages/essreduce/docs/developer/adr/0001-minimal-workflow-spec.md @@ -0,0 +1,158 @@ +# ADR 0001: Minimal implementation-independent workflow specifications + +- Status: proposed +- Deciders: Simon +- Date: 2026-08-05 + +## Context + +Three mechanisms currently describe workflow interfaces to users, with +overlapping purpose and no shared shape: + +- `ess.reduce.parameter` / `ess.reduce.workflow`: per-sciline-key `Parameter` + dataclasses in a global registry, with parameters discovered by walking the + pipeline graph from selected outputs. Drives the ipywidgets GUI. +- `ess.livedata.config.workflow_spec.WorkflowSpec`: one pydantic params model + per workflow, plus output descriptions, driving the live-data dashboard. +- `ess.nmx.configurations`: standalone pydantic models for batch reduction. + +Consolidation was analyzed at length in +[scipp/ess#653](https://github.com/scipp/ess/issues/653) and +[scipp/esslivedata#889](https://github.com/scipp/esslivedata/issues/889). Two +earlier attempts stalled, both for the same reason: scope. A universal +`ess.schemas` catalog ("all workflows for all instruments, imported by +everyone") turned shared conventions into a cross-team release-coordination +problem; a rewrite of the essreduce widget layer +([scipp/ess#689](https://github.com/scipp/ess/pull/689)) kept sciline keys, +workflow factories, and widget concerns inside the spec, so the spec could not +outlive or precede any particular implementation. + +The goal is the minimal layer that lets a *generic* user interface — ipywidgets, +a web dashboard, or a command-line tool — be generated from a workflow +description alone. Compute is deliberately abstracted away: the same spec must +make sense whether the workflow runs as a local sciline pipeline, behind a web +service, or as a cluster job. Compute is not part of this work, but it shapes +the design: nothing implementation-bound may appear in the spec. + +## Decision + +A new module `ess.reduce.spec` defines the spec layer. Its only dependency +beyond the standard library is pydantic (a new essreduce dependency); the one +scipp-facing piece is quarantined in a submodule. + +### The spec is pure interface: no factory, no keys, no registry + +`WorkflowSpec` holds identity (`name`, `version`), display metadata (`title`, +`description`, both mandatory), a params model, and output descriptions. +Nothing else. In particular it holds *no* workflow factory and *no* sciline +keys: a spec describes *what a user can configure and what they get back*, not +how it is computed. Binding a spec to an executor — conceptually a mapping from +spec identity to `Callable[[BaseModel], Mapping[str, Any]]`, or a remote +service holding the same spec — is a parallel mechanism, intentionally +undefined here. This is what keeps the spec valid across local, service, and +cluster execution. + +How specs are enumerated (module-level tuples, entry points, esslivedata's +per-instrument registration) is likewise out of scope. Any mechanism works +against the same spec type; prescribing one here would recreate the catalog +problem that sank the `ess.schemas` plan. + +### One params model per workflow + +Parameters are a single pydantic model class per workflow +(`params: type[BaseModel]`), not per-key entries in a registry. This enables +cross-parameter validation, gives JSON Schema for free, and removes the +implementation coupling of key-addressed parameters. The graph-derived +"select outputs, then see only relevant parameters" feature of +`ess.reduce.workflow.get_parameters` does not survive: it treats output +selection as workflow slicing, which only the sciline implementation can +express. If output-dependent parameter sets are needed, they are distinct +workflows (distinct specs). + +The field defaults to `NoParams` (a closed model with no fields), so consumers +never branch on params being absent, and sending parameters to a workflow that +takes none is a validation error rather than silently ignored. + +### Two forms, one-way projection + +`WorkflowSpec` is the in-process form: it holds the params model *class*, so +same-process consumers (ipywidgets, a CLI wrapping a local pipeline) get full +pydantic validation including custom validators. `spec.serialize()` projects +onto `SerializedWorkflowSpec`, a plain-data pydantic model with params as JSON +Schema (`model_json_schema()`), which round-trips through JSON and is what a +service announces to remote consumers. + +There is deliberately no inverse. Validators do not survive JSON Schema, so a +deserialized spec would be a lie about its own validation. Instead, validation +authority sits with the process owning the model class: in-process UIs validate +directly; remote UIs validate optimistically against the schema and the owning +service accepts or rejects authoritatively. This matches the +announcement-as-contract design adopted for esslivedata in +[scipp/esslivedata#889](https://github.com/scipp/esslivedata/issues/889): the +serialized spec is the entire cross-process surface, and where a model class is +*defined* is invisible to consumers. + +### Identity is `name` + `version`; scoping is the enumerator's problem + +No `instrument` field and no `WorkflowId` class at this level. Instrument is +meaningless for technique-level batch workflows, and a spec cannot guarantee +global uniqueness of anything — only the context that enumerates or deploys +specs can. esslivedata keeps keying workflows by `(instrument, name, version)`, +supplying the instrument from its registration context. Data-provenance +identity (which spec, params, and input datasets produced a dataset) similarly +composes spec identity with deployment context; the spec's contribution is +being serializable and versioned. + +### Outputs are declared, structurally, without scipp + +`outputs` maps output names to `OutputSpec` (mandatory title, description, +optional `ArraySpec`). `ArraySpec` describes dims, unit, and coordinate units — +plain data, so it serializes, replacing the `sc.DataArray` default-factory +templates esslivedata currently uses for plotter selection. Output *selection* +(choosing which sciline targets to compute) is not modeled: like parameter +slicing, it is an implementation notion. Declaration order is meaningful +(consumers show outputs in order, primary output first). Livedata-specific +output machinery (`OutputView`, `Temporality`, windowing) stays in esslivedata. + +### Shared parameter vocabulary, scipp-free + +`ess.reduce.spec.parameters` provides constrained unit enums and range/edges +models with cross-field validation (`stop > start`, log-scale positivity) — +the models previously duplicated between esslivedata and package-specific +code. They contain no scipp: conversion of validated values into scipp objects +(`edges_to_variable`, `range_to_variables`) lives in +`ess.reduce.spec.conversions`, imported by workflow implementations only. This +keeps the vocabulary JSON-Schema-clean and the spec layer importable without +touching scipp. Value defaults (start/stop/bin counts) are set by workflow +authors at the use site, not by the vocabulary — sensible values are a +workflow/instrument decision, and a generic default is a wrong default. + +### Convergence with esslivedata + +Explicit goal: `ess.livedata.config.workflow_spec.WorkflowSpec` eventually +inherits from this spec, adding its live-data fields (`instrument`, `group`, +`source_names`, `aux_sources`, `device_outputs`, reset flags). The base spec's +field names and semantics (`name`, `version`, `title`, `description`, +`params`) are a strict subset of esslivedata's today for exactly this reason. +The blocking difference is `outputs`: esslivedata's `sc.DataArray` templates +must first migrate to `ArraySpec` (already planned independently in +scipp/esslivedata#889). The import edge is free — the esslivedata backend +already depends on essreduce, and its dashboard is decoupled via the +serialized-spec announcement, not via imports. + +## Consequences + +- Generic UIs (including a command-line interface) can be generated from + `WorkflowSpec` alone, and from `SerializedWorkflowSpec` across process + boundaries, with no knowledge of the workflow implementation. +- essreduce gains a pydantic dependency. +- `ess.reduce.parameter`, `ess.reduce.workflow`, and the widgets built on them + are superseded and will be removed in a later hard break; they are untouched + for now. The graph-derived parameter discovery they provide is dropped, not + ported. +- `ess.nmx.configurations` and esslivedata migrate to the shared vocabulary + and spec incrementally, per package, with no coordination requirement — a + package that never migrates costs the others nothing. +- The executor binding and spec enumeration remain to be designed when a + concrete consumer needs them; the spec layer does not constrain either + beyond being addressable by `(name, version)`. diff --git a/packages/essreduce/docs/developer/adr/index.md b/packages/essreduce/docs/developer/adr/index.md new file mode 100644 index 000000000..da16b0c2f --- /dev/null +++ b/packages/essreduce/docs/developer/adr/index.md @@ -0,0 +1,17 @@ +# Architecture Decision Records + +Lightweight records of load-bearing design decisions and their rationale. Each +ADR captures one decision. Accepted text is not rewritten: corrections and +extensions land as a dated amendment section at the bottom, flagged in the +status line, so the original stays readable as the reasoning of its time. +Reversing or replacing a decision gets a new ADR that links back. Format +follows [scipp's ADR convention](https://github.com/scipp/scipp/tree/main/docs/development/adr). + +```{toctree} +--- +maxdepth: 1 +glob: true +--- + +0* +``` diff --git a/packages/essreduce/docs/developer/index.md b/packages/essreduce/docs/developer/index.md index e47b24cf0..64d3cffa4 100644 --- a/packages/essreduce/docs/developer/index.md +++ b/packages/essreduce/docs/developer/index.md @@ -14,4 +14,5 @@ getting-started coding-conventions dependency-management gui +adr/index ``` diff --git a/packages/essreduce/pyproject.toml b/packages/essreduce/pyproject.toml index cacd35069..e6f10ec66 100644 --- a/packages/essreduce/pyproject.toml +++ b/packages/essreduce/pyproject.toml @@ -32,6 +32,7 @@ dynamic = ["version"] dependencies = [ "dask>=2022.1.0", "graphviz>=0.20", + "pydantic>=2.5", "sciline>=25.11.0", "scipp>=26.3.1", "scippneutron>=26.6.0", diff --git a/packages/essreduce/src/ess/reduce/spec/__init__.py b/packages/essreduce/src/ess/reduce/spec/__init__.py new file mode 100644 index 000000000..9621ce5c2 --- /dev/null +++ b/packages/essreduce/src/ess/reduce/spec/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +""" +Implementation-independent workflow specifications for UI generation. + +See :mod:`ess.reduce.spec._workflow_spec` for the design; +ADR 0001 (docs/developer/adr) for the rationale. +""" + +from ._workflow_spec import ( + ArraySpec, + NoParams, + OutputSpec, + SerializedWorkflowSpec, + WorkflowSpec, +) + +__all__ = [ + 'ArraySpec', + 'NoParams', + 'OutputSpec', + 'SerializedWorkflowSpec', + 'WorkflowSpec', +] diff --git a/packages/essreduce/src/ess/reduce/spec/_workflow_spec.py b/packages/essreduce/src/ess/reduce/spec/_workflow_spec.py new file mode 100644 index 000000000..bb864f407 --- /dev/null +++ b/packages/essreduce/src/ess/reduce/spec/_workflow_spec.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +""" +Workflow specifications: implementation-independent workflow metadata. + +A :class:`WorkflowSpec` describes a workflow's user-facing interface — identity, +display metadata, parameters, and outputs — without reference to how or where +the workflow is computed. User interfaces (widgets, dashboards, command-line +tools) are generated from the spec alone; the binding from a spec to an +executor is a separate, parallel mechanism deliberately not defined here. + +Two forms exist, related by a one-way projection: + +* :class:`WorkflowSpec` is the in-process form. It holds the params *model + class*, so consumers in the same process get full pydantic validation, + including cross-field validators. +* :class:`SerializedWorkflowSpec` is the plain-data form produced by + :meth:`WorkflowSpec.serialize`, with params as JSON Schema. It is what a + service announces to remote consumers, which can render forms and validate + optimistically against the schema. There is intentionally no inverse: + validators do not round-trip through JSON Schema, and authoritative + validation always happens in the process owning the model class. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class NoParams(BaseModel): + """ + Params model for workflows that take no configuration. + + Workflows always have a params model, so consumers never branch on its + absence; "takes no parameters" is expressed as a model with no fields. + Extra fields are rejected so that sending params to such a workflow is an + error rather than silently ignored. + """ + + model_config = ConfigDict(extra='forbid') + + +class ArraySpec(BaseModel, frozen=True): + """ + Structural description of an array-valued workflow output. + + Describes shape-independent structure — dimensions, unit, and coordinate + units — sufficient for a consumer to prepare for the data (e.g., select a + plotter) before any has been computed. A scalar value with a unit is the + 0-d case: ``ArraySpec(dims=(), unit='counts')``. + """ + + dims: tuple[str, ...] = Field(description="Dimension names, outermost first.") + unit: str | None = Field( + default=None, description="Unit of the array values, if any." + ) + coords: dict[str, str | None] = Field( + default_factory=dict, + description="Coordinate names mapped to their units (None for unitless).", + ) + + +class OutputSpec(BaseModel, frozen=True): + """Description of a single named workflow output.""" + + title: str = Field(min_length=1, description="Display title of the output.") + description: str = Field(default='', description="Description of the output.") + array: ArraySpec | None = Field( + default=None, + description=( + "Structural description of the output data, if array-valued and known." + ), + ) + + +def _default_outputs() -> dict[str, OutputSpec]: + return {'result': OutputSpec(title='Result', description='Workflow output.')} + + +class _SpecFields(BaseModel, frozen=True): + """Metadata fields shared by both forms of the workflow spec.""" + + name: str = Field( + min_length=1, + description=( + "Machine-readable workflow identifier. Unique within the context " + "that enumerates the spec; global uniqueness is the enumerator's " + "responsibility, not the spec's." + ), + ) + version: int = Field( + ge=1, + description=( + "Version of the workflow interface. Increment on any change a " + "consumer could observe: params model, outputs, or semantics." + ), + ) + title: str = Field(min_length=1, description="Display title of the workflow.") + description: str = Field( + min_length=1, description="Description of what the workflow computes." + ) + + +class WorkflowSpec(_SpecFields, frozen=True): + """ + Implementation-independent specification of a workflow's user interface. + + Holds identity and display metadata, the pydantic model class defining the + workflow's parameters, and descriptions of its outputs. Contains no + factory, no executor, and no reference to any workflow implementation; + pairing a spec with something that computes it is a separate mechanism. + """ + + params: type[BaseModel] = Field( + default=NoParams, + description=( + "Pydantic model class defining the workflow parameters. Defaults " + "to :class:`NoParams` for workflows that take no configuration." + ), + ) + outputs: dict[str, OutputSpec] = Field( + default_factory=_default_outputs, + description=( + "Named outputs the workflow produces. Order is meaningful: " + "consumers present outputs in this order and may auto-select the " + "first, so put the primary output first." + ), + ) + + def serialize(self) -> SerializedWorkflowSpec: + """ + Project to the plain-data form with params as JSON Schema. + + The projection is one-way: pydantic validators do not survive it, so + a consumer of the serialized form can validate only optimistically. + Authoritative validation happens where the model class lives. + """ + return SerializedWorkflowSpec( + name=self.name, + version=self.version, + title=self.title, + description=self.description, + params_schema=self.params.model_json_schema(), + outputs=self.outputs, + ) + + +class SerializedWorkflowSpec(_SpecFields, frozen=True): + """ + Plain-data form of a workflow spec, safe to send across process boundaries. + + Produced by :meth:`WorkflowSpec.serialize`; round-trips through JSON. Params + are represented as JSON Schema, sufficient for form generation and + optimistic validation but not for authoritative validation — that remains + with the process owning the params model class. + """ + + params_schema: dict[str, Any] = Field( + description="JSON Schema of the workflow's params model." + ) + outputs: dict[str, OutputSpec] = Field( + description="Named outputs the workflow produces, in display order." + ) diff --git a/packages/essreduce/src/ess/reduce/spec/conversions.py b/packages/essreduce/src/ess/reduce/spec/conversions.py new file mode 100644 index 000000000..482d33f7e --- /dev/null +++ b/packages/essreduce/src/ess/reduce/spec/conversions.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +""" +Conversions from validated parameter models to scipp objects. + +Consumed by workflow implementations only; kept out of +:mod:`ess.reduce.spec.parameters` so the parameter vocabulary itself stays +free of scipp and serializes cleanly to JSON Schema. +""" + +import scipp as sc + +from .parameters import EdgesModel, RangeModel, Scale + + +def edges_to_variable(edges: EdgesModel, dim: str) -> sc.Variable: + """Return the bin edges described by the model as a scipp variable.""" + op = {Scale.LINEAR: sc.linspace, Scale.LOG: sc.geomspace}[edges.scale] + return op( + dim=dim, + start=edges.start, + stop=edges.stop, + num=edges.num_bins + 1, + unit=str(edges.unit), + ) + + +def range_to_variables(range_: RangeModel) -> tuple[sc.Variable, sc.Variable]: + """Return the range bounds as a pair of scipp scalars.""" + unit = str(range_.unit) + return sc.scalar(range_.start, unit=unit), sc.scalar(range_.stop, unit=unit) diff --git a/packages/essreduce/src/ess/reduce/spec/parameters.py b/packages/essreduce/src/ess/reduce/spec/parameters.py new file mode 100644 index 000000000..e426f938d --- /dev/null +++ b/packages/essreduce/src/ess/reduce/spec/parameters.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +""" +Shared vocabulary of workflow parameter models. + +Common building blocks for workflow params models: constrained unit choices and +range/edges models with cross-field validation. Purely declarative — no scipp; +converting validated values into scipp objects is the workflow implementation's +concern (see :mod:`ess.reduce.spec.conversions`). + +Value defaults (start, stop, number of bins) are deliberately not provided +here: sensible values depend on the workflow and instrument, so workflow +authors set them at the use site, e.g.:: + + class MyParams(pydantic.BaseModel): + wavelength: WavelengthEdges = WavelengthEdges( + start=1.0, stop=10.0, num_bins=200 + ) +""" + +from __future__ import annotations + +from abc import ABC +from enum import StrEnum + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class Scale(StrEnum): + """Spacing of generated bin edges.""" + + LINEAR = 'linear' + LOG = 'log' + + +class TimeUnit(StrEnum): + """Allowed units for time.""" + + NS = 'ns' + US = 'us' + MICROSECOND = 'µs' + MS = 'ms' + S = 's' + + +class WavelengthUnit(StrEnum): + """Allowed units for wavelength.""" + + ANGSTROM = 'Å' + NANOMETER = 'nm' + + +class DspacingUnit(StrEnum): + """Allowed units for d-spacing.""" + + ANGSTROM = 'Å' + NANOMETER = 'nm' + + +class LengthUnit(StrEnum): + """Allowed units for length.""" + + METER = 'm' + CENTIMETER = 'cm' + MILLIMETER = 'mm' + + +class AngleUnit(StrEnum): + """Allowed units for angles.""" + + DEGREE = 'deg' + RADIAN = 'rad' + + +class QUnit(StrEnum): + """Allowed units for momentum transfer Q.""" + + INVERSE_ANGSTROM = '1/Å' + INVERSE_NANOMETER = '1/nm' + + +class EnergyUnit(StrEnum): + """Allowed units for energy transfer.""" + + MILLI_EV = 'meV' + MICRO_EV = 'µeV' + + +class RangeModel(BaseModel, ABC): + """Base model for a value range. Subclasses constrain the unit.""" + + start: float = Field(description="Start of the range.") + stop: float = Field(description="Stop of the range.") + unit: str + + @field_validator('stop') + @classmethod + def stop_must_be_greater_than_start(cls, v: float, info) -> float: + start = info.data.get('start') + if start is not None and v <= start: + raise ValueError('stop must be greater than start') + return v + + +class EdgesModel(BaseModel, ABC): + """Base model for bin edges. Subclasses constrain the unit.""" + + start: float = Field(description="First bin edge.") + stop: float = Field(description="Last bin edge.") + num_bins: int = Field(ge=1, le=10000, description="Number of bins.") + scale: Scale = Field( + default=Scale.LINEAR, + description="Spacing of the edges, either 'linear' or 'log'.", + ) + unit: str + + @field_validator('stop') + @classmethod + def stop_must_be_greater_than_start(cls, v: float, info) -> float: + start = info.data.get('start') + if start is not None and v <= start: + raise ValueError('stop must be greater than start') + return v + + @model_validator(mode='after') + def start_must_be_positive_if_log(self) -> EdgesModel: + if self.scale == Scale.LOG and self.start <= 0: + raise ValueError("start must be positive when scale is 'log'") + return self + + +class TOARange(RangeModel): + """Time-of-arrival range.""" + + unit: TimeUnit = Field( + default=TimeUnit.MICROSECOND, description="Unit of the range bounds." + ) + + +class WavelengthRange(RangeModel): + """Wavelength range.""" + + unit: WavelengthUnit = Field( + default=WavelengthUnit.ANGSTROM, description="Unit of the range bounds." + ) + + +class TOAEdges(EdgesModel): + """Time-of-arrival bin edges.""" + + unit: TimeUnit = Field(default=TimeUnit.MS, description="Unit of the edges.") + + +class WavelengthEdges(EdgesModel): + """Wavelength bin edges.""" + + unit: WavelengthUnit = Field( + default=WavelengthUnit.ANGSTROM, description="Unit of the edges." + ) + + +class DspacingEdges(EdgesModel): + """D-spacing bin edges.""" + + unit: DspacingUnit = Field( + default=DspacingUnit.ANGSTROM, description="Unit of the edges." + ) + + +class TwoThetaEdges(EdgesModel): + """Scattering angle (two-theta) bin edges.""" + + unit: AngleUnit = Field(default=AngleUnit.DEGREE, description="Unit of the edges.") + + +class ThetaEdges(EdgesModel): + """Theta bin edges.""" + + unit: AngleUnit = Field(default=AngleUnit.DEGREE, description="Unit of the edges.") + + +class QEdges(EdgesModel): + """Momentum transfer (Q) bin edges.""" + + unit: QUnit = Field( + default=QUnit.INVERSE_ANGSTROM, description="Unit of the edges." + ) + + +class EnergyEdges(EdgesModel): + """Energy transfer bin edges.""" + + unit: EnergyUnit = Field( + default=EnergyUnit.MILLI_EV, description="Unit of the edges." + ) diff --git a/packages/essreduce/tests/spec/conversions_test.py b/packages/essreduce/tests/spec/conversions_test.py new file mode 100644 index 000000000..a838d2e3e --- /dev/null +++ b/packages/essreduce/tests/spec/conversions_test.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import scipp as sc + +from ess.reduce.spec.conversions import edges_to_variable, range_to_variables +from ess.reduce.spec.parameters import ( + Scale, + TOARange, + WavelengthEdges, +) + + +def test_linear_edges() -> None: + edges = WavelengthEdges(start=1.0, stop=10.0, num_bins=9) + var = edges_to_variable(edges, dim='wavelength') + assert sc.identical( + var, sc.linspace('wavelength', start=1.0, stop=10.0, num=10, unit='Å') + ) + + +def test_log_edges() -> None: + edges = WavelengthEdges(start=1.0, stop=100.0, num_bins=2, scale=Scale.LOG) + var = edges_to_variable(edges, dim='wavelength') + assert sc.identical( + var, sc.geomspace('wavelength', start=1.0, stop=100.0, num=3, unit='Å') + ) + + +def test_range_to_variables() -> None: + low, high = range_to_variables(TOARange(start=10.0, stop=20.0)) + assert sc.identical(low, sc.scalar(10.0, unit='µs')) + assert sc.identical(high, sc.scalar(20.0, unit='µs')) diff --git a/packages/essreduce/tests/spec/parameters_test.py b/packages/essreduce/tests/spec/parameters_test.py new file mode 100644 index 000000000..655ed2a2c --- /dev/null +++ b/packages/essreduce/tests/spec/parameters_test.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import pydantic +import pytest + +from ess.reduce.spec.parameters import ( + Scale, + WavelengthEdges, + WavelengthRange, + WavelengthUnit, +) + + +class TestRangeModel: + def test_valid_range(self) -> None: + r = WavelengthRange(start=1.0, stop=2.0) + assert r.unit == WavelengthUnit.ANGSTROM + + def test_stop_must_exceed_start(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthRange(start=2.0, stop=1.0) + with pytest.raises(pydantic.ValidationError): + WavelengthRange(start=1.0, stop=1.0) + + def test_bounds_are_required(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthRange(stop=2.0) + + def test_unit_is_constrained(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthRange(start=1.0, stop=2.0, unit='m') + + +class TestEdgesModel: + def test_valid_edges(self) -> None: + edges = WavelengthEdges(start=1.0, stop=10.0, num_bins=100) + assert edges.scale == Scale.LINEAR + + def test_stop_must_exceed_start(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthEdges(start=10.0, stop=1.0, num_bins=100) + + def test_log_scale_requires_positive_start(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthEdges(start=0.0, stop=10.0, num_bins=100, scale=Scale.LOG) + WavelengthEdges(start=0.1, stop=10.0, num_bins=100, scale=Scale.LOG) + + def test_num_bins_bounds(self) -> None: + with pytest.raises(pydantic.ValidationError): + WavelengthEdges(start=1.0, stop=10.0, num_bins=0) + with pytest.raises(pydantic.ValidationError): + WavelengthEdges(start=1.0, stop=10.0, num_bins=10001) + + +class TestJsonSchema: + def test_unit_choices_appear_as_enum(self) -> None: + schema = WavelengthEdges.model_json_schema() + unit_ref = schema['properties']['unit'] + enum = schema['$defs']['WavelengthUnit']['enum'] + assert set(enum) == {'Å', 'nm'} + assert unit_ref is not None + + def test_validated_model_roundtrips_through_json(self) -> None: + edges = WavelengthEdges(start=1.0, stop=10.0, num_bins=100, scale=Scale.LOG) + restored = WavelengthEdges.model_validate_json(edges.model_dump_json()) + assert restored == edges diff --git a/packages/essreduce/tests/spec/workflow_spec_test.py b/packages/essreduce/tests/spec/workflow_spec_test.py new file mode 100644 index 000000000..3b6b8d661 --- /dev/null +++ b/packages/essreduce/tests/spec/workflow_spec_test.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import pydantic +import pytest + +from ess.reduce.spec import ( + ArraySpec, + NoParams, + OutputSpec, + SerializedWorkflowSpec, + WorkflowSpec, +) + + +class Params(pydantic.BaseModel): + lower: float + upper: float + + @pydantic.model_validator(mode='after') + def upper_greater_than_lower(self) -> 'Params': + if self.upper <= self.lower: + raise ValueError('upper must be greater than lower') + return self + + +@pytest.fixture +def spec() -> WorkflowSpec: + return WorkflowSpec( + name='my-workflow', + version=1, + title='My workflow', + description='Computes things.', + params=Params, + outputs={ + 'iofq': OutputSpec( + title='I(Q)', + array=ArraySpec(dims=('Q',), unit='counts', coords={'Q': '1/Å'}), + ), + 'transmission': OutputSpec(title='Transmission'), + }, + ) + + +class TestWorkflowSpec: + def test_minimal_spec_defaults_to_no_params_and_result_output(self) -> None: + spec = WorkflowSpec( + name='wf', version=1, title='Workflow', description='Does things.' + ) + assert spec.params is NoParams + assert list(spec.outputs) == ['result'] + + @pytest.mark.parametrize('field', ['name', 'title', 'description']) + def test_empty_metadata_field_rejected(self, field: str) -> None: + fields = { + 'name': 'wf', + 'version': 1, + 'title': 'Workflow', + 'description': 'Does things.', + } + with pytest.raises(pydantic.ValidationError): + WorkflowSpec(**{**fields, field: ''}) + + def test_version_must_be_positive(self) -> None: + with pytest.raises(pydantic.ValidationError): + WorkflowSpec(name='wf', version=0, title='W', description='D') + + def test_spec_is_frozen(self, spec: WorkflowSpec) -> None: + with pytest.raises(pydantic.ValidationError): + spec.title = 'Other' + + def test_no_params_rejects_any_input(self) -> None: + with pytest.raises(pydantic.ValidationError): + NoParams(anything=1) + + def test_params_model_validates_in_process(self, spec: WorkflowSpec) -> None: + with pytest.raises(pydantic.ValidationError): + spec.params(lower=2.0, upper=1.0) + + +class TestSerialization: + def test_serialize_projects_params_to_json_schema(self, spec: WorkflowSpec) -> None: + serialized = spec.serialize() + assert serialized.params_schema == Params.model_json_schema() + assert set(serialized.params_schema['properties']) == {'lower', 'upper'} + + def test_serialize_preserves_metadata_and_outputs(self, spec: WorkflowSpec) -> None: + serialized = spec.serialize() + assert serialized.name == spec.name + assert serialized.version == spec.version + assert serialized.title == spec.title + assert serialized.description == spec.description + assert serialized.outputs == spec.outputs + + def test_output_order_preserved(self, spec: WorkflowSpec) -> None: + assert list(spec.serialize().outputs) == ['iofq', 'transmission'] + + def test_serialized_spec_roundtrips_through_json(self, spec: WorkflowSpec) -> None: + serialized = spec.serialize() + restored = SerializedWorkflowSpec.model_validate_json( + serialized.model_dump_json() + ) + assert restored == serialized + + def test_array_spec_survives_json_roundtrip(self, spec: WorkflowSpec) -> None: + restored = SerializedWorkflowSpec.model_validate_json( + spec.serialize().model_dump_json() + ) + array = restored.outputs['iofq'].array + assert array == ArraySpec(dims=('Q',), unit='counts', coords={'Q': '1/Å'})