From d96269623342ad1b64a0fc507464816a6b87e40c Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 21 Aug 2026 09:44:13 +0200 Subject: [PATCH 1/3] feat: add minmal skadi package --- .../thumbnails/skadi_detector_view_dark.svg | 42 ++ .../thumbnails/skadi_detector_view_light.svg | 42 ++ packages/esssans/docs/index.md | 5 + packages/esssans/docs/user-guide/index.md | 1 + .../esssans/docs/user-guide/skadi/index.md | 31 ++ .../skadi/skadi-detector-view.ipynb | 74 ++++ packages/esssans/pyproject.toml | 1 + packages/esssans/src/ess/skadi/__init__.py | 24 ++ packages/esssans/src/ess/skadi/data.py | 25 ++ packages/esssans/src/ess/skadi/mcstas.py | 373 ++++++++++++++++++ packages/esssans/src/ess/skadi/workflow.py | 130 ++++++ packages/esssans/tests/package_test.py | 3 +- packages/esssans/tests/skadi/mcstas_test.py | 132 +++++++ packages/esssans/tests/skadi/workflow_test.py | 41 ++ .../esssans/tools/docs/skadi-thumbnails.ipynb | 129 ++++++ .../esssans/tools/shrink_skadi_mcstas.ipynb | 212 ++++++++++ 16 files changed, 1264 insertions(+), 1 deletion(-) create mode 100644 packages/esssans/docs/_static/thumbnails/skadi_detector_view_dark.svg create mode 100644 packages/esssans/docs/_static/thumbnails/skadi_detector_view_light.svg create mode 100644 packages/esssans/docs/user-guide/skadi/index.md create mode 100644 packages/esssans/docs/user-guide/skadi/skadi-detector-view.ipynb create mode 100644 packages/esssans/src/ess/skadi/__init__.py create mode 100644 packages/esssans/src/ess/skadi/data.py create mode 100644 packages/esssans/src/ess/skadi/mcstas.py create mode 100644 packages/esssans/src/ess/skadi/workflow.py create mode 100644 packages/esssans/tests/skadi/mcstas_test.py create mode 100644 packages/esssans/tests/skadi/workflow_test.py create mode 100644 packages/esssans/tools/docs/skadi-thumbnails.ipynb create mode 100644 packages/esssans/tools/shrink_skadi_mcstas.ipynb 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 @@ + + + + + + + + 2026-08-21T15:24:47.291388 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + 2026-08-21T15:24:46.861353 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + 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 +} From 9d6c455b8e23ae0e6301856bafefa4b1199ec2c6 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 10 Sep 2026 09:25:34 +0200 Subject: [PATCH 2/3] fix --- .../esssans/src/ess/sans/normalization.py | 44 ++++++ packages/esssans/src/ess/skadi/mcstas.py | 75 ++++++---- packages/esssans/src/ess/skadi/workflow.py | 72 +-------- packages/esssans/tests/normalization_test.py | 50 +++++++ packages/esssans/tests/skadi/mcstas_test.py | 140 +++++++++++++++++- packages/esssans/tests/skadi/workflow_test.py | 48 +++++- 6 files changed, 321 insertions(+), 108 deletions(-) diff --git a/packages/esssans/src/ess/sans/normalization.py b/packages/esssans/src/ess/sans/normalization.py index cc32bdcbe..65ae6a649 100644 --- a/packages/esssans/src/ess/sans/normalization.py +++ b/packages/esssans/src/ess/sans/normalization.py @@ -27,6 +27,7 @@ Numerator, Position, ProcessedWavelengthBands, + RawDetector, ReducedQ, ReducedQxQy, ReturnEvents, @@ -90,6 +91,49 @@ def solid_angle( ) +def rectangular_pixel_solid_angle( + detector: RawDetector[RunType], + sample_position: Position[snx.NXsample, RunType], +) -> SolidAngle[RunType]: + r"""Approximate the solid angle of flat rectangular detector pixels. + + The approximation is :math:`\Delta\Omega = A |\hat{n}\cdot\hat{r}| / r^2`, + where :math:`A` is the pixel area, :math:`\hat{n}` is its unit normal, and + :math:`r` is the distance from sample to pixel. It is valid when that distance + is much larger than the pixel width and height. + + Parameters + ---------- + detector: + Detector data with calibrated geometry coordinates: ``position`` contains + pixel centers, the x and y components of ``pixel_size`` give the local + pixel width and height, and ``detector_normal`` contains dimensionless + unit normals. Positions and normals must share a coordinate system with + ``sample_position``. Pixel sizes and normals may vary between pixels. + sample_position: + Position of the sample. + + Returns + ------- + : + Solid angles with the detector's pixel coordinates and masks preserved. + """ + scattered_beam = detector.coords['position'] - sample_position + pixel_size = detector.coords['pixel_size'] + area = pixel_size.fields.x * pixel_size.fields.y + omega = ( + area + * sc.abs(sc.dot(detector.coords['detector_normal'], scattered_beam)) + / sc.norm(scattered_beam) ** 3 + ).to(unit='dimensionless') + + return SolidAngle[RunType]( + concepts.rewrap_reduced_data( + prototype=detector, data=omega, dim=set(detector.dims) - set(omega.dims) + ) + ) + + def mask_solid_angle( solid_angle: SolidAngle[RunType], masks: DetectorMasks, diff --git a/packages/esssans/src/ess/skadi/mcstas.py b/packages/esssans/src/ess/skadi/mcstas.py index e5e0ddbb5..0adaa76c6 100644 --- a/packages/esssans/src/ess/skadi/mcstas.py +++ b/packages/esssans/src/ess/skadi/mcstas.py @@ -19,12 +19,18 @@ CorrectForGravity, Filename, GravityVector, + MonitorTerm, Position, RawDetector, RunType, + WavelengthBins, WavelengthDetector, ) +# The McStas instrument places sourceMantid this far upstream of sample_position +# along the final collimation axis. The pruned files omit that virtual component. +_SOURCE_TO_SAMPLE_DISTANCE = sc.scalar(38.42, unit='m') + # 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. @@ -182,34 +188,17 @@ def _component_map(components: h5py.Group) -> dict[str, 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: +def load_skadi_mcstas(filename: str | Path) -> 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. + the variance. Events are grouped by the global McStas pixel ID. Source and sample + positions are supplied separately by the workflow's ``Position`` providers. 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 ------- @@ -282,9 +271,6 @@ def load_skadi_mcstas( 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'], @@ -304,8 +290,6 @@ def load_skadi_mcstas( 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, ) @@ -317,17 +301,37 @@ def load_skadi_mcstas_provider( def source_position_from_mcstas( - detector: RawDetector[RunType], + filename: Filename[RunType], + sample_position: Position[snx.NXsample, RunType], ) -> Position[snx.NXsource, RunType]: - """Extract the source position attached by the McStas loader.""" - return Position[snx.NXsource, RunType](detector.coords['source_position']) + """Construct the effective source using the incident direction at the sample. + + The local +z axis of ``sample_position`` follows the final collimation axis. + Using the physical moderator position instead would point across the bends in + the guide. Retain the flight-path length while aligning the source-to-sample + vector with the incident beam. Only component metadata is read. + """ + with h5py.File(_mcstas_path(filename), 'r') as file: + components = _component_map(file['entry1/instrument/components']) + rotation = sc.spatial.linear_transform( + value=components['sample_position']['Rotation'][()].T + ) + incident_direction = rotation * sc.vector([0.0, 0.0, 1.0]) + flight_path = _SOURCE_TO_SAMPLE_DISTANCE.to(unit=sample_position.unit) + return Position[snx.NXsource, RunType]( + sample_position - flight_path * incident_direction / sc.norm(incident_direction) + ) def sample_position_from_mcstas( - detector: RawDetector[RunType], + filename: Filename[RunType], ) -> Position[snx.NXsample, RunType]: - """Extract the sample position attached by the McStas loader.""" - return Position[snx.NXsample, RunType](detector.coords['sample_position']) + """Read the sample position without loading detector events.""" + with h5py.File(_mcstas_path(filename), 'r') as file: + components = _component_map(file['entry1/instrument/components']) + return Position[snx.NXsample, RunType]( + sc.vector(components['sample_position']['Position'][()], unit='m') + ) def mcstas_detector_coord_transform_graph( @@ -364,10 +368,19 @@ def mcstas_data_to_wavelength( ) +def unity_monitor_term(wavelength_bins: WavelengthBins) -> MonitorTerm[RunType]: + """Return unity normalization for McStas simulations without monitor data.""" + wavelength = sc.midpoints(wavelength_bins) + return MonitorTerm[RunType]( + sc.DataArray(sc.ones(sizes=wavelength.sizes), coords={'wavelength': wavelength}) + ) + + mcstas_providers = ( load_skadi_mcstas_provider, source_position_from_mcstas, sample_position_from_mcstas, mcstas_detector_coord_transform_graph, mcstas_data_to_wavelength, + unity_monitor_term, ) diff --git a/packages/esssans/src/ess/skadi/workflow.py b/packages/esssans/src/ess/skadi/workflow.py index d4bf61f60..bc7cfd406 100644 --- a/packages/esssans/src/ess/skadi/workflow.py +++ b/packages/esssans/src/ess/skadi/workflow.py @@ -3,9 +3,8 @@ """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.normalization import rectangular_pixel_solid_angle from ess.sans.parameters import typical_outputs from ess.reduce.uncertainty import UncertaintyBroadcastMode @@ -15,13 +14,7 @@ from ..sans.types import ( DetectorMasks, DirectBeam, - MonitorTerm, - Position, - RawDetector, ReturnEvents, - RunType, - SolidAngle, - WavelengthBins, ) from .mcstas import mcstas_providers @@ -32,67 +25,10 @@ def skadi_default_parameters() -> dict: 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) +skadi_providers = (rectangular_pixel_solid_angle,) @register_workflow @@ -104,8 +40,7 @@ def SkadiWorkflow( 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. + Mode used by the common SANS workflow to obtain wavelength. Returns ------- @@ -127,4 +62,5 @@ def SkadiMcStasWorkflow() -> sciline.Pipeline: workflow = SkadiWorkflow() for provider in mcstas_providers: workflow.insert(provider) + workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop return workflow diff --git a/packages/esssans/tests/normalization_test.py b/packages/esssans/tests/normalization_test.py index 4ea4e7a57..f7b24f600 100644 --- a/packages/esssans/tests/normalization_test.py +++ b/packages/esssans/tests/normalization_test.py @@ -7,6 +7,7 @@ import scippnexus as snx from ess.isissans.data import sans2d_solid_angle_reference from ess.sans import normalization +from scipp.testing import assert_allclose from ess.reduce.nexus.types import NeXusTransformation, SampleRun @@ -89,6 +90,55 @@ def test_solid_angle_compare_to_reference_file(): ) +@pytest.mark.parametrize('size_unit', ['m', 'mm']) +@pytest.mark.parametrize('normal_sign', [-1, 1]) +def test_rectangular_pixel_solid_angle(size_unit, normal_sign): + sample_position = sc.vector([1.0, -2.0, 0.5], unit='m') + # Normal incidence, double distance, off-axis, tilted, edge-on, double area. + detector = sc.DataArray( + sc.ones(sizes={'pixel': 6}), + coords={ + 'position': sc.vectors( + dims=['pixel'], + values=[ + [0, 0, 2], + [0, 0, 4], + [1, 0, 1], + [0, 0, 2], + [0, 0, 2], + [0, 0, 2], + ], + unit='m', + ) + + sample_position, + 'pixel_size': sc.vectors( + dims=['pixel'], + values=[[0.02, 0.03, 0.001]] * 5 + [[0.02, 0.06, 0.01]], + unit='m', + ).to(unit=size_unit), + 'detector_normal': normal_sign + * sc.vectors( + dims=['pixel'], + values=[[0, 0, 1]] * 3 + + [[np.sqrt(3) / 2, 0, 0.5], [1, 0, 0], [0, 0, 1]], + ), + }, + ) + + solid_angle = normalization.rectangular_pixel_solid_angle( + detector, sample_position=sample_position + ) + + assert_allclose( + solid_angle.data, + sc.array( + dims=['pixel'], + values=[0.00015, 0.0000375, 0.0003 / np.sqrt(2), 0.000075, 0.0, 0.0003], + unit='dimensionless', + ), + ) + + def test_transmission_fraction(): N = 100 wavelength = sc.linspace( diff --git a/packages/esssans/tests/skadi/mcstas_test.py b/packages/esssans/tests/skadi/mcstas_test.py index 15f930e30..eca5ff90c 100644 --- a/packages/esssans/tests/skadi/mcstas_test.py +++ b/packages/esssans/tests/skadi/mcstas_test.py @@ -5,17 +5,23 @@ import h5py import numpy as np +import pytest import scipp as sc +import scippnexus as snx +from ess.sans.conversions import ElasticCoordTransformGraph from ess.sans.types import ( + DetectorMasks, Filename, IntensityQ, + Position, QBins, + RawDetector, SampleRun, WavelengthBins, WavelengthDetector, ) from ess.skadi import SkadiMcStasWorkflow, load_skadi_mcstas -from scipp.testing import assert_allclose +from scipp.testing import assert_allclose, assert_identical def _component( @@ -30,7 +36,10 @@ def _component( def _small_mcstas_file( - path: Path, *, detector_rotation: np.ndarray | None = None + path: Path, + *, + detector_rotation: np.ndarray | None = None, + sample_rotation: np.ndarray | None = None, ) -> Path: with h5py.File(path, 'w') as file: entry = file.create_group('entry1') @@ -57,7 +66,12 @@ def _small_mcstas_file( 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, + '0002_sample_position', + [0.0, 0.0, 10.0], + rotation=sample_rotation, + ) _component( components, '0003_detector_0', @@ -76,6 +90,101 @@ def test_mcstas_loader_groups_events_by_pixel_id(tmp_path: Path) -> None: assert events_per_pixel['detector_number', 2:].sum().value == 0 +def test_mcstas_loader_does_not_attach_source_or_sample_position( + tmp_path: Path, +) -> None: + detector = load_skadi_mcstas(_small_mcstas_file(tmp_path / 'mccode.h5')) + + assert 'source_position' not in detector.coords + assert 'sample_position' not in detector.coords + + +@pytest.mark.parametrize('use_directory', [False, True]) +def test_mcstas_positions_do_not_require_detector_data_S4( + tmp_path: Path, use_directory: bool +) -> None: + # The sample frame is rotated so the outgoing collimation axis is global -x. + rotation = np.array([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]) + filename = _small_mcstas_file(tmp_path / 'mccode.h5', sample_rotation=rotation) + with h5py.File(filename, 'r+') as file: + del file['entry1/data'] + workflow = SkadiMcStasWorkflow() + workflow[Filename[SampleRun]] = tmp_path if use_directory else filename + + sample = workflow.compute(Position[snx.NXsample, SampleRun]) + source = workflow.compute(Position[snx.NXsource, SampleRun]) + + assert_identical(sample, sc.vector([0.0, 0.0, 10.0], unit='m')) + # Preserve the instrument's 38.42 m flight path, not the 10 m straight-line + # separation between moderator and sample in this synthetic file. + assert_allclose(source, sc.vector([38.42, 0.0, 10.0], unit='m')) + + +def test_mcstas_effective_source_gives_zero_q_along_incident_beam( + tmp_path: Path, +) -> None: + rotation = np.array([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]) + filename = _small_mcstas_file(tmp_path / 'mccode.h5', sample_rotation=rotation) + workflow = SkadiMcStasWorkflow() + workflow[Filename[SampleRun]] = filename + graph = workflow.compute(ElasticCoordTransformGraph[SampleRun]) + detector = sc.DataArray( + sc.scalar(1.0), + coords={ + 'position': sc.vector([-2.0, 0.0, 10.0], unit='m'), + 'tof': sc.scalar(0.012, unit='s'), + }, + ) + + converted = detector.transform_coords(['Q', 'L1'], graph=graph) + + assert_allclose(converted.coords['L1'].to(unit='m'), sc.scalar(38.42, unit='m')) + assert_allclose( + converted.coords['Q'], + sc.scalar(0.0, unit='1/angstrom'), + atol=sc.scalar(1e-14, unit='1/angstrom'), + ) + + +@pytest.mark.parametrize( + ('component', 'position'), + [(snx.NXsource, [0.0, 0.0, 5.0]), (snx.NXsample, [1.0, 0.0, 10.0])], +) +def test_mcstas_workflow_uses_position_overrides_for_wavelength( + tmp_path: Path, component: type, position: list[float] +) -> None: + filename = _small_mcstas_file(tmp_path / 'mccode.h5') + workflow = SkadiMcStasWorkflow() + workflow[Filename[SampleRun]] = filename + source = sc.vector([0.0, 0.0, 0.0], unit='m') + sample = sc.vector([0.0, 0.0, 10.0], unit='m') + workflow[Position[snx.NXsource, SampleRun]] = source + workflow[Position[snx.NXsample, SampleRun]] = sample + original = workflow.compute(WavelengthDetector[SampleRun]) + updated = sc.vector(position, unit='m') + workflow[Position[component, SampleRun]] = updated + if component is snx.NXsource: + source = updated + else: + sample = updated + + detector = workflow.compute(WavelengthDetector[SampleRun]) + + distance = sc.norm(sample - source) + sc.norm( + detector.coords['position'][0] - sample + ) + expected = ( + sc.constants.h / sc.constants.m_n * sc.scalar(0.012, unit='s') / distance + ).to(unit='angstrom') + assert_allclose( + detector.bins.constituents['data'].coords['wavelength'][0], expected + ) + assert not sc.allclose( + detector.bins.constituents['data'].coords['wavelength'], + original.bins.constituents['data'].coords['wavelength'], + ) + + 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]]) @@ -99,6 +208,7 @@ def test_mcstas_workflow_converts_event_time_to_wavelength(tmp_path: Path) -> No filename = _small_mcstas_file(tmp_path / 'mccode.h5') workflow = SkadiMcStasWorkflow() workflow[Filename[SampleRun]] = filename + workflow[Position[snx.NXsource, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') detector = workflow.compute(WavelengthDetector[SampleRun]) events = detector.bins.constituents['data'] @@ -120,6 +230,7 @@ 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[Position[snx.NXsource, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') workflow[WavelengthBins] = sc.linspace( 'wavelength', start=2.0, stop=8.0, num=31, unit='angstrom' ) @@ -130,3 +241,26 @@ def test_mcstas_workflow_computes_intensity_q(tmp_path: Path) -> None: assert intensity.dims == ('Q',) assert intensity.sizes == {'Q': 50} assert sc.isfinite(intensity.data).any().value + + +def test_mcstas_reduction_preserves_masks_attached_to_raw_detector( + tmp_path: Path, +) -> None: + filename = _small_mcstas_file(tmp_path / 'mccode.h5') + workflow = SkadiMcStasWorkflow() + workflow[Filename[SampleRun]] = filename + workflow[Position[snx.NXsource, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') + workflow[WavelengthBins] = sc.linspace('wavelength', 2.0, 8.0, 31, unit='angstrom') + workflow[QBins] = sc.linspace('Q', 0.0, 1.0, 2, unit='1/angstrom') + raw = workflow.compute(RawDetector[SampleRun]) + mask = raw.coords['detector_number'] > sc.scalar(0, unit=None) + workflow[DetectorMasks] = {'pixel_mask': mask} + expected = workflow.compute(IntensityQ[SampleRun]) + workflow[DetectorMasks] = {} + workflow[RawDetector[SampleRun]] = raw.assign_masks(pixel_mask=mask) + + actual = workflow.compute(IntensityQ[SampleRun]) + + assert sc.isfinite(expected.data).all().value + assert (expected.data > sc.scalar(0.0)).all().value + assert_allclose(actual, expected) diff --git a/packages/esssans/tests/skadi/workflow_test.py b/packages/esssans/tests/skadi/workflow_test.py index 6d0f4ab69..0eb1395aa 100644 --- a/packages/esssans/tests/skadi/workflow_test.py +++ b/packages/esssans/tests/skadi/workflow_test.py @@ -1,14 +1,17 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import pytest import scipp as sc import scippnexus as snx from ess.sans.types import Position, RawDetector, SampleRun, SolidAngle from ess.skadi import SkadiWorkflow +from scipp.testing import assert_allclose, assert_identical -def test_workflow_computes_solid_angle_from_calibrated_detector() -> None: - detector = sc.DataArray( +@pytest.fixture +def detector() -> sc.DataArray: + return sc.DataArray( sc.ones(sizes={'detector_number': 2}), coords={ 'position': sc.vectors( @@ -28,6 +31,11 @@ def test_workflow_computes_solid_angle_from_calibrated_detector() -> None: ), }, ) + + +def test_workflow_computes_solid_angle_from_calibrated_detector( + detector: sc.DataArray, +) -> None: workflow = SkadiWorkflow() workflow[RawDetector[SampleRun]] = detector workflow[Position[snx.NXsample, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') @@ -35,7 +43,35 @@ def test_workflow_computes_solid_angle_from_calibrated_detector() -> None: 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 + assert_allclose( + solid_angle.data, + sc.array( + dims=['detector_number'], + values=[0.00015, 0.0012 / 5**1.5], + unit='dimensionless', + ), + ) + + +def test_solid_angle_preserves_pixel_masks_and_drops_wavelength_masks( + detector: sc.DataArray, +) -> None: + detector = sc.broadcast( + detector, sizes={'detector_number': 2, 'wavelength': 3} + ).copy() + pixel_mask = sc.array(dims=['detector_number'], values=[False, True]) + detector.masks['pixel_mask'] = pixel_mask + detector.masks['wavelength_mask'] = sc.array( + dims=['wavelength'], values=[False, True, False] + ) + detector.coords['wavelength'] = sc.arange('wavelength', 4, unit='angstrom') + 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.dims == ('detector_number',) + assert_identical(solid_angle.masks['pixel_mask'], pixel_mask) + assert 'wavelength_mask' not in solid_angle.masks + assert 'wavelength' not in solid_angle.coords From 10a41aab54286e48f4efdd2c17ed833690d659eb Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 10 Sep 2026 15:12:04 +0200 Subject: [PATCH 3/3] fix --- packages/esssans/src/ess/skadi/__init__.py | 2 +- packages/esssans/src/ess/skadi/mcstas.py | 51 +++-- packages/esssans/src/ess/skadi/workflow.py | 12 +- packages/esssans/tests/package_test.py | 2 +- packages/esssans/tests/skadi/mcstas_test.py | 177 +++++++++++++----- packages/esssans/tests/skadi/workflow_test.py | 40 +--- .../esssans/tools/shrink_skadi_mcstas.ipynb | 18 +- pixi.lock | 83 ++++---- 8 files changed, 225 insertions(+), 160 deletions(-) diff --git a/packages/esssans/src/ess/skadi/__init__.py b/packages/esssans/src/ess/skadi/__init__.py index 0b8b84c20..0709df48d 100644 --- a/packages/esssans/src/ess/skadi/__init__.py +++ b/packages/esssans/src/ess/skadi/__init__.py @@ -8,7 +8,7 @@ from .workflow import SkadiMcStasWorkflow, SkadiWorkflow, skadi_default_parameters try: - __version__ = importlib.metadata.version(__package__ or __name__) + __version__ = importlib.metadata.version("esssans") except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" diff --git a/packages/esssans/src/ess/skadi/mcstas.py b/packages/esssans/src/ess/skadi/mcstas.py index 0adaa76c6..b9b73a7ed 100644 --- a/packages/esssans/src/ess/skadi/mcstas.py +++ b/packages/esssans/src/ess/skadi/mcstas.py @@ -47,13 +47,16 @@ @dataclass(frozen=True) class _DetectorSpec: - group_name: str + group: h5py.Group 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 event_count(self) -> int: + return self.group['events'].shape[0] @property def pixel_count(self) -> int: @@ -78,12 +81,10 @@ 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: +def _parse_detector_spec(group: h5py.Group) -> _DetectorSpec: options = _decode(group.attrs['options']) x_match = _X_OPTIONS.search(options) y_match = _Y_OPTIONS.search(options) @@ -91,13 +92,12 @@ def _parse_detector_spec(name: str, group: h5py.Group) -> _DetectorSpec: 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, + group=group, 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], ) @@ -210,8 +210,8 @@ def load_skadi_mcstas(filename: str | Path) -> sc.DataArray: data_groups = file['entry1/data'] specs = sorted( ( - _parse_detector_spec(name, group) - for name, group in data_groups.items() + _parse_detector_spec(group) + for group in data_groups.values() if isinstance(group, h5py.Group) and 'events' in group ), key=lambda spec: spec.pixel_min, @@ -257,13 +257,15 @@ def load_skadi_mcstas(filename: str | Path) -> sc.DataArray: 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() + group = spec.group + columns = _decode(group.attrs['variables']).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}") + ids = events[:, column['id']] + if not np.all( + (ids >= spec.pixel_min) & (ids < pixel_stop) & (ids == np.floor(ids)) + ): + raise ValueError(f"Invalid pixel ID in {group.name!r}") event_stop = event_cursor + spec.event_count weights[event_cursor:event_stop] = events[:, column['p']] @@ -368,11 +370,22 @@ def mcstas_data_to_wavelength( ) -def unity_monitor_term(wavelength_bins: WavelengthBins) -> MonitorTerm[RunType]: - """Return unity normalization for McStas simulations without monitor data.""" - wavelength = sc.midpoints(wavelength_bins) +def flat_monitor_term(wavelength_bins: WavelengthBins) -> MonitorTerm[RunType]: + """Assume unit incident intensity per angstrom when no monitor is available. + + The SANS denominator expects intensity integrated over each wavelength bin. + Weight by bin width so changing the binning does not rescale the result. + This flat spectrum is a placeholder for relative intensities; calibrated + reductions require a monitor spectrum and transmission correction. + """ + weights = (wavelength_bins[1:] - wavelength_bins[:-1]) / sc.scalar( + 1.0, unit='angstrom' + ) return MonitorTerm[RunType]( - sc.DataArray(sc.ones(sizes=wavelength.sizes), coords={'wavelength': wavelength}) + sc.DataArray( + weights.to(unit='dimensionless'), + coords={'wavelength': sc.midpoints(wavelength_bins)}, + ) ) @@ -382,5 +395,5 @@ def unity_monitor_term(wavelength_bins: WavelengthBins) -> MonitorTerm[RunType]: sample_position_from_mcstas, mcstas_detector_coord_transform_graph, mcstas_data_to_wavelength, - unity_monitor_term, + flat_monitor_term, ) diff --git a/packages/esssans/src/ess/skadi/workflow.py b/packages/esssans/src/ess/skadi/workflow.py index bc7cfd406..394ca95d8 100644 --- a/packages/esssans/src/ess/skadi/workflow.py +++ b/packages/esssans/src/ess/skadi/workflow.py @@ -28,9 +28,6 @@ def skadi_default_parameters() -> dict: } -skadi_providers = (rectangular_pixel_solid_angle,) - - @register_workflow def SkadiWorkflow( wavelength_from: WavelengthLutMode = "file", @@ -48,8 +45,7 @@ def SkadiWorkflow( The SKADI reduction workflow. """ workflow = sans.SansWorkflow(wavelength_from=wavelength_from) - for provider in skadi_providers: - workflow.insert(provider) + workflow.insert(rectangular_pixel_solid_angle) for key, value in skadi_default_parameters().items(): workflow[key] = value workflow.typical_outputs = typical_outputs @@ -58,7 +54,11 @@ def SkadiWorkflow( @register_workflow def SkadiMcStasWorkflow() -> sciline.Pipeline: - """Create the basic SKADI workflow with the McStas input adapter.""" + """Create the SKADI McStas workflow for relative intensity reductions. + + In the absence of monitor data, assume a flat incident spectrum with unit + intensity per angstrom and unit transmission. + """ workflow = SkadiWorkflow() for provider in mcstas_providers: workflow.insert(provider) diff --git a/packages/esssans/tests/package_test.py b/packages/esssans/tests/package_test.py index 904f40601..b0bc9bda8 100644 --- a/packages/esssans/tests/package_test.py +++ b/packages/esssans/tests/package_test.py @@ -13,7 +13,7 @@ def test_has_version(): assert hasattr(isissans, '__version__') assert hasattr(loki, '__version__') assert hasattr(sans, '__version__') - assert hasattr(skadi, '__version__') + assert skadi.__version__ == sans.__version__ if __name__ == '__main__': diff --git a/packages/esssans/tests/skadi/mcstas_test.py b/packages/esssans/tests/skadi/mcstas_test.py index eca5ff90c..00f32968c 100644 --- a/packages/esssans/tests/skadi/mcstas_test.py +++ b/packages/esssans/tests/skadi/mcstas_test.py @@ -17,10 +17,12 @@ QBins, RawDetector, SampleRun, + SolidAngle, WavelengthBins, WavelengthDetector, ) from ess.skadi import SkadiMcStasWorkflow, load_skadi_mcstas +from ess.skadi.mcstas import flat_monitor_term from scipp.testing import assert_allclose, assert_identical @@ -40,6 +42,7 @@ def _small_mcstas_file( *, detector_rotation: np.ndarray | None = None, sample_rotation: np.ndarray | None = None, + pixels_per_side: int = 8, ) -> Path: with h5py.File(path, 'w') as file: entry = file.create_group('entry1') @@ -48,8 +51,8 @@ def _small_mcstas_file( 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, ' + f'mantid square x limits=[-0.024,0.024] bins={pixels_per_side} ' + f'y limits=[-0.024,0.024] bins={pixels_per_side}, neutron pixel min=0 t, ' 'list all neutrons' ) detector.create_dataset( @@ -81,26 +84,71 @@ def _small_mcstas_file( 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')) +def test_mcstas_loader_groups_weighted_events_across_tiles(tmp_path: Path) -> None: + filename = _small_mcstas_file(tmp_path / 'mccode.h5') + with h5py.File(filename, 'r+') as file: + data = file['entry1/data'] + # Group names deliberately sort in the opposite order to pixel IDs. + for name, start, count in [('b_second', 64, 3), ('a_empty', 128, 0)]: + file.copy(data['detector_events'], data, name=name) + group = data[name] + group.attrs['options'] = group.attrs['options'].replace( + 'pixel min=0', f'pixel min={start}' + ) + group.attrs['component'] = f'detector_{start}' + _component( + file['entry1/instrument/components'], + f'0004_detector_{start}', + [float(start), 0.0, 12.0], + ) + events = group['events'][()] + events[:, 4] += start + # Exercise column metadata rather than assuming a fixed column order. + group.attrs['variables'] = np.bytes_('t id p x y n') + del group['events'] + group.create_dataset('events', data=events[:count, [5, 4, 0, 1, 2, 3]]) + + detector = load_skadi_mcstas(filename) 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 + assert detector.sizes == {'detector_number': 192} + for start in (0, 64): + assert events_per_pixel['detector_number', start].value == 1 + assert events_per_pixel['detector_number', start + 1].value == 2 + assert ( + events_per_pixel['detector_number', start + 2 : start + 64].sum().value == 0 + ) + events = detector['detector_number', start + 1].value + assert_identical( + events.data, + sc.array(dims=['event'], values=[2.0, 3.0], variances=[4.0, 9.0]), + ) + assert_identical( + events.coords['event_time_offset'], + sc.array(dims=['event'], values=[0.014, 0.016], unit='s'), + ) + assert events_per_pixel['detector_number', 128:].sum().value == 0 + for start in (64, 128): + assert_allclose( + detector.coords['position'][start] - detector.coords['position'][0], + sc.vector([float(start), 0.0, 0.0], unit='m'), + ) -def test_mcstas_loader_does_not_attach_source_or_sample_position( - tmp_path: Path, +@pytest.mark.parametrize('pixel_id', [-1.0, -0.5, 1.9, 64.0, np.nan, np.inf]) +def test_mcstas_loader_rejects_invalid_pixel_ids( + tmp_path: Path, pixel_id: float ) -> None: - detector = load_skadi_mcstas(_small_mcstas_file(tmp_path / 'mccode.h5')) + filename = _small_mcstas_file(tmp_path / 'mccode.h5') + with h5py.File(filename, 'r+') as file: + file['entry1/data/detector_events/events'][0, 4] = pixel_id - assert 'source_position' not in detector.coords - assert 'sample_position' not in detector.coords + with pytest.raises(ValueError, match='Invalid pixel ID'): + load_skadi_mcstas(filename) @pytest.mark.parametrize('use_directory', [False, True]) -def test_mcstas_positions_do_not_require_detector_data_S4( +def test_mcstas_positions_do_not_require_detector_data( tmp_path: Path, use_directory: bool ) -> None: # The sample frame is rotated so the outgoing collimation axis is global -x. @@ -185,62 +233,93 @@ def test_mcstas_workflow_uses_position_overrides_for_wavelength( ) -def test_mcstas_loader_uses_mcstas_rotation_convention(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ('pixels_per_side', 'pixel_width', 'corner'), + [(8, 0.006, 0.021125), (16, 0.003, 0.022625)], +) +def test_mcstas_loader_corrects_and_rotates_pixel_geometry( + tmp_path: Path, pixels_per_side: int, pixel_width: float, corner: float +) -> 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) + detector = load_skadi_mcstas( + _small_mcstas_file( + tmp_path / 'mccode.h5', + detector_rotation=rotation, + pixels_per_side=pixels_per_side, + ) ) - 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]), + detector.coords['position'][0], + sc.vector([0.0, -corner, 12.0 - corner], unit='m'), + ) + assert_allclose( + detector.coords['position'][-1], + sc.vector([0.0, corner, 12.0 + corner], unit='m'), + ) + assert_allclose( + detector.coords['position'][1] - detector.coords['position'][0], + sc.vector([0.0, 0.0, pixel_width + 0.000125], unit='m'), + ) + assert_allclose( + detector.coords['position'][pixels_per_side] - detector.coords['position'][0], + sc.vector([0.0, pixel_width + 0.000125, 0.0], unit='m'), ) + assert_identical(detector.coords['detector_normal'][0], sc.vector([-1.0, 0.0, 0.0])) + for index in (0, pixels_per_side**2 - 1): + assert_allclose( + detector.coords['pixel_size'][index], + sc.vector([pixel_width + 0.00025, pixel_width + 0.00025, 0.001], unit='m'), + ) assert_allclose( - rotated.coords['detector_normal'][0], - transform * unrotated.coords['detector_normal'][0], + detector.coords['pixel_size'][pixels_per_side + 1], + sc.vector([pixel_width, pixel_width, 0.001], unit='m'), ) -def test_mcstas_workflow_converts_event_time_to_wavelength(tmp_path: Path) -> None: +@pytest.mark.parametrize( + 'edges', + [np.linspace(2.0, 8.0, 16), np.linspace(2.0, 8.0, 31), [2.0, 3.0, 5.0, 8.0]], + ids=['coarse', 'fine', 'uneven'], +) +def test_mcstas_intensity_normalization_is_independent_of_wavelength_binning( + tmp_path: Path, edges: list[float] | np.ndarray +) -> None: filename = _small_mcstas_file(tmp_path / 'mccode.h5') workflow = SkadiMcStasWorkflow() workflow[Filename[SampleRun]] = filename workflow[Position[snx.NXsource, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') - - 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') + workflow[WavelengthBins] = sc.array( + dims=['wavelength'], values=edges, unit='angstrom' ) - expected = ( - sc.constants.h - / sc.constants.m_n - * sc.scalar(0.012, unit='s') - / (source_to_sample + sample_to_pixel) - ).to(unit='angstrom') + workflow[QBins] = sc.linspace('Q', 0.0, 1.0, 2, unit='1/angstrom') - assert sc.allclose(events.coords['wavelength'][0], expected) + intensity = workflow.compute(IntensityQ[SampleRun]) + # All events and pixels contribute to one Q bin over a six-angstrom band. + solid_angle = workflow.compute(SolidAngle[SampleRun]).sum().data + expected = sc.scalar(6.0, variance=14.0) / (6.0 * solid_angle) + assert intensity.sizes == {'Q': 1} + assert_allclose(intensity.data[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[Position[snx.NXsource, SampleRun]] = sc.vector([0.0, 0.0, 0.0], unit='m') - 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]) +def test_flat_monitor_term_accounts_for_wavelength_units() -> None: + edges = sc.array(dims=['wavelength'], values=[0.2, 0.3, 0.5, 0.8], unit='nm') + + monitor = flat_monitor_term(edges) - assert intensity.dims == ('Q',) - assert intensity.sizes == {'Q': 50} - assert sc.isfinite(intensity.data).any().value + assert_allclose( + monitor, + sc.DataArray( + sc.array(dims=['wavelength'], values=[1.0, 2.0, 3.0]), + coords={ + 'wavelength': sc.array( + dims=['wavelength'], values=[0.25, 0.4, 0.65], unit='nm' + ) + }, + ), + ) def test_mcstas_reduction_preserves_masks_attached_to_raw_detector( diff --git a/packages/esssans/tests/skadi/workflow_test.py b/packages/esssans/tests/skadi/workflow_test.py index 0eb1395aa..20f457256 100644 --- a/packages/esssans/tests/skadi/workflow_test.py +++ b/packages/esssans/tests/skadi/workflow_test.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026 Scipp contributors (https://github.com/scipp) -import pytest import scipp as sc import scippnexus as snx from ess.sans.types import Position, RawDetector, SampleRun, SolidAngle @@ -9,9 +8,8 @@ from scipp.testing import assert_allclose, assert_identical -@pytest.fixture -def detector() -> sc.DataArray: - return sc.DataArray( +def test_workflow_computes_solid_angle_preserving_pixel_masks() -> None: + detector = sc.DataArray( sc.ones(sizes={'detector_number': 2}), coords={ 'position': sc.vectors( @@ -31,31 +29,6 @@ def detector() -> sc.DataArray: ), }, ) - - -def test_workflow_computes_solid_angle_from_calibrated_detector( - detector: sc.DataArray, -) -> None: - 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_allclose( - solid_angle.data, - sc.array( - dims=['detector_number'], - values=[0.00015, 0.0012 / 5**1.5], - unit='dimensionless', - ), - ) - - -def test_solid_angle_preserves_pixel_masks_and_drops_wavelength_masks( - detector: sc.DataArray, -) -> None: detector = sc.broadcast( detector, sizes={'detector_number': 2, 'wavelength': 3} ).copy() @@ -71,7 +44,14 @@ def test_solid_angle_preserves_pixel_masks_and_drops_wavelength_masks( solid_angle = workflow.compute(SolidAngle[SampleRun]) - assert solid_angle.dims == ('detector_number',) + assert_allclose( + solid_angle.data, + sc.array( + dims=['detector_number'], + values=[0.00015, 0.0012 / 5**1.5], + unit='dimensionless', + ), + ) assert_identical(solid_angle.masks['pixel_mask'], pixel_mask) assert 'wavelength_mask' not in solid_angle.masks assert 'wavelength' not in solid_angle.coords diff --git a/packages/esssans/tools/shrink_skadi_mcstas.ipynb b/packages/esssans/tools/shrink_skadi_mcstas.ipynb index 9ea0f292f..03258fe7a 100644 --- a/packages/esssans/tools/shrink_skadi_mcstas.ipynb +++ b/packages/esssans/tools/shrink_skadi_mcstas.ipynb @@ -66,22 +66,19 @@ "\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", + " for name, group in list(data_groups.items()):\n", + " if not isinstance(group, h5py.Group) or \"events\" not in group:\n", + " del data_groups[name]\n", + " event_groups = list(data_groups.values())\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", + " required_component_names.add(\"sample_position\")\n", "\n", " components = file[\"entry1/instrument/components\"]\n", " component_names = {\n", @@ -99,11 +96,8 @@ " 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", @@ -149,8 +143,6 @@ " 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", " }" diff --git a/pixi.lock b/pixi.lock index 657c07c42..843b70c7e 100644 --- a/pixi.lock +++ b/pixi.lock @@ -6,13 +6,13 @@ platforms: - __linux=4.18 - __glibc=2.28 - __archspec=0=x86_64 -- name: p1 +- name: osx-64-macos-14-0 subdir: osx-64 virtual-packages: - __osx=14.0 - __unix=0=0 - __archspec=0=x86_64 -- name: p2 +- name: osx-arm64-macos-14-0 subdir: osx-arm64 virtual-packages: - __osx=14.0 @@ -292,7 +292,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -509,7 +509,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -1205,7 +1205,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -1411,7 +1411,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -2078,7 +2078,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -2275,7 +2275,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -2925,7 +2925,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -3123,7 +3123,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -3761,7 +3761,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -3948,7 +3948,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -4582,7 +4582,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -4784,7 +4784,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -5440,7 +5440,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -5634,7 +5634,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -6269,7 +6269,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -6458,7 +6458,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -7036,7 +7036,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -7177,7 +7177,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -7661,7 +7661,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -7803,7 +7803,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -8282,7 +8282,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -8417,7 +8417,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -8872,7 +8872,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -9000,7 +9000,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -9447,7 +9447,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -9578,7 +9578,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -10035,7 +10035,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -10170,7 +10170,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -10629,7 +10629,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -10758,7 +10758,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -11063,7 +11063,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - p1: + osx-64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda @@ -11089,7 +11089,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - p2: + osx-arm64-macos-14-0: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda @@ -14460,7 +14460,7 @@ packages: - spglib>=2.0.0,!=2.7 - pandas>=2.1.2 ; extra == 'test' - pooch>=1.5 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - ipywidgets>=8.1.7 ; extra == 'test' - anywidget>=0.8.0 ; extra == 'test' - pythreejs>=2.4.1 ; extra == 'test' @@ -14490,7 +14490,7 @@ packages: - tifffile>=2024.7.2 - essreduce>=26.6.2 - scitiff>=26.6 - - pytest>=8.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - pooch>=1.5 ; extra == 'test' - tof>=26.3.0 ; extra == 'test' - scitiff>=24.6.0 ; extra == 'test' @@ -14523,7 +14523,7 @@ packages: - tof>=25.12.1 - numpy>=2.0.0 - tof>=26.6.0 - - pytest>=8.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - bitshuffle>=0.5.2 ; os_name == 'posix' and extra == 'test' - autodoc-pydantic>=2.0.0 ; extra == 'docs' - ipykernel>=6.20.0 ; extra == 'docs' @@ -14553,7 +14553,7 @@ packages: - matplotlib>=3.10.7 ; extra == 'test' - numba>=0.63 ; extra == 'test' - pooch>=1.9.0 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - tof>=25.12.0 ; extra == 'test' - pandas>=2.1.2 ; extra == 'test' - autodoc-pydantic>=2.0.0 ; extra == 'docs' @@ -14585,7 +14585,7 @@ packages: - essreduce>=26.6.0 - esspolarization>=25.7.0 - pandas>=2.1.2 - - pytest>=7.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - pooch>=1.5 ; extra == 'test' - ipywidgets>=8.1.7 ; extra == 'gui' - ipydatagrid>=1.3.0 ; extra == 'gui' @@ -14615,12 +14615,13 @@ packages: requires_dist: - dask>=2022.1.0 - graphviz>=0.20 + - h5py>=3.11 - essreduce>=26.6.0 - numpy>=1.26.4 - pandas>=2.1.2 - plopp>=26.5.0 - ipympl>=0.9.4 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - pooch>=1.5 ; extra == 'test' - scipy>=1.15.0 ; extra == 'test' - anywidget>=0.8.0 ; extra == 'test' @@ -14648,7 +14649,7 @@ packages: - pandas>=2.1.2 - tof>=25.11.0 - pooch>=1.5 ; extra == 'test' - - pytest>=7.0 ; extra == 'test' + - pytest>=9.0 ; extra == 'test' - autodoc-pydantic>=2.0.0 ; extra == 'docs' - ipykernel>=6.20.0 ; extra == 'docs' - ipympl>=0.9.0 ; extra == 'docs'