diff --git a/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb b/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb index 02d0f3f..b8eaf3d 100644 --- a/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb +++ b/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb @@ -62,7 +62,7 @@ "workflow[LookupTableFilename] = lookup_table_simulation()\n", "workflow[LookupTableRelativeErrorThreshold] = {\n", " 'detector': np.inf,\n", - " '110_frame_3': np.inf,\n", + " 'normalization_monitor': np.inf,\n", "}" ] }, @@ -89,7 +89,7 @@ "metadata": {}, "outputs": [], "source": [ - "workflow[NeXusDetectorName] = \"309_channel_9_5_triplet\"\n", + "workflow[NeXusDetectorName] = \"channel_9_5_triplet\"\n", "\n", "\n", "def assemble_detector_data_flatten(\n", @@ -139,18 +139,10 @@ "workflow.visualize(CountsWithQMapCoords[SampleRun], graph_attr={\"rankdir\": \"LR\"})" ] }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "Compute the data projected onto Q:" - ] - }, { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -159,7 +151,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "9", "metadata": {}, "source": [ "Make an interactive figure.\n", @@ -169,13 +161,13 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "10", "metadata": {}, "outputs": [], "source": [ "make_q_map(counts,\n", - " q_parallel_bins=sc.linspace('Q_parallel', -3, 3, 100, unit='1/Å'),\n", - " q_perpendicular_bins=sc.linspace('Q_perpendicular', -3, 3, 100, unit='1/Å'),\n", + " q_parallel_bins=sc.linspace('Q_parallel', -4.5, 4.5, 100, unit='1/Å'),\n", + " q_perpendicular_bins=sc.linspace('Q_perpendicular', -4.5, 4.5, 100, unit='1/Å'),\n", " sample_rotation_bins=sc.scalar(1.0, unit='deg'),\n", " )" ] diff --git a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb index e864781..716cfac 100644 --- a/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb +++ b/docs/user-guide/bifrost/bifrost-make-wavelength-lookup-table.ipynb @@ -69,7 +69,7 @@ "bifrost_workflow = BifrostSimulationWorkflow(detector_names)\n", "bifrost_workflow[Filename[SampleRun]] = input_filename\n", "\n", - "M = nexus.types.EmptyMonitor[SampleRun, FrameMonitor3]\n", + "M = nexus.types.EmptyMonitor[SampleRun, NormalizationMonitor]\n", "C = RawChoppers[SampleRun]\n", "SOURCE = Position[snx.NXsource, SampleRun]\n", "SAMPLE = Position[snx.NXsample, SampleRun]\n", @@ -128,6 +128,13 @@ " processed['phase'] = processed['phase'].data.mean()\n", " # Guessing here as this is not stored in the file.\n", " processed['beam_position'] = sc.scalar(0.0, unit='deg')\n", + "\n", + " # FIXME Get rid of this bad hack if/when McStas' DiskChopper is fixed\n", + " # Currently, it sets a delay-time by `delta = 2 * pi * phase/|frequency|`\n", + " # But it should set `delta = 2 * pi * phase / frequency`\n", + " # So phases reported here have the wrong sign if frequency < 0\n", + " processed['phase'] = -processed['phase'] if processed['rotation_speed'].value < 0 else processed['phase']\n", + "\n", " return DiskChopper.from_nexus(processed)\n", "\n", "\n", @@ -163,7 +170,7 @@ "workflow[SourcePosition] = source_position\n", "\n", "# Increase this number for more reliable results:\n", - "workflow[NumberOfSimulatedNeutrons] = 6000_000" + "workflow[NumberOfSimulatedNeutrons] = 6_000_000" ] }, { diff --git a/docs/user-guide/bifrost/bifrost-reduction.ipynb b/docs/user-guide/bifrost/bifrost-reduction.ipynb index 7da4618..497ba21 100644 --- a/docs/user-guide/bifrost/bifrost-reduction.ipynb +++ b/docs/user-guide/bifrost/bifrost-reduction.ipynb @@ -96,7 +96,7 @@ "# For this example, we do not mask anything:\n", "workflow[LookupTableRelativeErrorThreshold] = {\n", " 'detector': np.inf,\n", - " '110_frame_3': np.inf,\n", + " 'normalization_monitor': np.inf,\n", "}\n", "# We need to read many objects from the file,\n", "# keeping it open improves performance: (optional)\n", @@ -183,9 +183,9 @@ "outputs": [], "source": [ "(\n", - " data['a4', 0]\n", + " data\n", " .bins.concat()\n", - " .hist(energy_transfer=sc.linspace('energy_transfer', -0.05, 0.05, 200, unit='meV'))\n", + " .hist(energy_transfer=500)\n", ").plot()" ] }, @@ -211,7 +211,7 @@ "metadata": {}, "outputs": [], "source": [ - "d = data['a4', 0].bins.concat().copy()\n", + "d = data.bins.concat().copy()\n", "x = sc.vector([1, 0, 0])\n", "z = sc.vector([0, 0, 1])\n", "d.bins.coords['Qx'] = sc.dot(x, d.bins.coords['sample_table_momentum_transfer'])\n", diff --git a/docs/user-guide/bifrost/index.md b/docs/user-guide/bifrost/index.md index c982681..380fe16 100644 --- a/docs/user-guide/bifrost/index.md +++ b/docs/user-guide/bifrost/index.md @@ -1,4 +1,5 @@ # BIFROST + ## Reduction Workflows ::::{grid} 3 @@ -11,10 +12,12 @@ :class: only-light :width: 100% ``` + ```{image} ../../_static/thumbnails/bifrost_reduction_dark.svg :class: only-dark :width: 100% ``` + ::: :::: @@ -25,7 +28,6 @@ hidden: --- bifrost-reduction -bifrost-bragg-peak-monitor ``` ## Advanced Tools @@ -35,5 +37,6 @@ bifrost-bragg-peak-monitor maxdepth: 1 --- +bifrost-bragg-peak-monitor bifrost-make-wavelength-lookup-table ``` diff --git a/pyproject.toml b/pyproject.toml index 3a7d263..51c8435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ requires-python = ">=3.11" # Run 'tox -e deps' after making changes here. This will update requirement files. # Make sure to list one dependency per line. dependencies = [ - "essreduce>=26.4.0", + "essreduce>=26.4.1", "graphviz>=0.20", "pandas>=2.1.2", "sciline>=25.4.1", diff --git a/requirements/base.in b/requirements/base.in index e6807c8..9b9d0b5 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,7 +2,7 @@ # will not be touched by ``make_base.py`` # --- END OF CUSTOM SECTION --- # The following was generated by 'tox -e deps', DO NOT EDIT MANUALLY! -essreduce>=26.4.0 +essreduce>=26.4.1 graphviz>=0.20 pandas>=2.1.2 sciline>=25.4.1 diff --git a/requirements/base.txt b/requirements/base.txt index 8467d71..3f66a0c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:b30fe4f8b8899208801c1e28bbca8fdf94dbc938 +# SHA1:db0b6133057674a1a7c9f64ad9fe7e4f411c9e8f # # This file was generated by pip-compile-multi. # To update, run: @@ -9,7 +9,7 @@ annotated-types==0.7.0 # via pydantic certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests contourpy==1.3.3 # via matplotlib @@ -21,7 +21,7 @@ dnspython==2.8.0 # via email-validator email-validator==2.3.0 # via scippneutron -essreduce==26.4.0 +essreduce==26.4.1 # via -r base.in fonttools==4.62.1 # via matplotlib @@ -50,7 +50,7 @@ mpltoolbox==26.2.0 # via scippneutron networkx==3.6.1 # via cyclebane -numpy==2.4.3 +numpy==2.4.4 # via # contourpy # h5py @@ -59,26 +59,26 @@ numpy==2.4.3 # scipp # scippneutron # scipy -packaging==26.0 +packaging==26.1 # via # lazy-loader # matplotlib # pooch -pandas==3.0.1 +pandas==3.0.2 # via -r base.in -pillow==12.1.1 +pillow==12.2.0 # via matplotlib -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch -plopp==26.3.1 +plopp==26.4.0 # via # scippneutron # tof pooch==1.9.0 # via tof -pydantic==2.12.5 +pydantic==2.13.2 # via scippneutron -pydantic-core==2.41.5 +pydantic-core==2.46.2 # via pydantic pyparsing==3.3.2 # via matplotlib @@ -87,7 +87,7 @@ python-dateutil==2.9.0.post0 # matplotlib # pandas # scippneutron -requests==2.32.5 +requests==2.33.1 # via pooch sciline==25.11.1 # via diff --git a/requirements/basetest.txt b/requirements/basetest.txt index 4eb0e5c..acd4742 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -7,27 +7,27 @@ # certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests idna==3.11 # via requests iniconfig==2.3.0 # via pytest -packaging==26.0 +packaging==26.1 # via # pooch # pytest -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch pluggy==1.6.0 # via pytest pooch==1.9.0 # via -r basetest.in -pygments==2.19.2 +pygments==2.20.0 # via pytest -pytest==9.0.2 +pytest==9.0.3 # via -r basetest.in -requests==2.32.5 +requests==2.33.1 # via pooch urllib3==2.6.3 # via requests diff --git a/requirements/ci.txt b/requirements/ci.txt index dedea3a..d1c46af 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -9,13 +9,13 @@ cachetools==7.0.5 # via tox certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests colorama==0.4.6 # via tox distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.29.0 # via # python-discovery # tox @@ -26,12 +26,12 @@ gitpython==3.1.46 # via -r ci.in idna==3.11 # via requests -packaging==26.0 +packaging==26.1 # via # -r ci.in # pyproject-api # tox -platformdirs==4.9.4 +platformdirs==4.9.6 # via # python-discovery # tox @@ -40,17 +40,19 @@ pluggy==1.6.0 # via tox pyproject-api==1.10.0 # via tox -python-discovery==1.2.0 - # via virtualenv -requests==2.32.5 +python-discovery==1.2.2 + # via + # tox + # virtualenv +requests==2.33.1 # via -r ci.in smmap==5.0.3 # via gitdb tomli-w==1.2.0 # via tox -tox==4.50.3 +tox==4.53.0 # via -r ci.in urllib3==2.6.3 # via requests -virtualenv==21.2.0 +virtualenv==21.2.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index e6f94c4..eb4972c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,13 +26,13 @@ async-lru==2.3.0 # via jupyterlab cffi==2.0.0 # via argon2-cffi-bindings -click==8.3.1 +click==8.3.2 # via # pip-compile-multi # pip-tools -copier==9.14.0 +copier==9.14.3 # via -r dev.in -dunamai==1.26.0 +dunamai==1.26.1 # via copier fqdn==1.5.1 # via jsonschema @@ -48,7 +48,7 @@ isoduration==20.11.0 # via jsonschema jinja2-ansible-filters==1.3.2 # via copier -json5==0.13.0 +json5==0.14.0 # via jupyterlab-server jsonpointer==3.1.1 # via jsonschema @@ -59,7 +59,7 @@ jsonschema[format-nongpl]==4.26.0 # nbformat jupyter-events==0.12.0 # via jupyter-server -jupyter-lsp==2.3.0 +jupyter-lsp==2.3.1 # via jupyterlab jupyter-server==2.17.0 # via @@ -79,17 +79,17 @@ notebook-shim==0.2.4 # via jupyterlab overrides==7.7.0 # via jupyter-server -pip-compile-multi==3.2.2 +pip-compile-multi==3.3.1 # via -r dev.in pip-tools==7.5.3 # via pip-compile-multi plumbum==1.10.0 # via copier -prometheus-client==0.24.1 +prometheus-client==0.25.0 # via jupyter-server pycparser==3.0 # via cffi -python-json-logger==4.0.0 +python-json-logger==4.1.0 # via jupyter-events questionary==2.1.1 # via copier @@ -111,7 +111,7 @@ terminado==0.18.1 # jupyter-server-terminals toposort==1.10 # via pip-compile-multi -tzdata==2025.3 +tzdata==2026.1 # via arrow uri-template==1.3.0 # via jsonschema diff --git a/requirements/docs.txt b/requirements/docs.txt index 2793e92..0ceeccc 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -54,7 +54,7 @@ ipykernel==7.2.0 # via -r docs.in ipympl==0.10.0 # via -r docs.in -ipython==9.10.0 +ipython==9.10.1 # via # -r docs.in # ipykernel @@ -115,7 +115,7 @@ myst-parser==5.0.0 # via -r docs.in nbclient==0.10.4 # via nbconvert -nbconvert==7.17.0 +nbconvert==7.17.1 # via nbsphinx nbformat==5.10.4 # via @@ -142,9 +142,9 @@ pure-eval==0.2.3 # via stack-data pydantic-settings==2.13.1 # via autodoc-pydantic -pydata-sphinx-theme==0.16.1 +pydata-sphinx-theme==0.17.0 # via -r docs.in -pygments==2.19.2 +pygments==2.20.0 # via # accessible-pygments # ipython diff --git a/requirements/mypy.txt b/requirements/mypy.txt index f90f757..725c546 100644 --- a/requirements/mypy.txt +++ b/requirements/mypy.txt @@ -6,9 +6,9 @@ # requirements upgrade # -r test.txt -librt==0.8.1 +librt==0.9.0 # via mypy -mypy==1.19.1 +mypy==1.20.1 # via -r mypy.in mypy-extensions==1.1.0 # via mypy diff --git a/requirements/nightly.txt b/requirements/nightly.txt index 1df764c..e6115d5 100644 --- a/requirements/nightly.txt +++ b/requirements/nightly.txt @@ -12,7 +12,7 @@ annotated-types==0.7.0 # via pydantic certifi==2026.2.25 # via requests -charset-normalizer==3.4.6 +charset-normalizer==3.4.7 # via requests contourpy==1.3.3 # via matplotlib @@ -55,7 +55,7 @@ mpltoolbox==26.2.0 # via scippneutron networkx==3.6.1 # via cyclebane -numpy==2.4.3 +numpy==2.4.4 # via # contourpy # h5py @@ -64,17 +64,17 @@ numpy==2.4.3 # scipp # scippneutron # scipy -packaging==26.0 +packaging==26.1 # via # lazy-loader # matplotlib # pooch # pytest -pandas==3.0.1 +pandas==3.0.2 # via -r nightly.in -pillow==12.1.1 +pillow==12.2.0 # via matplotlib -platformdirs==4.9.4 +platformdirs==4.9.6 # via pooch plopp @ git+https://github.com/scipp/plopp@main # via @@ -87,22 +87,22 @@ pooch==1.9.0 # via # -r nightly.in # tof -pydantic==2.13.0b2 +pydantic==2.13.2 # via scippneutron -pydantic-core==2.42.0 +pydantic-core==2.46.2 # via pydantic -pygments==2.19.2 +pygments==2.20.0 # via pytest pyparsing==3.3.2 # via matplotlib -pytest==9.0.2 +pytest==9.0.3 # via -r nightly.in python-dateutil==2.9.0.post0 # via # matplotlib # pandas # scippneutron -requests==2.32.5 +requests==2.33.1 # via pooch sciline @ git+https://github.com/scipp/sciline@main # via diff --git a/requirements/static.txt b/requirements/static.txt index 02cb746..ee45896 100644 --- a/requirements/static.txt +++ b/requirements/static.txt @@ -9,23 +9,23 @@ cfgv==3.5.0 # via pre-commit distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.29.0 # via # python-discovery # virtualenv -identify==2.6.18 +identify==2.6.19 # via pre-commit nodeenv==1.10.0 # via pre-commit -platformdirs==4.9.4 +platformdirs==4.9.6 # via # python-discovery # virtualenv pre-commit==4.5.1 # via -r static.in -python-discovery==1.2.0 +python-discovery==1.2.2 # via virtualenv pyyaml==6.0.3 # via pre-commit -virtualenv==21.2.0 +virtualenv==21.2.4 # via pre-commit diff --git a/requirements/wheels.txt b/requirements/wheels.txt index 0d70d60..0a50fd0 100644 --- a/requirements/wheels.txt +++ b/requirements/wheels.txt @@ -5,9 +5,9 @@ # # requirements upgrade # -build==1.4.0 +build==1.4.3 # via -r wheels.in -packaging==26.0 +packaging==26.1 # via build pyproject-hooks==1.2.0 # via build diff --git a/src/ess/bifrost/cutting.py b/src/ess/bifrost/cutting.py index 6388e05..52d4b29 100644 --- a/src/ess/bifrost/cutting.py +++ b/src/ess/bifrost/cutting.py @@ -39,11 +39,29 @@ def group_by_rotation( : ``data`` grouped by rotation angles "a3" and "a4". """ - graph = { - 'a3': _make_angle_from_time_calculator(sample_angle), - 'a4': _make_angle_from_time_calculator(instrument_angle), - } - grouped = data.transform_coords(('a3', 'a4'), graph=graph).group('a3', 'a4') + graph = {'a3': _make_angle_from_time_calculator(sample_angle)} + group_coords = ['a3'] + drop_coords = [] + + if 'time' in data.dims: + # If the data is time-dependent, a4 corresponds to that time-dependence + # (the instrument angle is the only dynamic parameter). The data has a 'time' + # bin-edge coord in this case. Since a4 changes at the bin edges, not within + # each bin, we can look it up safely using midpoints. + graph['a4'] = lambda time: sc.lookup( + instrument_angle, dim='time', mode='previous' + )[sc.midpoints(time)] + # a4 replaces the 'time' coord. + drop_coords.append('time') + else: + graph['a4'] = _make_angle_from_time_calculator(instrument_angle) + group_coords.append('a4') + + grouped = ( + data.transform_coords(('a3', 'a4'), graph=graph) + .group(*group_coords) + .drop_coords(drop_coords) + ) return DataGroupedByRotation[RunType](grouped) @@ -51,7 +69,7 @@ def _make_angle_from_time_calculator(angle: sc.DataArray) -> Callable[..., sc.Va if angle.ndim == 0: return lambda: angle.data else: - lut = sc.lookup(angle, 'time') + lut = sc.lookup(angle, 'time', mode='previous') return lambda event_time_zero: lut[event_time_zero] diff --git a/src/ess/bifrost/data.py b/src/ess/bifrost/data.py index 5a27e37..231e99b 100644 --- a/src/ess/bifrost/data.py +++ b/src/ess/bifrost/data.py @@ -10,12 +10,11 @@ _registry = make_registry( 'ess/bifrost', files={ - "BIFROST_20240914T053723.h5": "md5:0f2fa5c9a851f8e3a4fa61defaa3752e", - "computed_energy_data_simulated_5x2.h5": "md5:57408fa10aa4689c43630f994cff8d30", - "BIFROST-simulation-tof-lookup-table.h5": "blake2b:682021920a355f789da37b18029719fe20569d86db26cdaf5f3d916d2f76f9360907960ba86903be4cab489d39f1b6f9f265f3a4ab3f82c5e095afa4a2c456af", # noqa: E501 - "BIFROST-simulation-lookup-table.h5": "md5:6d776afa591d4a83c91ad0142bbfc53d", + "bifrost_260418T170408.h5": "md5:5fe544c2eccfb6c4ec52beca9957f528", + "computed_energy_data_simulated_5x2.h5": "md5:1a24a1067ae2968dfc162c5c72dcb073", + "BIFROST-simulation-lookup-table.h5": "md5:237e26125b22aa9cb0c68454206896a2", }, - version="7", + version="8", ) @@ -31,7 +30,7 @@ def get_path(name: str) -> Path: def simulated_elastic_incoherent_with_phonon() -> Path: """Simulated data for elastic incoherent scattering including a phonon.""" - return get_path("BIFROST_20240914T053723.h5") + return get_path("bifrost_260418T170408.h5") def lookup_table_simulation() -> Path: diff --git a/src/ess/bifrost/detector.py b/src/ess/bifrost/detector.py index 24acd57..9b25055 100644 --- a/src/ess/bifrost/detector.py +++ b/src/ess/bifrost/detector.py @@ -3,11 +3,15 @@ """Detector handling for BIFROST.""" +from collections.abc import Callable + import scipp as sc import scippnexus as snx +from ess.reduce.nexus.types import Position from ess.spectroscopy.indirect.conversion import add_spectrometer_coords from ess.spectroscopy.types import ( + Analyzer, DetectorPositionOffset, EmptyDetector, NeXusComponent, @@ -78,6 +82,7 @@ def arc_and_channel_from_detector_number( def get_calibrated_detector_bifrost( detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], *, transform: NeXusTransformation[snx.NXdetector, RunType], offset: DetectorPositionOffset[RunType], @@ -86,17 +91,19 @@ def get_calibrated_detector_bifrost( ) -> EmptyDetector[RunType]: """Extract the data array corresponding to a detector's signal field. - The data array is reshaped to the logical detector shape. + This includes: - This function is specific to BIFROST and differs from the generic - :func:`ess.reduce.nexus.workflow.get_calibrated_detector` in that it does not - fold the detectors into logical dimensions because the files already contain - the detectors in the correct shape. + - Reshaping the data array is reshaped to the logical detector shape. + - Assigning geometry coordinate "position". + - Assigning spectrometer coordinates such as "final_energy", + "secondary_flight_time", and "L1". Parameters ---------- detector: Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. transform: Transformation that determines the detector position. offset: @@ -112,18 +119,10 @@ def get_calibrated_detector_bifrost( ------- : Detector geometry and spectrometer coordinates. - This includes "final_energy", "secondary_flight_time", and "L1". """ - from ess.reduce.nexus.types import DetectorBankSizes - from ess.reduce.nexus.workflow import get_calibrated_detector - - da = get_calibrated_detector( - detector=detector, - transform=transform, - offset=offset, - # The detectors are folded in the file, no need to do that here. - bank_sizes=DetectorBankSizes({}), + da = get_base_calibrated_detector_bifrost( + detector, analyzer, transform=transform, offset=offset ) da = da.rename(dim_0='tube', dim_1='length') @@ -131,11 +130,101 @@ def get_calibrated_detector_bifrost( da.coords['arc'] = arc da.coords['channel'] = channel - da = add_spectrometer_coords(da, primary_graph, secondary_graph) + da = add_spectrometer_coords( + da, + primary_graph, + SecondarySpecCoordTransformGraph[RunType]( + {**secondary_graph, **_make_analyzer_coord_graph(da, analyzer)} + ), + ) return EmptyDetector[RunType](da) +def get_base_calibrated_detector_bifrost( + detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], + *, + transform: NeXusTransformation[snx.NXdetector, RunType], + offset: DetectorPositionOffset[RunType], +) -> sc.DataArray: + """Extract the data array corresponding to a detector's signal field. + + This function is specific to BIFROST and differs from the generic + :func:`ess.reduce.nexus.workflow.get_calibrated_detector` in that it + assigns time-dependent positions by broadcasting the data into the 'time' dimension. + + Parameters + ---------- + detector: + Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. + transform: + Transformation that determines the detector position. + offset: + Offset to add to the detector position. + + Returns + ------- + : + Detector with geometry coordinates. + """ + + from ess.reduce.nexus import compute_detector_position, extract_signal_data_array + + da = extract_signal_data_array(detector) + position = compute_detector_position(da, transform=transform, offset=offset) + return _assign_detector_position(da, position) + + +def _assign_detector_position( + da: sc.DataArray, position: sc.Variable | sc.DataArray +) -> sc.DataArray: + if isinstance(position, sc.DataArray): # time-dependent transform + # Store position and time as separate coords because we can't store data arrays. + return da.broadcast( + dims=['time', *da.dims], shape=[position.sizes['time'], *da.shape] + ).assign_coords(position=position.data, time=position.coords['time']) + return da.assign_coords(position=position) + + +# We insert the analyzer coords into the graph so that they don't end up as coords +# in the output. This could be done in the provider of SecondarySpecCoordTransformGraph +# but that provider would then have to request the detector component to check +# the time coordinates. +def _make_analyzer_coord_graph( + detector: sc.DataArray, + analyzer: Analyzer[RunType], +) -> dict[str, Callable[[], sc.Variable]]: + ana_pos: Position[snx.NXcrystal, RunType] = analyzer['position'] + if ana_pos.is_dynamic: + if 'time' not in detector.coords: + raise sc.CoordError( + "The analyzer position is time-dependent but the detector is not" + ) + analyzer_positions = ana_pos.positions + if not sc.identical(analyzer_positions.coords['time'], detector.coords['time']): + raise sc.CoordError( + f"The analyzer and detector positions are not at the same times.\n" + f"Analyzer: {analyzer_positions.coords['time']}\n" + f"Detector: {detector.coords['time']}\n" + "This is likely due to a change in the NeXus structure. It used to " + "guarantee that the times are identical." + ) + analyzer_position = analyzer_positions.data + analyzer_transform = analyzer['transform'].value.data + else: + analyzer_position = ana_pos.position + analyzer_transform = analyzer['transform'].value + + return { + 'analyzer_dspacing': lambda: analyzer['dspacing'], + 'analyzer_position': lambda: analyzer_position, + 'analyzer_transform': lambda: analyzer_transform, + } + + def merge_triplets( *triplets: sc.DataArray, ) -> sc.DataArray: diff --git a/src/ess/bifrost/io/nexus.py b/src/ess/bifrost/io/nexus.py index 41b3297..1a7ccbc 100644 --- a/src/ess/bifrost/io/nexus.py +++ b/src/ess/bifrost/io/nexus.py @@ -3,18 +3,23 @@ """NeXus input/output for BIFROST.""" +import warnings + +import numpy as np import scipp as sc import scippnexus as snx -from ess.reduce.nexus import load_all_components, open_component_group -from ess.reduce.nexus.types import NeXusAllLocationSpec, NeXusLocationSpec +from ess.reduce.nexus import open_component_group +from ess.reduce.nexus.types import NeXusLocationSpec, TransformationTimeFilter from ess.spectroscopy.types import ( Analyzer, - Analyzers, + DynamicPosition, InstrumentAngle, NeXusClass, + NeXusComponent, NeXusComponentLocationSpec, NeXusFileSpec, + NeXusTransformation, RunType, SampleAngle, ) @@ -49,72 +54,170 @@ def _load_experiment_parameter( return group[param_name][()]['value'] -def load_analyzers(file_spec: NeXusFileSpec[RunType]) -> Analyzers[RunType]: - """Load all analyzers in a NeXus file.""" - return Analyzers[RunType]( - load_all_components( - NeXusAllLocationSpec(filename=file_spec.value), - nx_class=snx.NXcrystal, +def load_analyzer_for_detector( + detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], +) -> NeXusComponent[snx.NXcrystal, RunType]: + """Load the analyzer component for the given detector. + + This function searches for an ``NXcrystal`` in the inputs (via the + 'input' attribute) of the detector and loads the first NeXus group it finds. + + See Also + -------- + get_calibrated_analyzer: + A provider that combines loaded analyzer data into an ``Analyzer`` object. + """ + with open_component_group(detector_location, nx_class=snx.NXdetector) as det_group: + analyzer_group = _find_class_in_inputs( + group=det_group.parent, target=snx.NXcrystal, start=det_group ) - ) + return analyzer_group[()] -def _get_analyzer_for_detector_name( - detector_name: str, analyzers: Analyzers[RunType] -) -> Analyzers[RunType]: - detector_index = int(detector_name.split('_', 1)[0]) - analyzer_index = str(detector_index - 2) - for name, analyzer in analyzers.items(): - if name.startswith(analyzer_index): - return analyzer - raise RuntimeError(f"No analyzer found for detector {detector_name}") +def _find_class_in_inputs( + group: snx.Group, target: type, start: snx.Group +) -> snx.Group: + """Search for a NeXus class in a group's inputs. + This function uses a breadth-first search through ``'input'`` attributes. + It begins at ``start`` and walks along chains of inputs until a group with the + given class is found, the chain ends, or the chain leads outside ``group``. -def analyzer_for_detector( - analyzers: Analyzers[RunType], - detector_location: NeXusComponentLocationSpec[snx.NXdetector, RunType], + Parameters + ---------- + group: HDF5 Group + The group that contains all possible next named groups + target: + The NeXus class to look for. + + Returns + ------- + : + The group with the target NeXus class found within ``group``. + """ + pending = [start] + while pending: + element = pending.pop(0) + if element.nx_class == target: + return element + for name in _get_inputs(element): + try: + pending.append(group[name]) + except KeyError: + warnings.warn(f"No '{name}' in NeXus group {group.name}", stacklevel=2) + continue + raise ValueError(f"No {target} found in the inputs of {start.name}") + + +def _get_inputs(group: snx.Group) -> list[str]: + try: + inputs = group.attrs['inputs'] + except KeyError: + return [] + # Deal with nexusformat (Python module) or kafka-to-nexus (filewriter) + # silently converting a len(list[str]) == 1 attribute to a str attribute: + return [inputs] if isinstance(inputs, str) else inputs + + +def get_calibrated_analyzer( + analyzer_component: NeXusComponent[snx.NXcrystal, RunType], + analyzer_transform: NeXusTransformation[snx.NXcrystal, RunType], + analyzer_position: DynamicPosition[snx.NXcrystal, RunType], ) -> Analyzer[RunType]: - """Extract the analyzer for a given detector. + """Collect the data for a single analyzer. - Note - ---- - Depends heavily on the names of components being preceded by an instrument index, - and the analyzer and detector components being separated in index by 2. - If either condition changes, this function will need to be modified. + This provider works together with :func:`load_analyzer_for_detector` and the + generic NeXus workflow from ESSreduce. + ``load_analyzer_for_detector`` loads a raw analyzer component. + Then the default providers from ESSreduce extract a transform and position like + for any other component. + Finally, this provider combines the data into a single Analyzer object. Parameters ---------- - analyzers: + analyzer_component: Data group of loaded analyzers. - detector_location: - The location of an NXdetector in the NeXus file. - The analyzer is identified based on this location. + analyzer_transform: + Transformation matrix of the analyzer. + analyzer_position: + The computed position vector of the analyzer. Returns ------- : - The analyzer for the given detector triplet. + A given analyzer. Only a subset of fields is returned. """ - if detector_location.component_name is None: - raise ValueError("Detector component name is None") - analyzer = snx.compute_positions( - _get_analyzer_for_detector_name(detector_location.component_name, analyzers), - store_transform='transform', - ) + return Analyzer[RunType]( sc.DataGroup( - dspacing=analyzer['d_spacing'], - position=analyzer['position'], - transform=analyzer['transform'], + dspacing=analyzer_component['d_spacing'], + position=analyzer_position, + transform=analyzer_transform, ) ) +def _collapse_runs(transform: sc.DataArray, dim: str) -> sc.DataArray: + """Collapse runs of equal values into a single value.""" + # Find indices where the data changes + different_from_previous = np.hstack( + [True, ~np.isclose(transform.values[:-1], transform.values[1:])] + ) + change_indices = np.flatnonzero(different_from_previous) + if change_indices.shape == transform.shape: + return transform # Return early to avoid expensive indexing + # Get unique values + unique_values = transform[change_indices] + + # Make bin-edges and extend range to include the whole measurement + last = unique_values.coords[dim][-1] + unique_values.coords[dim] = sc.concat( + [ + # bin-edges are left-inclusive, so we can start with coord[0] as first edge + unique_values.coords[dim], + # Surely, no experiment will last more than 10 years... + last + sc.scalar(10, unit='Y').to(unit=last.unit), + ], + dim=dim, + ) + + return unique_values + + +def stepwise_transformation_time_filter(transform: sc.DataArray) -> sc.DataArray: + """Collapse runs of equal values into a single value. + + This can be used as a time filter for NeXus transformations when the component + mostly stays at one position and only rarely moves. + For example, a stepwise scan across detector rotations. + + Repeated values are identified using :func:`numpy.isclose` with default tolerances + applied to the individual transformation components. + I.e., for the BIFROST detector, the detector angle (currently in degrees) + is checked for approximate equality between consecutive values. + + Note + ---- + This approach is meant to handle noisy NXlogs if they are written + from readback values or repeated setpoint values. + We currently do not know enough about how ESS NeXus files will be written for + real measurements, so we may need to revisit this approach. + """ + collapsed = _collapse_runs(transform, 'time') + if collapsed.sizes['time'] == 1: + return collapsed.squeeze('time') + return collapsed + + providers = ( - analyzer_for_detector, - load_analyzers, + get_calibrated_analyzer, + load_analyzer_for_detector, load_instrument_angle, load_sample_angle, moderator_class_for_source, ) + +parameters = { + TransformationTimeFilter: stepwise_transformation_time_filter, +} diff --git a/src/ess/bifrost/io/sqw.py b/src/ess/bifrost/io/sqw.py index 28444ab..9528354 100644 --- a/src/ess/bifrost/io/sqw.py +++ b/src/ess/bifrost/io/sqw.py @@ -106,19 +106,13 @@ def save_sqw( scippneutron.io.sqw: For low-level SQW I/O and the underlying implementation of ``save_sqw``. """ - if np.unique(events.coords['a4'].values).size != 1: - # We need to support this eventually, but we don't - # have data for a moving detector vessel yet. - raise NotImplementedError("a4 must be constant for all events") - flat_events = _flatten_events(events) del events # 'move' events into _flatten_events _filter_and_convert_coords_in_place(flat_events) - sample_angle = flat_events.coords['a3'] - observations = _histogram_detector_setting_ei(flat_events, energy_bins=energy_bins) del flat_events # 'move' flat_events into _histogram_detector_setting_ei + sample_angle = observations.coords['a3'] final_energy = observations.coords['final_energy'] observations = _with_inelastic_coords(observations, gravity) energy_transfer = observations.coords['energy_transfer'].rename_dims( @@ -161,7 +155,7 @@ def _flatten_events( n_a3 = aux.sizes['a3'] aux.coords['i_a3'] = sc.arange('a3', n_a3, dtype='float32', unit=None) aux.coords['i_a4'] = sc.arange('a4', aux.sizes['a4'], dtype='float32', unit=None) - flat = aux.flatten(['a3', 'a4'], 'setting') + flat = aux.transpose(['detector', 'a4', 'a3']).flatten(['a4', 'a3'], 'setting') return flat.assign_coords( irun=flat.coords.pop('i_a3') + flat.coords.pop('i_a4') * sc.index(n_a3) @@ -422,9 +416,9 @@ def _make_experiments( ) -> list[sqw.SqwIXExperiment]: experiment_template = sqw.SqwIXExperiment( run_id=0, # converted to 1-based by ScippNeutron - efix=final_energy, emode=sqw.EnergyMode.indirect, - en=energy_transfer, + efix=None, # type: ignore[assignment] (overridden below) + en=None, # type: ignore[assignment] psi=sc.scalar(0.0, unit="rad"), u=_AXIS_U, v=_AXIS_V, @@ -434,6 +428,12 @@ def _make_experiments( gs=sc.scalar(0.0, unit="rad"), ) return [ - dataclasses.replace(experiment_template, run_id=i, psi=a3) + dataclasses.replace( + experiment_template, + run_id=i, + psi=a3, + efix=final_energy['setting', i], + en=energy_transfer['setting', i], + ) for i, a3 in enumerate(sample_angle) ] diff --git a/src/ess/bifrost/normalization.py b/src/ess/bifrost/normalization.py index 074bf3b..41bfa23 100644 --- a/src/ess/bifrost/normalization.py +++ b/src/ess/bifrost/normalization.py @@ -7,8 +7,8 @@ from ess.reduce.uncertainty import broadcast_uncertainties from ess.spectroscopy.types import ( - FrameMonitor3, IncidentEnergyDetector, + NormalizationMonitor, NormalizedIncidentEnergyDetector, ProtonCharge, RunType, @@ -19,7 +19,7 @@ def normalize_by_monitor_and_proton_charge( detector: IncidentEnergyDetector[RunType], - monitor: WavelengthMonitor[RunType, FrameMonitor3], + monitor: WavelengthMonitor[RunType, NormalizationMonitor], proton_charge: ProtonCharge[RunType], uncertainty_broadcast_mode: UncertaintyBroadcastMode, ) -> NormalizedIncidentEnergyDetector[RunType]: diff --git a/src/ess/bifrost/single_crystal/detector.py b/src/ess/bifrost/single_crystal/detector.py new file mode 100644 index 0000000..320b9a6 --- /dev/null +++ b/src/ess/bifrost/single_crystal/detector.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +"""Bragg peak detector handling for BIFROST.""" + +import scippnexus as snx + +from ess.spectroscopy.types import ( + Analyzer, + DetectorPositionOffset, + EmptyDetector, + NeXusComponent, + NeXusTransformation, + RunType, +) + +from ..detector import get_base_calibrated_detector_bifrost + + +def get_calibrated_bragg_peak_detector( + detector: NeXusComponent[snx.NXdetector, RunType], + analyzer: Analyzer[RunType], + *, + transform: NeXusTransformation[snx.NXdetector, RunType], + offset: DetectorPositionOffset[RunType], +) -> EmptyDetector[RunType]: + """Extract the data array corresponding to the Bragg peak detector's signal field. + + Parameters + ---------- + detector: + Loaded NeXus detector. + analyzer: + Loaded analyzer parameters. + transform: + Transformation that determines the detector position. + offset: + Offset to add to the detector position. + + Returns + ------- + : + Detector with geometry coordinates. + """ + return get_base_calibrated_detector_bifrost( + detector, analyzer, transform=transform, offset=offset + ) + + +providers = (get_calibrated_bragg_peak_detector,) diff --git a/src/ess/bifrost/single_crystal/q_map.py b/src/ess/bifrost/single_crystal/q_map.py index a1cb863..9914fc5 100644 --- a/src/ess/bifrost/single_crystal/q_map.py +++ b/src/ess/bifrost/single_crystal/q_map.py @@ -47,6 +47,9 @@ def project_momentum_transfer( transformed.bins.coords['a3'] = sc.bins_like( transformed, transformed.coords['a3'] ) + transformed.bins.coords['a4'] = sc.bins_like( + transformed, transformed.coords['a4'] + ) transformed = transformed.bins.concat() return CountsWithQMapCoords[RunType](transformed) diff --git a/src/ess/bifrost/single_crystal/workflow.py b/src/ess/bifrost/single_crystal/workflow.py index 88c7fdf..ac4bb98 100644 --- a/src/ess/bifrost/single_crystal/workflow.py +++ b/src/ess/bifrost/single_crystal/workflow.py @@ -7,9 +7,8 @@ from ess.reduce import unwrap as reduce_unwrap from ess.spectroscopy.indirect.time_of_flight import TofWorkflow from ess.spectroscopy.types import ( - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, + ElasticMonitor, + NormalizationMonitor, SampleRun, ) @@ -17,11 +16,12 @@ from ..io import nexus from ..io.mcstas import convert_simulated_time_to_event_time_offset from ..workflow import default_parameters, simulation_default_parameters -from . import conversion, q_map, time_of_flight +from . import conversion, detector, q_map, time_of_flight _PROVIDERS = ( *nexus.providers, *conversion.providers, + *detector.providers, *q_map.providers, *time_of_flight.providers, group_by_rotation, @@ -30,6 +30,7 @@ _SIMULATION_PROVIDERS = ( *nexus.providers, *conversion.providers, + *detector.providers, *q_map.providers, *time_of_flight.providers, convert_simulated_time_to_event_time_offset, @@ -40,7 +41,7 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=(ElasticMonitor, NormalizationMonitor), ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) @@ -54,7 +55,7 @@ def BifrostBraggPeakMonitorWorkflow() -> sciline.Pipeline: def BifrostSimulationBraggPeakMonitorWorkflow() -> sciline.Pipeline: workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=(ElasticMonitor, NormalizationMonitor), ) # Use the vanilla implementation instead of the indirect geometry one: workflow.insert(reduce_unwrap.to_wavelength.detector_wavelength_data) diff --git a/src/ess/bifrost/workflow.py b/src/ess/bifrost/workflow.py index ff158bb..4b1b796 100644 --- a/src/ess/bifrost/workflow.py +++ b/src/ess/bifrost/workflow.py @@ -14,15 +14,15 @@ from ess.spectroscopy.indirect.ki import providers as ki_providers from ess.spectroscopy.indirect.time_of_flight import TofWorkflow from ess.spectroscopy.types import ( + ElasticMonitor, EmptyDetector, - FrameMonitor0, - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, NeXusData, NeXusDetectorName, NeXusMonitorName, + NormalizationMonitor, + OverlapMonitor, ProtonCharge, + PSCMonitor, PulsePeriod, RawDetector, SampleRun, @@ -39,23 +39,21 @@ def default_parameters() -> dict[type, Any]: """Default parameters for BifrostWorkflow.""" return { - NeXusMonitorName[FrameMonitor1]: '090_frame_1', - NeXusMonitorName[FrameMonitor2]: '097_frame_2', - NeXusMonitorName[FrameMonitor3]: '110_frame_3', + NeXusMonitorName[ElasticMonitor]: "elastic_monitor", + NeXusMonitorName[NormalizationMonitor]: "normalization_monitor", + NeXusMonitorName[OverlapMonitor]: "overlap_monitor", + NeXusMonitorName[PSCMonitor]: "psc_monitor", PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, + **nexus.parameters, } def simulation_default_parameters() -> dict[type, Any]: """Default parameters for BifrostSimulationWorkflow.""" return { - NeXusMonitorName[FrameMonitor1]: '090_frame_1', - NeXusMonitorName[FrameMonitor2]: '097_frame_2', - NeXusMonitorName[FrameMonitor3]: '110_frame_3', - PulsePeriod: 1.0 / sc.scalar(14.0, unit="Hz"), + **default_parameters(), ProtonCharge[SampleRun]: sc.DataArray(sc.scalar(1.0, unit='pC')), - UncertaintyBroadcastMode: UncertaintyBroadcastMode.fail, } @@ -98,7 +96,12 @@ def BifrostSimulationWorkflow( """ workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor0, FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=( + ElasticMonitor, + NormalizationMonitor, + OverlapMonitor, + PSCMonitor, + ), ) for provider in _SIMULATION_PROVIDERS: workflow.insert(provider) @@ -120,7 +123,12 @@ def BifrostWorkflow( """Data reduction workflow for BIFROST.""" workflow = TofWorkflow( run_types=(SampleRun,), - monitor_types=(FrameMonitor0, FrameMonitor1, FrameMonitor2, FrameMonitor3), + monitor_types=( + ElasticMonitor, + NormalizationMonitor, + OverlapMonitor, + PSCMonitor, + ), ) for provider in _PROVIDERS: workflow.insert(provider) diff --git a/src/ess/spectroscopy/indirect/conversion.py b/src/ess/spectroscopy/indirect/conversion.py index fa4196b..37fcaea 100644 --- a/src/ess/spectroscopy/indirect/conversion.py +++ b/src/ess/spectroscopy/indirect/conversion.py @@ -244,7 +244,7 @@ def add_incident_energy( def add_spectrometer_coords( - data: sc.DataArray, + detector: sc.DataArray, primary_graph: PrimarySpecCoordTransformGraph[RunType], secondary_graph: SecondarySpecCoordTransformGraph[RunType], ) -> sc.DataArray: @@ -252,10 +252,9 @@ def add_spectrometer_coords( Parameters ---------- - data: - Data array with beamline coordinates "position", "source_position", and - "sample_position". - Does not need to contain events or flight times. + detector: + Data array with a "position" coordinate. + Does not need to contain events, wavelength, or flight times. primary_graph: Coordinate transformation graph for the primary spectrometer. secondary_graph: @@ -269,7 +268,7 @@ def add_spectrometer_coords( Input data with added spectrometer coordinates. This includes "final_energy", "secondary_flight_time", and "L1". """ - return data.transform_coords( + return detector.transform_coords( ( 'final_energy', 'final_wavevector', diff --git a/src/ess/spectroscopy/indirect/kf.py b/src/ess/spectroscopy/indirect/kf.py index e62d09a..a191992 100644 --- a/src/ess/spectroscopy/indirect/kf.py +++ b/src/ess/spectroscopy/indirect/kf.py @@ -10,7 +10,6 @@ ) from ess.spectroscopy.types import ( - Analyzer, DataAtSample, DataGroupedByRotation, PulsePeriod, @@ -211,30 +210,21 @@ def secondary_flight_time( return sc.to_unit(L2 / velocity, 'ms', copy=False) -def secondary_spectrometer_coordinate_transformation_graph( - analyzer: Analyzer[RunType], -) -> SecondarySpecCoordTransformGraph[RunType]: +def secondary_spectrometer_coordinate_transformation_graph() -> ( + SecondarySpecCoordTransformGraph[RunType] +): """Return a coordinate transformation graph for the secondary spectrometer. - Parameters - ---------- - analyzer: - Data group with analyzer parameters. - Returns ------- : Coordinate transformation graph for the secondary spectrometer. - The graph captures the relevant parameters of ``analyzer``. """ from scippneutron.conversion.beamline import beam_aligned_unit_vectors return SecondarySpecCoordTransformGraph[RunType]( { "a4": detector_geometric_a4, - "analyzer_dspacing": lambda: analyzer["dspacing"], - "analyzer_position": lambda: analyzer["position"], - "analyzer_transform": lambda: analyzer["transform"], "beam_aligned_unit_vectors": beam_aligned_unit_vectors, "detector_position": "position", "sample_analyzer_vector": sample_analyzer_vector, diff --git a/src/ess/spectroscopy/indirect/time_of_flight.py b/src/ess/spectroscopy/indirect/time_of_flight.py index 496df3e..f19ef09 100644 --- a/src/ess/spectroscopy/indirect/time_of_flight.py +++ b/src/ess/spectroscopy/indirect/time_of_flight.py @@ -85,7 +85,7 @@ def monitor_wavelength_data( for indirect geometry spectrometers. """ result = reduce_unwrap.to_wavelength.monitor_wavelength_data( - monitor_data=monitor_data.rename(t='tof'), + monitor_data=monitor_data.rename(t='frame_time'), lookup=lookup, ltotal=ltotal, pulse_stride_offset=pulse_stride_offset, diff --git a/src/ess/spectroscopy/types.py b/src/ess/spectroscopy/types.py index 2d85061..d2be4e0 100644 --- a/src/ess/spectroscopy/types.py +++ b/src/ess/spectroscopy/types.py @@ -16,6 +16,7 @@ Beamline = reduce_t.Beamline DetectorPositionOffset = reduce_t.DetectorPositionOffset +DynamicPosition = reduce_t.DynamicPosition EmptyDetector = reduce_t.EmptyDetector Filename = reduce_t.Filename GravityVector = reduce_t.GravityVector @@ -36,20 +37,17 @@ SampleRun = reduce_t.SampleRun VanadiumRun = reduce_t.VanadiumRun -FrameMonitor0 = reduce_t.FrameMonitor0 -FrameMonitor1 = reduce_t.FrameMonitor1 -FrameMonitor2 = reduce_t.FrameMonitor2 -FrameMonitor3 = reduce_t.FrameMonitor3 +# BIFROST monitors +ElasticMonitor = NewType('ElasticMonitor', int) +NormalizationMonitor = NewType('NormalizationMonitor', int) +PSCMonitor = NewType('PSCMonitor', int) +OverlapMonitor = NewType('OverlapMonitor', int) # Type vars RunType = TypeVar("RunType", SampleRun, VanadiumRun) MonitorType = TypeVar( - "MonitorType", - FrameMonitor0, - FrameMonitor1, - FrameMonitor2, - FrameMonitor3, + "MonitorType", ElasticMonitor, NormalizationMonitor, PSCMonitor, OverlapMonitor ) # Time-of-flight types @@ -76,10 +74,6 @@ class Analyzer(sciline.Scope[RunType, sc.DataGroup[Any]], sc.DataGroup[Any]): """ -class Analyzers(sciline.Scope[RunType, sc.DataGroup[Any]], sc.DataGroup[Any]): - """All wavelength analyzers loaded from a NXcrystals.""" - - class DataAtSample(sciline.Scope[RunType, sc.DataArray], sc.DataArray): ... diff --git a/tests/bifrost/cutting_test.py b/tests/bifrost/cutting_test.py index 225b91f..d49fe1d 100644 --- a/tests/bifrost/cutting_test.py +++ b/tests/bifrost/cutting_test.py @@ -39,7 +39,7 @@ def energy_data( workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop return workflow.compute(EnergyQDetector[SampleRun]) @@ -59,7 +59,7 @@ def test_cut_along_q_norm_and_energy_transfer_preserves_counts( axis_2 = CutAxis( output='E', fn=lambda energy_transfer: energy_transfer, - bins=sc.linspace(dim='E', start=-10.0, stop=10.0, num=50, unit='meV'), + bins=sc.linspace(dim='E', start=-10.0, stop=15.0, num=50, unit='meV'), ) cut_data = cut( @@ -84,12 +84,12 @@ def test_cut_along_qx_direction_preserves_counts( axis_1 = CutAxis.from_q_vector( output='Qx', vec=sc.vector([1, 0, 0]), - bins=sc.linspace(dim='Qx', start=-2.0, stop=2.0, num=40, unit='1/angstrom'), + bins=sc.linspace(dim='Qx', start=-3.0, stop=2.0, num=40, unit='1/angstrom'), ) axis_2 = CutAxis( output='E', fn=lambda energy_transfer: energy_transfer, - bins=sc.linspace(dim='E', start=-5.0, stop=5.0, num=30, unit='meV'), + bins=sc.linspace(dim='E', start=-10.0, stop=15.0, num=30, unit='meV'), ) cut_data = cut( diff --git a/tests/bifrost/io/sqw_test.py b/tests/bifrost/io/sqw_test.py index f8762f8..6bd5d4d 100644 --- a/tests/bifrost/io/sqw_test.py +++ b/tests/bifrost/io/sqw_test.py @@ -7,6 +7,7 @@ # Function-scoped fixtures allow accessing that file for reading. import itertools +from collections import Counter from collections.abc import Generator from pathlib import Path @@ -38,7 +39,8 @@ ) N_DETECTORS = 3 -N_ANGLES = 180 +N_ANGLES = 178 +N_DET_ROTATIONS = 2 BIN_SIZES = {'u1': 6, 'u2': 7, 'u3': 8, 'u4': 9} ENERGY_BIN_SIZE = 13 @@ -77,7 +79,7 @@ def common_workflow( wf[LookupTableFilename] = lookup_table_simulation() wf[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } wf[PreopenNeXusFile] = PreopenNeXusFile(True) wf[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop @@ -108,13 +110,13 @@ def output_file(write_file: Path) -> Generator[sqw.Sqw, None, None]: def test_save_sqw_writes_instrument_metadata(output_file: sqw.Sqw) -> None: instruments = output_file.read_data_block("experiment_info", "instruments") - assert len(instruments) == N_ANGLES + assert len(instruments) == N_ANGLES * N_DET_ROTATIONS # All instruments are the same: for instrument in instruments[1:]: sc.testing.assert_identical(instrument, instruments[0]) instrument = instruments[0] - assert instrument.name == "BIFROST" + assert instrument.name == "bifrost" sc.testing.assert_identical(instrument.source.frequency, sc.scalar(14.0, unit="Hz")) @@ -123,7 +125,7 @@ def test_save_sqw_writes_sample_metadata( ) -> None: samples = output_file.read_data_block("experiment_info", "samples") - assert len(samples) == N_ANGLES + assert len(samples) == N_ANGLES * N_DET_ROTATIONS # All samples are the same: for s in samples: sc.testing.assert_identical(s, sample) @@ -132,14 +134,22 @@ def test_save_sqw_writes_sample_metadata( def test_save_sqw_writes_experiment_metadata(output_file: sqw.Sqw) -> None: experiments = output_file.read_data_block("experiment_info", "expdata") - assert len(experiments) == N_ANGLES + assert len(experiments) == N_ANGLES * N_DET_ROTATIONS # N unique run ids - assert len({experiment.run_id for experiment in experiments}) == N_ANGLES + assert ( + len({experiment.run_id for experiment in experiments}) + == N_ANGLES * N_DET_ROTATIONS + ) for experiment in experiments: assert experiment.emode == sqw.EnergyMode.indirect assert experiment.u == U assert experiment.v == V + # Every a3 (psi) is represented once per detector rotation + # because we have a regular grid. + psi_counts = Counter(experiment.psi.value for experiment in experiments) + assert all(count == N_DET_ROTATIONS for count in psi_counts.values()) + def test_save_sqw_writes_dnd_metadata( output_file: sqw.Sqw, sample: sqw.SqwIXSample @@ -176,7 +186,11 @@ def test_save_sqw_writes_pixel_data(output_file: sqw.Sqw) -> None: pix = output_file.read_data_block("pix", "data_wrap") assert pix.shape == ( - N_DETECTORS * N_PIXELS_PER_DETECTOR * N_ANGLES * ENERGY_BIN_SIZE, + N_DETECTORS + * N_PIXELS_PER_DETECTOR + * N_ANGLES + * ENERGY_BIN_SIZE + * N_DET_ROTATIONS, 9, ) @@ -222,7 +236,7 @@ def check_pixel_indices_in_ranges(pix: npt.NDArray[np.float32]) -> None: # 1-based indices! assert irun.min() == 1 - assert irun.max() == N_ANGLES + assert irun.max() == N_ANGLES * N_DET_ROTATIONS assert ien.min() == 1 assert ien.max() == ENERGY_BIN_SIZE diff --git a/tests/bifrost/live_test.py b/tests/bifrost/live_test.py index 22823d2..ada00c1 100644 --- a/tests/bifrost/live_test.py +++ b/tests/bifrost/live_test.py @@ -40,7 +40,7 @@ def qcut_workflow( workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop workflow[ProtonCharge[SampleRun]] = sc.DataArray(sc.scalar(1.0, unit='pC')) diff --git a/tests/bifrost/workflow_test.py b/tests/bifrost/workflow_test.py index 43206ad..b5abc00 100644 --- a/tests/bifrost/workflow_test.py +++ b/tests/bifrost/workflow_test.py @@ -16,10 +16,10 @@ from ess.spectroscopy.types import ( EnergyQDetector, Filename, - FrameMonitor3, LookupTableFilename, LookupTableRelativeErrorThreshold, NeXusDetectorName, + NormalizationMonitor, RawDetector, RawMonitor, SampleRun, @@ -42,7 +42,7 @@ def workflow(simulation_detector_names: list[NeXusDetectorName]) -> sciline.Pipe workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = { 'detector': np.inf, - '110_frame_3': np.inf, + 'normalization_monitor': np.inf, } workflow[UncertaintyBroadcastMode] = UncertaintyBroadcastMode.drop return workflow @@ -50,20 +50,20 @@ def workflow(simulation_detector_names: list[NeXusDetectorName]) -> sciline.Pipe def test_simulation_workflow_can_load_detector() -> None: workflow = bifrost.BifrostSimulationWorkflow( - [NeXusDetectorName("125_channel_1_1_triplet")] + [NeXusDetectorName("channel_3_1_triplet")] ) workflow[Filename[SampleRun]] = simulated_elastic_incoherent_with_phonon() results = sciline.compute_mapped(workflow, RawDetector[SampleRun]) result = results.iloc[0] assert result.bins is not None - assert set(result.dims) == {'tube', 'length'} + assert set(result.dims) == {'tube', 'length', 'time'} assert result.sizes['tube'] == 3 assert 'position' in result.coords def test_simulation_workflow_can_load_monitor(workflow: sciline.Pipeline) -> None: - result = workflow.compute(RawMonitor[SampleRun, FrameMonitor3]) + result = workflow.compute(RawMonitor[SampleRun, NormalizationMonitor]) assert result.bins is None assert 'position' in result.coords @@ -80,7 +80,7 @@ def test_simulation_workflow_can_compute_energy_data( 'tube': 3, 'length': 100, 'a3': 180, - 'a4': 1, + 'a4': 2, } expected_coords = {'a3', 'a4', 'detector_number'} assert expected_coords.issubset(energy_data.coords) @@ -100,7 +100,7 @@ def test_simulation_workflow_can_compute_energy_data( def test_simulation_workflow_can_compute_wavelength_monitor( workflow: sciline.Pipeline, ) -> None: - monitor = workflow.compute(WavelengthMonitor[SampleRun, FrameMonitor3]) + monitor = workflow.compute(WavelengthMonitor[SampleRun, NormalizationMonitor]) assert set(monitor.dims) == {'time', 'incident_wavelength'} expected_coords = {'position', 'incident_wavelength', 'time'} assert expected_coords.issubset(monitor.coords) @@ -111,7 +111,6 @@ def test_simulation_workflow_produces_the_same_data_as_before( workflow: sciline.Pipeline, ) -> None: energy_data = workflow.compute(EnergyQDetector[SampleRun]) - sc.io.save_hdf5(energy_data, 'computed_energy_data_simulated_5x2.h5') expected = sc.io.load_hdf5(computed_energy_data_simulated_5x2()) assert not energy_data.masks