diff --git a/packages/esssans/docs/_static/thumbnails/skadi_detector_view_dark.svg b/packages/esssans/docs/_static/thumbnails/skadi_detector_view_dark.svg
new file mode 100644
index 000000000..ace5d5582
--- /dev/null
+++ b/packages/esssans/docs/_static/thumbnails/skadi_detector_view_dark.svg
@@ -0,0 +1,42 @@
+
+
+
diff --git a/packages/esssans/docs/_static/thumbnails/skadi_detector_view_light.svg b/packages/esssans/docs/_static/thumbnails/skadi_detector_view_light.svg
new file mode 100644
index 000000000..a097c3105
--- /dev/null
+++ b/packages/esssans/docs/_static/thumbnails/skadi_detector_view_light.svg
@@ -0,0 +1,42 @@
+
+
+
diff --git a/packages/esssans/docs/index.md b/packages/esssans/docs/index.md
index c05f86c99..1b4d61f7e 100644
--- a/packages/esssans/docs/index.md
+++ b/packages/esssans/docs/index.md
@@ -37,6 +37,11 @@
:::
+:::{grid-item-card} SKADI
+:link: user-guide/skadi/index.md
+
+:::
+
:::{grid-item-card} ISIS instruments
:link: user-guide/isis/index.md
diff --git a/packages/esssans/docs/user-guide/index.md b/packages/esssans/docs/user-guide/index.md
index 1433947b6..f661a3a17 100644
--- a/packages/esssans/docs/user-guide/index.md
+++ b/packages/esssans/docs/user-guide/index.md
@@ -7,6 +7,7 @@ maxdepth: 1
installation
loki/index
+skadi/index
isis/index
common/index
```
diff --git a/packages/esssans/docs/user-guide/skadi/index.md b/packages/esssans/docs/user-guide/skadi/index.md
new file mode 100644
index 000000000..19a51d85b
--- /dev/null
+++ b/packages/esssans/docs/user-guide/skadi/index.md
@@ -0,0 +1,31 @@
+# SKADI
+
+## Detector diagnostics
+
+::::{grid} 3
+
+:::{grid-item-card} Detector view
+:link: skadi-detector-view.ipynb
+:text-align: center
+
+```{image} ../../_static/thumbnails/skadi_detector_view_light.svg
+:class: only-light
+:width: 100%
+```
+
+```{image} ../../_static/thumbnails/skadi_detector_view_dark.svg
+:class: only-dark
+:width: 100%
+```
+
+:::
+
+::::
+
+```{toctree}
+---
+hidden:
+---
+
+skadi-detector-view
+```
diff --git a/packages/esssans/docs/user-guide/skadi/skadi-detector-view.ipynb b/packages/esssans/docs/user-guide/skadi/skadi-detector-view.ipynb
new file mode 100644
index 000000000..11a52d685
--- /dev/null
+++ b/packages/esssans/docs/user-guide/skadi/skadi-detector-view.ipynb
@@ -0,0 +1,74 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# SKADI detector view\n",
+ "\n",
+ "This example uses the SKADI McStas workflow to load detector events and display them in an interactive instrument view. The reduced example file is downloaded on first use and cached by pooch."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import scippneutron as scn\n",
+ "\n",
+ "from ess.skadi import SkadiMcStasWorkflow\n",
+ "from ess.skadi.data import skadi_mcstas_sample\n",
+ "from ess.sans.types import Filename, RawDetector, SampleRun"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "workflow = SkadiMcStasWorkflow()\n",
+ "workflow[Filename[SampleRun]] = skadi_mcstas_sample()\n",
+ "detector = workflow.compute(RawDetector[SampleRun])"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "detector_view = scn.instrument_view(\n",
+ " detector.bins.sum(), size=0.006, norm=\"log\", cbar=True\n",
+ ")\n",
+ "detector_view"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.15"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/packages/esssans/pyproject.toml b/packages/esssans/pyproject.toml
index 8a5cd1048..4331cdab2 100644
--- a/packages/esssans/pyproject.toml
+++ b/packages/esssans/pyproject.toml
@@ -32,6 +32,7 @@ dynamic = ["version"]
dependencies = [
"dask>=2022.1.0",
"graphviz>=0.20",
+ "h5py>=3.11",
"essreduce>=26.6.0",
"numpy>=1.26.4",
"pandas>=2.1.2",
diff --git a/packages/esssans/src/ess/skadi/__init__.py b/packages/esssans/src/ess/skadi/__init__.py
new file mode 100644
index 000000000..0b8b84c20
--- /dev/null
+++ b/packages/esssans/src/ess/skadi/__init__.py
@@ -0,0 +1,24 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+
+import importlib.metadata
+
+from . import mcstas, workflow
+from .mcstas import load_skadi_mcstas
+from .workflow import SkadiMcStasWorkflow, SkadiWorkflow, skadi_default_parameters
+
+try:
+ __version__ = importlib.metadata.version(__package__ or __name__)
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "0.0.0"
+
+del importlib
+
+__all__ = [
+ 'SkadiMcStasWorkflow',
+ 'SkadiWorkflow',
+ 'load_skadi_mcstas',
+ 'mcstas',
+ 'skadi_default_parameters',
+ 'workflow',
+]
diff --git a/packages/esssans/src/ess/skadi/data.py b/packages/esssans/src/ess/skadi/data.py
new file mode 100644
index 000000000..da53dc5e8
--- /dev/null
+++ b/packages/esssans/src/ess/skadi/data.py
@@ -0,0 +1,25 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+"""Data for SKADI documentation examples."""
+
+from pathlib import Path
+
+from ess.reduce.data import make_registry
+
+_registry = make_registry(
+ "ess/skadi",
+ files={
+ "skadi_mcstas_1e8_sample10_1_of_50.h5": (
+ "md5:37883335a05c41d420cff2b38f883fc2"
+ ),
+ },
+ version="1",
+)
+
+
+def skadi_mcstas_sample() -> Path:
+ """Return the reduced SKADI McStas sample used in the user guide."""
+ return _registry.get_path("skadi_mcstas_1e8_sample10_1_of_50.h5")
+
+
+__all__ = ["skadi_mcstas_sample"]
diff --git a/packages/esssans/src/ess/skadi/mcstas.py b/packages/esssans/src/ess/skadi/mcstas.py
new file mode 100644
index 000000000..e5e0ddbb5
--- /dev/null
+++ b/packages/esssans/src/ess/skadi/mcstas.py
@@ -0,0 +1,373 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+"""McStas input adapter for the SKADI workflow."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+import h5py
+import numpy as np
+import scipp as sc
+import scippnexus as snx
+from scippneutron.conversion.graph import tof
+
+from ..sans.conversions import ElasticCoordTransformGraph, sans_elastic
+from ..sans.types import (
+ CorrectForGravity,
+ Filename,
+ GravityVector,
+ Position,
+ RawDetector,
+ RunType,
+ WavelengthDetector,
+)
+
+# The McStas detector geometry description needs some adjustments.
+# Specifically, the outermost pixels on the banks cover a slightly larger
+# area than the rest. But that is not reflected in the McStas geometry data.
+# These parameters are used to make the adjustments:
+# Width and height assigned to low-resolution pixels.
+LOW_RES_PIXEL_SIZE = sc.scalar(0.006, unit='m')
+# Width and height assigned to high-resolution pixels.
+HIGH_RES_PIXEL_SIZE = sc.scalar(0.003, unit='m')
+# Added to the width or height of perimeter pixels; their centers move outward by half.
+PERIMETER_PIXEL_EXTENSION = sc.scalar(0.00025, unit='m')
+# Depth assigned to every pixel.
+PIXEL_DEPTH = sc.scalar(0.001, unit='m')
+
+
+@dataclass(frozen=True)
+class _DetectorSpec:
+ group_name: str
+ component_name: str
+ pixel_min: int
+ x_limits: tuple[float, float]
+ y_limits: tuple[float, float]
+ shape: tuple[int, int]
+ event_count: int
+
+ @property
+ def pixel_count(self) -> int:
+ return self.shape[0] * self.shape[1]
+
+
+_NUMBER = r"[-+]?\d+(?:\.\d*)?(?:[eE][-+]?\d+)?"
+_X_OPTIONS = re.compile(
+ rf"x limits=\[\s*({_NUMBER})\s*,\s*({_NUMBER})\s*\]\s+bins=(\d+)"
+)
+_Y_OPTIONS = re.compile(
+ rf"y limits=\[\s*({_NUMBER})\s*,\s*({_NUMBER})\s*\]\s+bins=(\d+)"
+)
+_PIXEL_MIN = re.compile(r"pixel min=(\d+)")
+
+
+def _decode(value: str | bytes) -> str:
+ return value.decode('utf-8') if isinstance(value, bytes) else value
+
+
+def _mcstas_path(filename: str | Path) -> Path:
+ path = Path(filename)
+ if path.is_dir():
+ path = path / 'mccode.h5'
+ if not path.exists():
+ raise FileNotFoundError(f"McStas file does not exist: {path}")
+ return path
+
+
+def _parse_detector_spec(name: str, group: h5py.Group) -> _DetectorSpec:
+ options = _decode(group.attrs['options'])
+ x_match = _X_OPTIONS.search(options)
+ y_match = _Y_OPTIONS.search(options)
+ pixel_match = _PIXEL_MIN.search(options)
+ if x_match is None or y_match is None or pixel_match is None:
+ raise ValueError(f"Cannot parse detector geometry options for {group.name!r}")
+ return _DetectorSpec(
+ group_name=name,
+ component_name=_decode(group.attrs['component']),
+ pixel_min=int(pixel_match.group(1)),
+ x_limits=(float(x_match.group(1)), float(x_match.group(2))),
+ y_limits=(float(y_match.group(1)), float(y_match.group(2))),
+ shape=(int(y_match.group(3)), int(x_match.group(3))),
+ event_count=group['events'].shape[0],
+ )
+
+
+def corrected_pixel_geometry(
+ *,
+ component_position: sc.Variable,
+ component_rotation: np.ndarray,
+ x_limits: tuple[float, float],
+ y_limits: tuple[float, float],
+ shape: tuple[int, int],
+) -> tuple[sc.Variable, sc.Variable, sc.Variable]:
+ """Build corrected global pixel positions, sizes, and surface normals.
+
+ McStas records an even grid of pixel centers. The outermost pixels represent the
+ remaining quarter-millimetre perimeter of each detector tile. Their centers are
+ shifted outwards by half that amount and their corresponding size is enlarged.
+ This is the correction used by the scripts supplied with the SKADI example data.
+
+ Parameters
+ ----------
+ component_position:
+ Global position of the McStas detector component in metres.
+ component_rotation:
+ McStas rotation matrix for the detector component.
+ x_limits:
+ Limits of the local pixel grid in metres.
+ y_limits:
+ Limits of the local pixel grid in metres.
+ shape:
+ Number of pixels as ``(y, x)``.
+
+ Returns
+ -------
+ positions: scipp.Variable
+ Corrected global pixel centers.
+ sizes: scipp.Variable
+ Corrected pixel sizes as ``(x, y, depth)``.
+ normals: scipp.Variable
+ Global detector surface normals.
+ """
+ ny, nx = shape
+ if nx != ny or nx not in (8, 16):
+ raise ValueError(
+ f"Unsupported SKADI McStas detector shape {shape}; expected 8x8 or 16x16"
+ )
+ nominal_size = LOW_RES_PIXEL_SIZE if nx == 8 else HIGH_RES_PIXEL_SIZE
+
+ x = sc.midpoints(sc.linspace('x', *x_limits, num=nx + 1, unit='m'))
+ y = sc.midpoints(sc.linspace('y', *y_limits, num=ny + 1, unit='m'))
+ shift = PERIMETER_PIXEL_EXTENSION / 2
+ x['x', 0] -= shift
+ x['x', -1] += shift
+ y['y', 0] -= shift
+ y['y', -1] += shift
+
+ local = (
+ sc.spatial.as_vectors(x=x, y=y, z=sc.scalar(0.0, unit='m'))
+ .transpose(['y', 'x'])
+ .flatten(to='detector_number')
+ )
+ # McStas applies the stored matrix to row vectors. Scipp transformations act on
+ # column vectors, so the matrix must be transposed.
+ rotation = sc.spatial.linear_transform(value=component_rotation.T)
+ positions = component_position + rotation * local
+
+ width = sc.broadcast(nominal_size, sizes={'y': ny, 'x': nx}).copy()
+ height = width.copy()
+ width['x', 0] += PERIMETER_PIXEL_EXTENSION
+ width['x', -1] += PERIMETER_PIXEL_EXTENSION
+ height['y', 0] += PERIMETER_PIXEL_EXTENSION
+ height['y', -1] += PERIMETER_PIXEL_EXTENSION
+ sizes = sc.spatial.as_vectors(
+ width,
+ height,
+ sc.broadcast(PIXEL_DEPTH, sizes=width.sizes),
+ ).flatten(to='detector_number')
+
+ normal = rotation * sc.vector([0.0, 0.0, 1.0])
+ normals = sc.broadcast(normal, sizes=positions.sizes)
+ return positions, sizes, normals
+
+
+def _component_map(components: h5py.Group) -> dict[str, h5py.Group]:
+ return {
+ name.split('_', maxsplit=1)[-1]: group
+ for name, group in components.items()
+ if isinstance(group, h5py.Group)
+ }
+
+
+def _component_position(
+ components: dict[str, h5py.Group], component_name: str
+) -> sc.Variable:
+ component = components.get(component_name)
+ if component is None or 'Position' not in component:
+ raise ValueError(f"No instrument component found for {component_name!r}")
+ return sc.vector(np.asarray(component['Position'][()], dtype=np.float64), unit='m')
+
+
+def load_skadi_mcstas(
+ filename: str | Path,
+ *,
+ source_name: str = 'sourceESS',
+ sample_name: str = 'sample_position',
+) -> sc.DataArray:
+ """Load SKADI McStas detector events and apply the geometry correction.
+
+ The McStas event probability is retained as the event value, with its square as
+ the variance. Events are grouped by the global McStas pixel ID.
+
+ Parameters
+ ----------
+ filename:
+ McStas ``mccode.h5`` file or its containing directory.
+ source_name:
+ Component name identifying the source position.
+ sample_name:
+ Component name identifying the sample position.
+
+ Returns
+ -------
+ :
+ Calibrated, event-mode detector data suitable for the SKADI workflow.
+ """
+ path = _mcstas_path(filename)
+ with h5py.File(path, 'r') as file:
+ data_groups = file['entry1/data']
+ specs = sorted(
+ (
+ _parse_detector_spec(name, group)
+ for name, group in data_groups.items()
+ if isinstance(group, h5py.Group) and 'events' in group
+ ),
+ key=lambda spec: spec.pixel_min,
+ )
+ if not specs:
+ raise ValueError(f"No McStas detector event groups found in {path}")
+
+ total_pixels = 0
+ for spec in specs:
+ if spec.pixel_min != total_pixels:
+ raise ValueError(
+ "McStas detector groups do not cover a contiguous pixel-ID "
+ f"range; expected ID {total_pixels}, got {spec.pixel_min}"
+ )
+ total_pixels += spec.pixel_count
+
+ total_events = sum(spec.event_count for spec in specs)
+ positions = []
+ pixel_sizes = []
+ detector_normals = []
+ weights = np.empty(total_events, dtype=np.float64)
+ event_time_offset = np.empty(total_events, dtype=np.float64)
+ pixel_ids = np.empty(total_events, dtype=np.int64)
+
+ components = file['entry1/instrument/components']
+ component_by_name = _component_map(components)
+ event_cursor = 0
+ for spec in specs:
+ pixel_stop = spec.pixel_min + spec.pixel_count
+ component = component_by_name.get(spec.component_name)
+ if component is None:
+ raise ValueError(
+ f"No instrument component found for {spec.component_name!r}"
+ )
+ geometry = corrected_pixel_geometry(
+ component_position=sc.vector(component['Position'][()], unit='m'),
+ component_rotation=np.asarray(component['Rotation'][()]),
+ x_limits=spec.x_limits,
+ y_limits=spec.y_limits,
+ shape=spec.shape,
+ )
+ positions.append(geometry[0])
+ pixel_sizes.append(geometry[1])
+ detector_normals.append(geometry[2])
+
+ group = data_groups[spec.group_name]
+ columns = _decode(group.attrs.get('variables', 'p x y n id t')).split()
+ column = {name: i for i, name in enumerate(columns)}
+ events = group['events'][()]
+ ids = events[:, column['id']].astype(np.int64)
+ if np.any((ids < spec.pixel_min) | (ids >= pixel_stop)):
+ raise ValueError(f"Out-of-range pixel ID in {group.name!r}")
+
+ event_stop = event_cursor + spec.event_count
+ weights[event_cursor:event_stop] = events[:, column['p']]
+ event_time_offset[event_cursor:event_stop] = events[:, column['t']]
+ pixel_ids[event_cursor:event_stop] = ids
+ event_cursor = event_stop
+
+ source_position = _component_position(component_by_name, source_name)
+ sample_position = _component_position(component_by_name, sample_name)
+
+ events = sc.DataArray(
+ sc.array(
+ dims=['event'],
+ values=weights,
+ variances=np.square(weights),
+ ),
+ coords={
+ 'event_time_offset': sc.array(
+ dims=['event'], values=event_time_offset, unit='s'
+ ),
+ 'detector_number': sc.array(dims=['event'], values=pixel_ids, unit=None),
+ },
+ )
+ detector_dim = 'detector_number'
+ detector_numbers = sc.arange(detector_dim, total_pixels, unit=None)
+ return events.group(detector_numbers).assign_coords(
+ position=sc.concat(positions, detector_dim),
+ pixel_size=sc.concat(pixel_sizes, detector_dim),
+ detector_normal=sc.concat(detector_normals, detector_dim),
+ source_position=source_position,
+ sample_position=sample_position,
+ )
+
+
+def load_skadi_mcstas_provider(
+ filename: Filename[RunType],
+) -> RawDetector[RunType]:
+ """Load a McStas file for a run in the SKADI workflow."""
+ return RawDetector[RunType](load_skadi_mcstas(filename))
+
+
+def source_position_from_mcstas(
+ detector: RawDetector[RunType],
+) -> Position[snx.NXsource, RunType]:
+ """Extract the source position attached by the McStas loader."""
+ return Position[snx.NXsource, RunType](detector.coords['source_position'])
+
+
+def sample_position_from_mcstas(
+ detector: RawDetector[RunType],
+) -> Position[snx.NXsample, RunType]:
+ """Extract the sample position attached by the McStas loader."""
+ return Position[snx.NXsample, RunType](detector.coords['sample_position'])
+
+
+def mcstas_detector_coord_transform_graph(
+ correct_for_gravity: CorrectForGravity,
+ *,
+ sample_position: Position[snx.NXsample, RunType],
+ source_position: Position[snx.NXsource, RunType],
+ gravity: GravityVector,
+) -> ElasticCoordTransformGraph[RunType]:
+ """Build the coordinate graph for McStas, whose event time is already TOF."""
+ graph = sans_elastic(
+ correct_for_gravity=correct_for_gravity,
+ sample_position=sample_position,
+ source_position=source_position,
+ gravity=gravity,
+ )
+ return ElasticCoordTransformGraph[RunType](
+ {**graph, **tof.elastic_wavelength('tof')}
+ )
+
+
+def mcstas_data_to_wavelength(
+ detector: RawDetector[RunType],
+ graph: ElasticCoordTransformGraph[RunType],
+) -> WavelengthDetector[RunType]:
+ """Convert McStas time-of-flight events to wavelength."""
+ event_time_offset = detector.bins.coords['event_time_offset']
+ detector = detector.bins.drop_coords('event_time_offset')
+ detector.bins.coords['tof'] = event_time_offset
+ return WavelengthDetector[RunType](
+ detector.transform_coords(
+ 'wavelength', graph=graph, keep_intermediate=False, rename_dims=False
+ )
+ )
+
+
+mcstas_providers = (
+ load_skadi_mcstas_provider,
+ source_position_from_mcstas,
+ sample_position_from_mcstas,
+ mcstas_detector_coord_transform_graph,
+ mcstas_data_to_wavelength,
+)
diff --git a/packages/esssans/src/ess/skadi/workflow.py b/packages/esssans/src/ess/skadi/workflow.py
new file mode 100644
index 000000000..d4bf61f60
--- /dev/null
+++ b/packages/esssans/src/ess/skadi/workflow.py
@@ -0,0 +1,130 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+"""Default providers and parameters for the SKADI SANS workflow."""
+
+import sciline
+import scipp as sc
+import scippnexus as snx
+from ess import sans
+from ess.sans.parameters import typical_outputs
+
+from ess.reduce.uncertainty import UncertaintyBroadcastMode
+from ess.reduce.unwrap import WavelengthLutMode
+from ess.reduce.workflow import register_workflow
+
+from ..sans.types import (
+ DetectorMasks,
+ DirectBeam,
+ MonitorTerm,
+ Position,
+ RawDetector,
+ ReturnEvents,
+ RunType,
+ SolidAngle,
+ WavelengthBins,
+)
+from .mcstas import mcstas_providers
+
+
+def skadi_default_parameters() -> dict:
+ """Return defaults for a minimal SKADI reduction."""
+ return {
+ DetectorMasks: {},
+ DirectBeam: None,
+ ReturnEvents: False,
+ UncertaintyBroadcastMode: UncertaintyBroadcastMode.drop,
+ }
+
+
+def rectangular_pixel_solid_angle(
+ detector: RawDetector[RunType],
+ sample_position: Position[snx.NXsample, RunType],
+) -> SolidAngle[RunType]:
+ """Compute the solid angle of flat rectangular SKADI pixels.
+
+ The detector data must contain ``position``, ``pixel_size``, and
+ ``detector_normal`` coordinates. This requirement is independent of the source
+ data format; a NeXus loader can supply the same calibrated coordinates as the
+ McStas loader.
+ """
+ missing = {
+ 'position',
+ 'pixel_size',
+ 'detector_normal',
+ } - set(detector.coords)
+ if missing:
+ raise ValueError(
+ "SKADI detector data is missing geometry coordinates: "
+ + ', '.join(sorted(missing))
+ )
+
+ scattered_beam = detector.coords['position'] - sample_position
+ distance = sc.norm(scattered_beam)
+ area = (
+ detector.coords['pixel_size'].fields.x * detector.coords['pixel_size'].fields.y
+ )
+ projected_area = (
+ area
+ * sc.abs(sc.dot(detector.coords['detector_normal'], scattered_beam))
+ / distance
+ )
+ omega = projected_area / distance**2
+
+ coords = {
+ name: coord
+ for name, coord in detector.coords.items()
+ if set(coord.dims).issubset(detector.dims)
+ }
+ return SolidAngle[RunType](sc.DataArray(omega, coords=coords))
+
+
+def unity_monitor_term(wavelength_bins: WavelengthBins) -> MonitorTerm[RunType]:
+ """Return unity incident-flux and transmission normalization.
+
+ This makes the basic workflow usable for simulations without monitor data. The
+ standard SANS solid-angle normalization and optional direct-beam correction remain
+ in the workflow. Replace this provider when measured monitor and transmission data
+ are available.
+ """
+ wavelength = sc.midpoints(wavelength_bins)
+ return MonitorTerm[RunType](
+ sc.DataArray(sc.ones(sizes=wavelength.sizes), coords={'wavelength': wavelength})
+ )
+
+
+skadi_providers = (rectangular_pixel_solid_angle, unity_monitor_term)
+
+
+@register_workflow
+def SkadiWorkflow(
+ wavelength_from: WavelengthLutMode = "file",
+) -> sciline.Pipeline:
+ """Create a basic, data-source-independent SKADI reduction workflow.
+
+ Parameters
+ ----------
+ wavelength_from:
+ Mode used by the common SANS workflow to obtain wavelength. A data-source
+ adapter may override this conversion, as :func:`SkadiMcStasWorkflow` does.
+
+ Returns
+ -------
+ :
+ The SKADI reduction workflow.
+ """
+ workflow = sans.SansWorkflow(wavelength_from=wavelength_from)
+ for provider in skadi_providers:
+ workflow.insert(provider)
+ for key, value in skadi_default_parameters().items():
+ workflow[key] = value
+ workflow.typical_outputs = typical_outputs
+ return workflow
+
+
+@register_workflow
+def SkadiMcStasWorkflow() -> sciline.Pipeline:
+ """Create the basic SKADI workflow with the McStas input adapter."""
+ workflow = SkadiWorkflow()
+ for provider in mcstas_providers:
+ workflow.insert(provider)
+ return workflow
diff --git a/packages/esssans/tests/package_test.py b/packages/esssans/tests/package_test.py
index 46906b2ce..904f40601 100644
--- a/packages/esssans/tests/package_test.py
+++ b/packages/esssans/tests/package_test.py
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
-from ess import isissans, loki, sans
+from ess import isissans, loki, sans, skadi
"""Tests of package integrity.
@@ -13,6 +13,7 @@ def test_has_version():
assert hasattr(isissans, '__version__')
assert hasattr(loki, '__version__')
assert hasattr(sans, '__version__')
+ assert hasattr(skadi, '__version__')
if __name__ == '__main__':
diff --git a/packages/esssans/tests/skadi/mcstas_test.py b/packages/esssans/tests/skadi/mcstas_test.py
new file mode 100644
index 000000000..15f930e30
--- /dev/null
+++ b/packages/esssans/tests/skadi/mcstas_test.py
@@ -0,0 +1,132 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+
+from pathlib import Path
+
+import h5py
+import numpy as np
+import scipp as sc
+from ess.sans.types import (
+ Filename,
+ IntensityQ,
+ QBins,
+ SampleRun,
+ WavelengthBins,
+ WavelengthDetector,
+)
+from ess.skadi import SkadiMcStasWorkflow, load_skadi_mcstas
+from scipp.testing import assert_allclose
+
+
+def _component(
+ components: h5py.Group,
+ name: str,
+ position: list[float],
+ rotation: np.ndarray | None = None,
+) -> None:
+ group = components.create_group(name)
+ group.create_dataset('Position', data=position)
+ group.create_dataset('Rotation', data=np.eye(3) if rotation is None else rotation)
+
+
+def _small_mcstas_file(
+ path: Path, *, detector_rotation: np.ndarray | None = None
+) -> Path:
+ with h5py.File(path, 'w') as file:
+ entry = file.create_group('entry1')
+ data = entry.create_group('data')
+ detector = data.create_group('detector_events')
+ detector.attrs['component'] = 'detector_0'
+ detector.attrs['variables'] = 'p x y n id t '
+ detector.attrs['options'] = (
+ 'mantid square x limits=[-0.024,0.024] bins=8 '
+ 'y limits=[-0.024,0.024] bins=8, neutron pixel min=0 t, '
+ 'list all neutrons'
+ )
+ detector.create_dataset(
+ 'events',
+ data=np.array(
+ [
+ [2.0, 0.0, 0.0, 1.0, 1.0, 0.014],
+ [1.0, 0.0, 0.0, 2.0, 0.0, 0.012],
+ [3.0, 0.0, 0.0, 3.0, 1.0, 0.016],
+ ]
+ ),
+ )
+
+ instrument = entry.create_group('instrument')
+ components = instrument.create_group('components')
+ _component(components, '0001_sourceESS', [0.0, 0.0, 0.0])
+ _component(components, '0002_sample_position', [0.0, 0.0, 10.0])
+ _component(
+ components,
+ '0003_detector_0',
+ [0.0, 0.0, 12.0],
+ rotation=detector_rotation,
+ )
+ return path
+
+
+def test_mcstas_loader_groups_events_by_pixel_id(tmp_path: Path) -> None:
+ detector = load_skadi_mcstas(_small_mcstas_file(tmp_path / 'mccode.h5'))
+ events_per_pixel = detector.bins.size()
+
+ assert events_per_pixel['detector_number', 0].value == 1
+ assert events_per_pixel['detector_number', 1].value == 2
+ assert events_per_pixel['detector_number', 2:].sum().value == 0
+
+
+def test_mcstas_loader_uses_mcstas_rotation_convention(tmp_path: Path) -> None:
+ # McStas stores matrices for multiplication from the left by row vectors.
+ rotation = np.array([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]])
+ unrotated = load_skadi_mcstas(_small_mcstas_file(tmp_path / 'unrotated.h5'))
+ rotated = load_skadi_mcstas(
+ _small_mcstas_file(tmp_path / 'rotated.h5', detector_rotation=rotation)
+ )
+ transform = sc.spatial.linear_transform(value=rotation.T)
+
+ assert_allclose(
+ rotated.coords['position'][1] - rotated.coords['position'][0],
+ transform * (unrotated.coords['position'][1] - unrotated.coords['position'][0]),
+ )
+ assert_allclose(
+ rotated.coords['detector_normal'][0],
+ transform * unrotated.coords['detector_normal'][0],
+ )
+
+
+def test_mcstas_workflow_converts_event_time_to_wavelength(tmp_path: Path) -> None:
+ filename = _small_mcstas_file(tmp_path / 'mccode.h5')
+ workflow = SkadiMcStasWorkflow()
+ workflow[Filename[SampleRun]] = filename
+
+ detector = workflow.compute(WavelengthDetector[SampleRun])
+ events = detector.bins.constituents['data']
+ source_to_sample = sc.scalar(10.0, unit='m')
+ sample_to_pixel = sc.norm(
+ detector.coords['position'][0] - sc.vector([0.0, 0.0, 10.0], unit='m')
+ )
+ expected = (
+ sc.constants.h
+ / sc.constants.m_n
+ * sc.scalar(0.012, unit='s')
+ / (source_to_sample + sample_to_pixel)
+ ).to(unit='angstrom')
+
+ assert sc.allclose(events.coords['wavelength'][0], expected)
+
+
+def test_mcstas_workflow_computes_intensity_q(tmp_path: Path) -> None:
+ filename = _small_mcstas_file(tmp_path / 'mccode.h5')
+ workflow = SkadiMcStasWorkflow()
+ workflow[Filename[SampleRun]] = filename
+ workflow[WavelengthBins] = sc.linspace(
+ 'wavelength', start=2.0, stop=8.0, num=31, unit='angstrom'
+ )
+ workflow[QBins] = sc.linspace('Q', start=0.0, stop=0.1, num=51, unit='1/angstrom')
+
+ intensity = workflow.compute(IntensityQ[SampleRun])
+
+ assert intensity.dims == ('Q',)
+ assert intensity.sizes == {'Q': 50}
+ assert sc.isfinite(intensity.data).any().value
diff --git a/packages/esssans/tests/skadi/workflow_test.py b/packages/esssans/tests/skadi/workflow_test.py
new file mode 100644
index 000000000..6d0f4ab69
--- /dev/null
+++ b/packages/esssans/tests/skadi/workflow_test.py
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
+
+import scipp as sc
+import scippnexus as snx
+from ess.sans.types import Position, RawDetector, SampleRun, SolidAngle
+from ess.skadi import SkadiWorkflow
+
+
+def test_workflow_computes_solid_angle_from_calibrated_detector() -> None:
+ detector = sc.DataArray(
+ sc.ones(sizes={'detector_number': 2}),
+ coords={
+ 'position': sc.vectors(
+ dims=['detector_number'],
+ values=[[0.0, 0.0, 2.0], [1.0, 0.0, 2.0]],
+ unit='m',
+ ),
+ 'pixel_size': sc.vectors(
+ dims=['detector_number'],
+ values=[[0.02, 0.03, 0.001], [0.02, 0.03, 0.001]],
+ unit='m',
+ ),
+ 'detector_normal': sc.vectors(
+ dims=['detector_number'],
+ values=[[0.0, 0.0, -1.0], [0.0, 0.0, -1.0]],
+ unit='dimensionless',
+ ),
+ },
+ )
+ workflow = SkadiWorkflow()
+ workflow[RawDetector[SampleRun]] = detector
+ workflow[Position[snx.NXsample, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m')
+
+ solid_angle = workflow.compute(SolidAngle[SampleRun])
+
+ assert solid_angle.sizes == detector.sizes
+ assert solid_angle.unit == 'dimensionless'
+ assert sc.all(
+ sc.isfinite(solid_angle.data) & (solid_angle.data > sc.scalar(0))
+ ).value
diff --git a/packages/esssans/tools/docs/skadi-thumbnails.ipynb b/packages/esssans/tools/docs/skadi-thumbnails.ipynb
new file mode 100644
index 000000000..e7813fa0e
--- /dev/null
+++ b/packages/esssans/tools/docs/skadi-thumbnails.ipynb
@@ -0,0 +1,129 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# SKADI thumbnails\n",
+ "\n",
+ "This notebook generates the thumbnails used in the SKADI user guide."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import plopp as pp\n",
+ "import scipp as sc\n",
+ "\n",
+ "from ess.skadi import SkadiMcStasWorkflow\n",
+ "from ess.skadi.data import skadi_mcstas_sample\n",
+ "from ess.sans.types import Filename, RawDetector, SampleRun"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "workflow = SkadiMcStasWorkflow()\n",
+ "workflow[Filename[SampleRun]] = skadi_mcstas_sample()\n",
+ "detector = workflow.compute(RawDetector[SampleRun])\n",
+ "detector_intensity = detector.bins.sum()\n",
+ "\n",
+ "position = detector.coords[\"position\"]\n",
+ "\n",
+ "\n",
+ "def normalize(coord):\n",
+ " lower, upper = sc.min(coord), sc.max(coord)\n",
+ " return (coord - 0.5 * (lower + upper)) / (upper - lower)\n",
+ "\n",
+ "\n",
+ "x, y, z = (normalize(position.fields[axis]) for axis in (\"x\", \"y\", \"z\"))\n",
+ "detector_intensity.coords[\"view_x\"] = 0.82 * z + 0.57 * x\n",
+ "detector_intensity.coords[\"view_y\"] = -0.15 * z + 0.21 * x + 0.97 * y"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def detector_view_plot():\n",
+ " intensity = detector_intensity.values\n",
+ " positive = intensity[intensity > 0]\n",
+ " figure = pp.scatter(\n",
+ " detector_intensity,\n",
+ " x=\"view_x\",\n",
+ " y=\"view_y\",\n",
+ " cbar=True,\n",
+ " logc=True,\n",
+ " cmin=np.percentile(positive, 5),\n",
+ " cmax=np.percentile(positive, 99.5),\n",
+ " size=0.4,\n",
+ " figsize=(3, 2.5),\n",
+ " aspect=\"equal\",\n",
+ " rasterized=True,\n",
+ " linewidths=0,\n",
+ " )\n",
+ " figure.ax.set_axis_off()\n",
+ " figure.cax.set_axis_off()\n",
+ " return figure"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "fig = detector_view_plot()\n",
+ "fig.save(\n",
+ " \"../../docs/_static/thumbnails/skadi_detector_view_light.svg\",\n",
+ " transparent=True,\n",
+ " bbox_inches=\"tight\",\n",
+ " pad_inches=0,\n",
+ ")\n",
+ "fig"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "fig.save(\n",
+ " \"../../docs/_static/thumbnails/skadi_detector_view_dark.svg\",\n",
+ " transparent=True,\n",
+ " bbox_inches=\"tight\",\n",
+ " pad_inches=0,\n",
+ ")\n",
+ "fig"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/packages/esssans/tools/shrink_skadi_mcstas.ipynb b/packages/esssans/tools/shrink_skadi_mcstas.ipynb
new file mode 100644
index 000000000..9ea0f292f
--- /dev/null
+++ b/packages/esssans/tools/shrink_skadi_mcstas.ipynb
@@ -0,0 +1,212 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Make the reduced SKADI tutorial data\n",
+ "\n",
+ "This notebook creates the small McStas file used by the SKADI user-guide notebook. It keeps every 50th event across the complete detector, removes histogram outputs and their stale plotting metadata, and retains only the component positions and rotations required to reconstruct the detector geometry. The final `h5repack` step removes the vacated HDF5 space.\n",
+ "\n",
+ "The original simulation is not downloaded because of its size. Place `mccode.h5` from `all_banks_1e8_mpi4_sample10` in `SKADI_example_data` before running the notebook. The destination must not already exist."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import shutil\n",
+ "import subprocess\n",
+ "import tempfile\n",
+ "from pathlib import Path\n",
+ "\n",
+ "import h5py"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## Reduction function\n",
+ "\n",
+ "Events are sampled using their global index rather than sampling each detector group independently. This produces exactly $\\lfloor N / 50 \\rfloor$ events while preserving their original detector groups and metadata."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def shrink_skadi_mcstas(\n",
+ " source: Path, destination: Path, *, factor: int\n",
+ ") -> dict[str, int | Path]:\n",
+ " \"\"\"Subsample events and retain only geometry needed by the SKADI loader.\"\"\"\n",
+ " if factor < 1:\n",
+ " raise ValueError(\"factor must be at least 1\")\n",
+ " if destination.exists():\n",
+ " raise FileExistsError(f\"Destination already exists: {destination}\")\n",
+ " h5repack = shutil.which(\"h5repack\")\n",
+ " if h5repack is None:\n",
+ " raise RuntimeError(\"h5repack must be available on PATH\")\n",
+ "\n",
+ " destination.parent.mkdir(parents=True, exist_ok=True)\n",
+ " with tempfile.TemporaryDirectory(\n",
+ " prefix=\"shrink-skadi-mcstas-\", dir=destination.parent\n",
+ " ) as tmpdir:\n",
+ " working_copy = Path(tmpdir) / source.name\n",
+ " repacked = Path(tmpdir) / f\"repacked-{source.name}\"\n",
+ " shutil.copyfile(source, working_copy)\n",
+ "\n",
+ " original_event_count = 0\n",
+ " retained_event_count = 0\n",
+ " removed_histogram_count = 0\n",
+ " removed_component_count = 0\n",
+ " with h5py.File(working_copy, \"r+\") as file:\n",
+ " data_groups = file[\"entry1/data\"]\n",
+ " event_groups = [\n",
+ " group\n",
+ " for group in data_groups.values()\n",
+ " if isinstance(group, h5py.Group) and \"events\" in group\n",
+ " ]\n",
+ " required_component_names = {\n",
+ " group.attrs[\"component\"].decode()\n",
+ " if isinstance(group.attrs[\"component\"], bytes)\n",
+ " else group.attrs[\"component\"]\n",
+ " for group in event_groups\n",
+ " }\n",
+ " required_component_names.update((\"sourceESS\", \"sample_position\"))\n",
+ "\n",
+ " components = file[\"entry1/instrument/components\"]\n",
+ " component_names = {\n",
+ " name.split(\"_\", maxsplit=1)[-1]\n",
+ " for name, group in components.items()\n",
+ " if isinstance(group, h5py.Group)\n",
+ " }\n",
+ " missing = required_component_names - component_names\n",
+ " if missing:\n",
+ " raise ValueError(\n",
+ " \"Required instrument components are missing: \"\n",
+ " + \", \".join(sorted(missing))\n",
+ " )\n",
+ "\n",
+ " for name in list(components):\n",
+ " component_name = name.split(\"_\", maxsplit=1)[-1]\n",
+ " component = components[name]\n",
+ " if \"output\" in component and \"BINS\" in component[\"output\"]:\n",
+ " removed_histogram_count += 1\n",
+ " if component_name not in required_component_names:\n",
+ " del components[name]\n",
+ " removed_component_count += 1\n",
+ " continue\n",
+ "\n",
+ " for child in list(component):\n",
+ " if child not in {\"Position\", \"Rotation\"}:\n",
+ " del component[child]\n",
+ "\n",
+ " for group in event_groups:\n",
+ " events = group[\"events\"]\n",
+ " event_count = events.shape[0]\n",
+ " start = (factor - 1 - original_event_count % factor) % factor\n",
+ " retained = events[start::factor]\n",
+ " attrs = dict(events.attrs)\n",
+ " del group[\"events\"]\n",
+ " events = group.create_dataset(\"events\", data=retained)\n",
+ " events.attrs.update(attrs)\n",
+ " for attr in (\n",
+ " \"signal\",\n",
+ " \"statistics\",\n",
+ " \"target\",\n",
+ " \"type\",\n",
+ " \"values\",\n",
+ " \"xylimits\",\n",
+ " ):\n",
+ " if attr in group.attrs:\n",
+ " del group.attrs[attr]\n",
+ "\n",
+ " original_event_count += event_count\n",
+ " retained_event_count += retained.shape[0]\n",
+ "\n",
+ " expected_event_count = original_event_count // factor\n",
+ " if retained_event_count != expected_event_count:\n",
+ " raise RuntimeError(\n",
+ " f\"Expected {expected_event_count} retained events, got \"\n",
+ " f\"{retained_event_count}\"\n",
+ " )\n",
+ "\n",
+ " subprocess.run( # noqa: S603\n",
+ " [h5repack, str(working_copy), str(repacked)],\n",
+ " check=True,\n",
+ " )\n",
+ " shutil.move(repacked, destination)\n",
+ "\n",
+ " return {\n",
+ " \"source_events\": original_event_count,\n",
+ " \"retained_events\": retained_event_count,\n",
+ " \"removed_histograms\": removed_histogram_count,\n",
+ " \"removed_components\": removed_component_count,\n",
+ " \"output_bytes\": destination.stat().st_size,\n",
+ " \"destination\": destination,\n",
+ " }"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4",
+ "metadata": {},
+ "source": [
+ "## Create the tutorial file\n",
+ "\n",
+ "The path setup works when Jupyter is launched from either the repository root, the `esssans` package directory, or this `tools` directory."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "working_directory = Path.cwd()\n",
+ "if (working_directory / \"packages/esssans\").is_dir():\n",
+ " package_root = working_directory / \"packages/esssans\"\n",
+ "elif working_directory.name == \"tools\":\n",
+ " package_root = working_directory.parent\n",
+ "else:\n",
+ " package_root = working_directory\n",
+ "\n",
+ "data_directory = package_root / \"SKADI_example_data\"\n",
+ "source = data_directory / \"all_banks_1e8_mpi4_sample10/mccode.h5\"\n",
+ "destination = data_directory / \"skadi_mcstas_1e8_sample10_1_of_50.h5\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "shrink_skadi_mcstas(source, destination, factor=50)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}