diff --git a/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb b/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb index c581454fb..98276b054 100644 --- a/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb +++ b/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb @@ -47,7 +47,13 @@ }, "outputs": [], "source": [ - "from ess.powder.types import DspacingBins, Filename, SampleRun, VanadiumRun\n", + "from ess.powder.types import (\n", + " DspacingBins,\n", + " Filename,\n", + " SampleRun,\n", + " UncertaintyBroadcastMode,\n", + " VanadiumRun,\n", + ")\n", "import ess.dream.data # noqa: F401\n", "from ess.dream import InstrumentConfiguration\n", "\n", @@ -59,16 +65,19 @@ "wfw = widget.children[1].children[0]\n", "outputs = wfw.output_selection_box.typical_outputs_widget\n", "keys, values = zip(*outputs.options, strict=True)\n", - "ind = keys.index(\"IntensityDspacing[SampleRun]\")\n", + "ind = keys.index(\"IntensityDspacingTwoTheta[SampleRun]\")\n", "outputs.value = (values[ind],)\n", - "# Refresh parameters\n", - "pbox = wfw.parameter_box\n", - "pbox.parameter_refresh_button.click()\n", "# Set parameters\n", - "pbox._input_widgets[Filename[SampleRun]].children[0].value = str(dream.data.simulated_diamond_sample())\n", - "pbox._input_widgets[Filename[VanadiumRun]].children[0].value = str(dream.data.simulated_vanadium_sample())\n", - "pbox._input_widgets[InstrumentConfiguration].value = InstrumentConfiguration.high_flux_BC215\n", - "pbox._input_widgets[DspacingBins].fields[\"stop\"].value = 2.3434\n", + "ui.set_parameter_widget_values(\n", + " wfw,\n", + " {\n", + " Filename[SampleRun]: str(dream.data.simulated_diamond_sample()),\n", + " Filename[VanadiumRun]: str(dream.data.simulated_vanadium_sample()),\n", + " InstrumentConfiguration: InstrumentConfiguration.high_flux_BC215,\n", + " UncertaintyBroadcastMode: UncertaintyBroadcastMode.drop,\n", + " DspacingBins: {\"stop\": 2.3434},\n", + " },\n", + ")\n", "# Run the workflow\n", "rbox = wfw.result_box\n", "rbox.run_button.click()" @@ -136,7 +145,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.11.13" } }, "nbformat": 4, diff --git a/packages/essdiffraction/src/ess/beer/parameters.py b/packages/essdiffraction/src/ess/beer/parameters.py new file mode 100644 index 000000000..86f8403a8 --- /dev/null +++ b/packages/essdiffraction/src/ess/beer/parameters.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) +"""Default parameter specs for BEER workflows.""" + +from __future__ import annotations + +from math import pi + +from ess.powder.masking import with_pixel_mask_filenames +from ess.powder.types import ( + CalibrationFilename, + DspacingBins, + EmptyCanRun, + Filename, + IntensityDspacing, + IntensityDspacingTwoTheta, + MonitorFilename, + NeXusDetectorName, + PixelMaskFilename, + ReducedTofCIF, + SampleRun, + TwoThetaBins, + UncertaintyBroadcastMode, + VanadiumRun, +) + +from ess.reduce.parameter import ParameterRegistry, ParameterSpec +from ess.reduce.parameter_models import AngleUnit, DspacingEdges, TwoTheta + +from .types import DetectorBank + + +def _edges(model): + return model.get_edges() + + +parameters = ParameterRegistry() + +parameters[Filename[SampleRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Run', + description='NeXus file path for the sample run.', + default=None, +) +parameters[Filename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium Run', + description='NeXus file path for the vanadium normalization run.', + default=None, +) +parameters[Filename[EmptyCanRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Empty Can Run', + description='NeXus file path for the empty-can background run.', + default=None, +) +parameters[CalibrationFilename] = ParameterSpec( + model=str | None, + category='Files', + title='Calibration', + description='Path to the calibration file used for detector calibration.', + default=None, +) +parameters[MonitorFilename[SampleRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Monitor', + description='NeXus file path for the sample monitor data.', + default=None, +) +parameters[PixelMaskFilename] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Pixel Masks', + description='Comma-separated paths to detector pixel mask files.', + default=(), + apply=with_pixel_mask_filenames, +) + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', + description='Name of the detector group in the NeXus files.', + default='detector', +) + +parameters[DspacingBins] = ParameterSpec( + model=DspacingEdges, + category='Binning', + title='D-spacing Edges', + description='D-spacing bin edges used for diffraction intensity outputs.', + default=DspacingEdges(start=0.0, stop=2.0, num_bins=200), + transform=_edges, + use_workflow_default=False, +) +parameters[TwoThetaBins] = ParameterSpec( + model=TwoTheta, + category='Binning', + title='Two-Theta Edges', + description='Two-theta bin edges used for two-dimensional diffraction outputs.', + default=TwoTheta(start=0.0, stop=pi, num_bins=180, unit=AngleUnit.RADIAN), + transform=_edges, + use_workflow_default=False, +) + +parameters[UncertaintyBroadcastMode] = ParameterSpec( + model=UncertaintyBroadcastMode, + category='Reduction', + title='Uncertainty Broadcast', + description='How uncertainties are broadcast when reduced data are combined.', + default=UncertaintyBroadcastMode.upper_bound, +) +parameters[DetectorBank] = ParameterSpec( + model=DetectorBank, + category='Reduction', + title='Detector Bank', + description='BEER detector bank to include in the reduction.', + default=DetectorBank.south, +) + + +typical_outputs = ( + IntensityDspacing[SampleRun], + IntensityDspacingTwoTheta[SampleRun], + ReducedTofCIF, +) diff --git a/packages/essdiffraction/src/ess/beer/workflow.py b/packages/essdiffraction/src/ess/beer/workflow.py index 0668e6a20..4a18f47e9 100644 --- a/packages/essdiffraction/src/ess/beer/workflow.py +++ b/packages/essdiffraction/src/ess/beer/workflow.py @@ -12,41 +12,65 @@ CalibrationData, CaveMonitor, EmptyCanRun, + Filename, + KeepEvents, + NeXusDetectorName, RunType, SampleRun, + TofMask, + TwoThetaMask, VanadiumRun, + WavelengthMask, ) from ess.reduce.nexus.types import DetectorBankSizes, NeXusName from ess.reduce.unwrap import GenericUnwrapWorkflow from ess.reduce.unwrap.types import LookupTableRelativeErrorThreshold +from ess.reduce.workflow import register_workflow from .clustering import providers as clustering_providers from .conversions import convert_from_known_peaks_providers, convert_pulse_shaping from .conversions import providers as conversion_providers +from .data import ( + mcstas_powder_empty_can, + mcstas_powder_silicon_in_vanadium_can, + mcstas_powder_vanadium, +) from .mcstas import ( mcstas_modulation_period_from_mode, mcstas_providers, pulse_shaping_mcstas_providers, ) -from .types import ( - PulseLength, -) +from .parameters import parameters, typical_outputs +from .types import DetectorBank, PulseLength default_parameters = { CalibrationData: None, PulseLength: sc.scalar(0.003, unit='s'), DetectorBankSizes: { - 'south_detector': {'y': 200, 'x': 500}, - 'north_detector': {'y': 200, 'x': 500}, + DetectorBank.south: {'y': 200, 'x': 500}, + DetectorBank.north: {'y': 200, 'x': 500}, }, + KeepEvents[RunType]: KeepEvents(False), + TwoThetaMask: lambda two_theta: ( + (two_theta < sc.scalar(float('inf'), unit=two_theta.unit)) + | (two_theta > sc.scalar(float('-inf'), unit=two_theta.unit)) + ), + TofMask: None, + WavelengthMask: None, + NeXusDetectorName: "detector", + DetectorBank: DetectorBank.south, + Filename[SampleRun]: str(mcstas_powder_silicon_in_vanadium_can()), + Filename[VanadiumRun]: str(mcstas_powder_vanadium()), + Filename[EmptyCanRun]: str(mcstas_powder_empty_can()), } +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerModMcStasWorkflow(): """Workflow to process BEER (modulation regime) McStas files without a list of estimated peak positions.""" - return sl.Pipeline( + wf = sl.Pipeline( ( *mcstas_providers, mcstas_modulation_period_from_mode, @@ -56,12 +80,14 @@ def BeerModMcStasWorkflow(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) + return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerModMcStasWorkflowKnownPeaks(): """Workflow to process BEER (modulation regime) McStas files using a list of estimated peak positions.""" - return sl.Pipeline( + wf = sl.Pipeline( ( *mcstas_providers, mcstas_modulation_period_from_mode, @@ -70,17 +96,21 @@ def BeerModMcStasWorkflowKnownPeaks(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) + return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerMcStasWorkflowPulseShaping(): """Workflow to process BEER (pulse shaping modes) McStas files""" - return sl.Pipeline( + wf = sl.Pipeline( (*mcstas_providers, *convert_pulse_shaping), params=default_parameters, constraints={RunType: (SampleRun,)}, ) + return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerMcStasWorkflowPulseShapingAnalytical(): """Workflow to process BEER pulse-shaping McStas files using analytical frame unwrapping.""" @@ -99,8 +129,11 @@ def BeerMcStasWorkflowPulseShapingAnalytical(): return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderWorkflow( - *, run_norm: RunNormalization = RunNormalization.monitor_integrated, **kwargs + *, + run_norm: RunNormalization = RunNormalization.monitor_integrated, + **kwargs, ) -> sl.Pipeline: """ Beer powder workflow with default parameters. @@ -134,8 +167,11 @@ def BeerPowderWorkflow( return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderWorkflowAnalytical( - *, run_norm: RunNormalization = RunNormalization.monitor_integrated, **kwargs + *, + run_norm: RunNormalization = RunNormalization.monitor_integrated, + **kwargs, ) -> sl.Pipeline: """ Beer powder workflow using analytical lookup-table frame unwrapping. @@ -176,19 +212,19 @@ def BeerPowderWorkflowAnalytical( return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderMcStasWorkflow(**kwargs) -> sl.Pipeline: """Create the BEER powder workflow with McStas loaders inserted.""" wf = BeerPowderWorkflow(**kwargs) for provider in mcstas_providers: wf.insert(provider) - return wf +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderMcStasWorkflowAnalytical(**kwargs) -> sl.Pipeline: """Create the BEER analytical powder workflow with McStas loaders inserted.""" wf = BeerPowderWorkflowAnalytical(**kwargs) for provider in itertools.chain(mcstas_providers, pulse_shaping_mcstas_providers): wf.insert(provider) - return wf diff --git a/packages/essdiffraction/src/ess/dream/parameters.py b/packages/essdiffraction/src/ess/dream/parameters.py index f82c8906e..097043e97 100644 --- a/packages/essdiffraction/src/ess/dream/parameters.py +++ b/packages/essdiffraction/src/ess/dream/parameters.py @@ -1,9 +1,12 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -"""Default parameters and providers for DREAM workflows.""" +"""Default parameter specs for DREAM workflows.""" from __future__ import annotations +from math import pi + +from ess.powder.masking import with_pixel_mask_filenames from ess.powder.types import ( CalibrationFilename, DspacingBins, @@ -16,58 +19,116 @@ PixelMaskFilename, ReducedTofCIF, SampleRun, + TwoThetaBins, UncertaintyBroadcastMode, VanadiumRun, ) -from ess.reduce.parameter import ( - BinEdgesParameter, - FilenameParameter, - MultiFilenameParameter, - ParamWithOptions, - StringParameter, - parameter_registry, -) +from ess.reduce.parameter import ParameterRegistry, ParameterSpec +from ess.reduce.parameter_models import AngleUnit, DspacingEdges, TwoTheta from .beamline import InstrumentConfiguration -parameter_registry[Filename[SampleRun]] = FilenameParameter.from_type( - Filename[SampleRun] + +def _edges(model): + return model.get_edges() + + +parameters = ParameterRegistry() + +parameters[Filename[SampleRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Run', + description='NeXus file path for the sample run.', + default=None, ) -parameter_registry[Filename[VanadiumRun]] = FilenameParameter.from_type( - Filename[VanadiumRun] +parameters[Filename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium Run', + description='NeXus file path for the vanadium normalization run.', + default=None, ) -parameter_registry[Filename[EmptyCanRun]] = FilenameParameter.from_type( - Filename[EmptyCanRun] +parameters[Filename[EmptyCanRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Empty Can Run', + description='NeXus file path for the empty-can background run.', + default=None, ) -parameter_registry[CalibrationFilename] = FilenameParameter.from_type( - CalibrationFilename +parameters[CalibrationFilename] = ParameterSpec( + model=str | None, + category='Files', + title='Calibration', + description='Path to the calibration file used for detector calibration.', + default=None, ) -parameter_registry[InstrumentConfiguration] = ParamWithOptions.from_enum( - InstrumentConfiguration, default=InstrumentConfiguration.high_flux_BC215 +parameters[MonitorFilename[SampleRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Monitor', + description='NeXus file path for the sample monitor data.', + default=None, ) -parameter_registry[MonitorFilename[SampleRun]] = FilenameParameter.from_type( - MonitorFilename[SampleRun] +parameters[MonitorFilename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium Monitor', + description='NeXus file path for the vanadium monitor data.', + default=None, ) -parameter_registry[MonitorFilename[VanadiumRun]] = FilenameParameter.from_type( - MonitorFilename[VanadiumRun] +parameters[PixelMaskFilename] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Pixel Masks', + description='Comma-separated paths to detector pixel mask files.', + default=(), + apply=with_pixel_mask_filenames, ) -parameter_registry[PixelMaskFilename] = MultiFilenameParameter.from_type( - PixelMaskFilename, + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', + description='Name of the detector group in the NeXus files.', + default='mantle', ) -parameter_registry[NeXusDetectorName] = StringParameter.from_type( - NeXusDetectorName, default="mantle" + +parameters[DspacingBins] = ParameterSpec( + model=DspacingEdges, + category='Binning', + title='D-spacing Edges', + description='D-spacing bin edges used for diffraction intensity outputs.', + default=DspacingEdges(start=0.0, stop=2.0, num_bins=200), + transform=_edges, + use_workflow_default=False, ) -parameter_registry[DspacingBins] = BinEdgesParameter( - DspacingBins, dim='dspacing', unit='angstrom', start=0.0, stop=2.0, nbins=200 +parameters[TwoThetaBins] = ParameterSpec( + model=TwoTheta, + category='Binning', + title='Two-Theta Edges', + description='Two-theta bin edges used for two-dimensional diffraction outputs.', + default=TwoTheta(start=0.0, stop=pi, num_bins=180, unit=AngleUnit.RADIAN), + transform=_edges, + use_workflow_default=False, +) + +parameters[InstrumentConfiguration] = ParameterSpec( + model=InstrumentConfiguration, + category='Reduction', + title='Instrument Configuration', + description='DREAM chopper system configuration used in the measurement.', + default=InstrumentConfiguration.high_flux_BC215, ) -parameter_registry[UncertaintyBroadcastMode] = ParamWithOptions.from_enum( - UncertaintyBroadcastMode, default=UncertaintyBroadcastMode.upper_bound +parameters[UncertaintyBroadcastMode] = ParameterSpec( + model=UncertaintyBroadcastMode, + category='Reduction', + title='Uncertainty Broadcast', + description='How uncertainties are treated when uncertain quantities are combined.', + default=UncertaintyBroadcastMode.upper_bound, ) -# TODO: the mask parameters (TofMask, TwoThetaMask, WavelengthMask) need a new widget -# that allows to define a python function in the notebook and pass it to the workflow. -# We defer this to later; the masks are set to None by default in the workflow for now. typical_outputs = ( IntensityDspacing[SampleRun], diff --git a/packages/essdiffraction/src/ess/dream/workflows.py b/packages/essdiffraction/src/ess/dream/workflows.py index 813ae228d..47fd80d2c 100644 --- a/packages/essdiffraction/src/ess/dream/workflows.py +++ b/packages/essdiffraction/src/ess/dream/workflows.py @@ -7,7 +7,6 @@ import scipp as sc import scippnexus as snx from ess.powder import providers as powder_providers -from ess.powder import with_pixel_mask_filenames from ess.powder.correction import RunNormalization, insert_run_normalization from ess.powder.types import ( AccumulatedProtonCharge, @@ -19,7 +18,7 @@ LookupTableFilename, LookupTableRelativeErrorThreshold, Measurement, - PixelMaskFilename, + NeXusDetectorName, Position, ReducerSoftware, SampleRun, @@ -31,7 +30,6 @@ from scippneutron.metadata import Software from ess.reduce.nexus.types import DetectorBankSizes, NeXusName -from ess.reduce.parameter import parameter_mappers from ess.reduce.unwrap import GenericUnwrapWorkflow, WavelengthLutMode from ess.reduce.workflow import register_workflow @@ -42,7 +40,7 @@ prepare_reduced_tof_cif, ) from .io.geant4 import providers as geant4_providers -from .parameters import typical_outputs +from .parameters import parameters, typical_outputs DETECTOR_BANK_SIZES = { "endcap_backward_detector": { @@ -148,8 +146,6 @@ def DreamWorkflow( prepare_reduced_empty_can_subtracted_tof_cif, ) -parameter_mappers[PixelMaskFilename] = with_pixel_mask_filenames - def default_parameters() -> dict: """Return the default reduction parameters shared by DREAM powder workflows.""" @@ -160,6 +156,7 @@ def default_parameters() -> dict: TofMask: None, WavelengthMask: None, TwoThetaMask: None, + NeXusDetectorName: "mantle", CIFAuthors: CIFAuthors([]), } @@ -186,11 +183,14 @@ def DreamPowderWorkflow(*, run_norm: RunNormalization, **kwargs) -> sciline.Pipe insert_run_normalization(wf, run_norm) for key, value in default_parameters().items(): wf[key] = value - wf.typical_outputs = typical_outputs return wf -def DreamGeant4Workflow(*, run_norm: RunNormalization, **kwargs) -> sciline.Pipeline: +def DreamGeant4Workflow( + *, + run_norm: RunNormalization, + **kwargs, +) -> sciline.Pipeline: """ Workflow with default parameters for the Dream Geant4 simulation. @@ -245,11 +245,10 @@ def DreamGeant4Workflow(*, run_norm: RunNormalization, **kwargs) -> sciline.Pipe for key, value in additional_parameters.items(): wf[key] = value - wf.typical_outputs = typical_outputs return wf -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def DreamGeant4MonitorHistogramWorkflow() -> sciline.Pipeline: """ Workflow with default parameters for the Dream Geant4 simulation, using a @@ -258,7 +257,7 @@ def DreamGeant4MonitorHistogramWorkflow() -> sciline.Pipeline: return DreamGeant4Workflow(run_norm=RunNormalization.monitor_histogram) -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def DreamGeant4MonitorIntegratedWorkflow() -> sciline.Pipeline: """ Workflow with default parameters for the Dream Geant4 simulation, using @@ -267,7 +266,7 @@ def DreamGeant4MonitorIntegratedWorkflow() -> sciline.Pipeline: return DreamGeant4Workflow(run_norm=RunNormalization.monitor_integrated) -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def DreamGeant4ProtonChargeWorkflow() -> sciline.Pipeline: """ Workflow with default parameters for the Dream Geant4 simulation, using diff --git a/packages/essdiffraction/tests/beer/mcstas_reduction_test.py b/packages/essdiffraction/tests/beer/mcstas_reduction_test.py index f56341f48..1ad0c7163 100644 --- a/packages/essdiffraction/tests/beer/mcstas_reduction_test.py +++ b/packages/essdiffraction/tests/beer/mcstas_reduction_test.py @@ -27,11 +27,15 @@ from ess.powder.types import ( DspacingDetector, ElasticCoordTransformGraph, + IntensityDspacingTwoTheta, + MaskedDetectorIDs, + PixelMaskFilename, RawDetector, SampleRun, ) from scipp.testing import assert_allclose +from ess.reduce import workflow as reduce_workflow from ess.reduce.nexus.types import DetectorBankSizes, Filename @@ -123,6 +127,19 @@ def test_powder_mcstas_analytical_workflow_computes_dspacing(): ) +def test_powder_mcstas_analytical_workflow_exposes_pixel_mask_parameter(): + wf = BeerPowderMcStasWorkflowAnalytical() + spec = reduce_workflow.workflow_registry.get(BeerPowderMcStasWorkflowAnalytical) + + specs = reduce_workflow.get_parameters( + wf, (IntensityDspacingTwoTheta[SampleRun],), spec.parameters + ) + wf = reduce_workflow.assign_parameter_values(wf, {PixelMaskFilename: ()}, specs) + + assert PixelMaskFilename in specs + assert wf.compute(MaskedDetectorIDs) == {} + + def test_can_load_3d_detector(): sizes = { 'north_detector': {'x': 500, 'y': 200}, diff --git a/packages/essdiffraction/tests/dream/geant4_reduction_test.py b/packages/essdiffraction/tests/dream/geant4_reduction_test.py index 31bc513c1..4b5bef608 100644 --- a/packages/essdiffraction/tests/dream/geant4_reduction_test.py +++ b/packages/essdiffraction/tests/dream/geant4_reduction_test.py @@ -10,6 +10,7 @@ import scipp as sc import scipp.testing from ess import dream, powder +from ess.dream.parameters import parameters as dream_parameters from ess.dream.workflows import ( DreamGeant4MonitorHistogramWorkflow, DreamGeant4MonitorIntegratedWorkflow, @@ -29,6 +30,7 @@ IntensityTof, KeepEvents, LookupTableFilename, + MaskedDetectorIDs, MonitorFilename, NeXusDetectorName, ReducedTofCIF, @@ -46,6 +48,7 @@ from ess.reduce import unwrap from ess.reduce import workflow as reduce_workflow from ess.reduce.nexus.types import AnyRun, Position +from ess.reduce.parameter import keep_default PARAMETRIZATION = ("detector_name", ["mantle", "endcap_backward", "endcap_forward"]) @@ -337,15 +340,34 @@ def test_dream_workflow_registers_subclasses(): assert wf in reduce_workflow.workflow_registry count = len(reduce_workflow.workflow_registry) - @reduce_workflow.register_workflow + @reduce_workflow.register_workflow() class MyWorkflow: ... assert MyWorkflow in reduce_workflow.workflow_registry assert len(reduce_workflow.workflow_registry) == count + 1 -def test_dream_workflow_parameters_returns_filtered_params(): +def test_dream_workflow_registers_parameter_registry(): + spec = reduce_workflow.workflow_registry.get(DreamGeant4ProtonChargeWorkflow) + assert spec.parameters is dream_parameters + + +def test_dream_workflow_applies_parameter_values(): wf = DreamGeant4ProtonChargeWorkflow() - parameters = reduce_workflow.get_parameters(wf, (CorrectedDetector[SampleRun],)) - assert Filename[SampleRun] in parameters - assert Filename[EmptyCanRun] not in parameters + spec = reduce_workflow.workflow_registry.get(DreamGeant4ProtonChargeWorkflow) + specs = reduce_workflow.get_parameters( + wf, (IntensityDspacingTwoTheta[SampleRun],), spec.parameters + ) + values = { + key: spec.default + for key, spec in specs.items() + if spec.default is not keep_default + } + values[Filename[SampleRun]] = "sample.csv" + + wf = reduce_workflow.assign_parameter_values(wf, values, specs) + + assert wf.compute(Filename[SampleRun]) == "sample.csv" + assert wf.compute(CalibrationFilename) is None + assert wf.compute(MaskedDetectorIDs) == {} + assert wf.compute(TwoThetaBins).unit == sc.Unit('rad') diff --git a/packages/essnmx/src/ess/nmx/workflows.py b/packages/essnmx/src/ess/nmx/workflows.py index 1a50c12c0..84896ec88 100644 --- a/packages/essnmx/src/ess/nmx/workflows.py +++ b/packages/essnmx/src/ess/nmx/workflows.py @@ -205,7 +205,7 @@ def compute_detector_tof(da: WavelengthDetector[RunType]) -> TofDetector[RunType ) -@register_workflow +@register_workflow() def NMXWorkflow( wavelength_from: WavelengthLutMode = "file", **kwargs ) -> sciline.Pipeline: diff --git a/packages/essreduce/docs/developer/gui.ipynb b/packages/essreduce/docs/developer/gui.ipynb index a3144c260..133013f71 100644 --- a/packages/essreduce/docs/developer/gui.ipynb +++ b/packages/essreduce/docs/developer/gui.ipynb @@ -4,39 +4,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Graphic user interfaces for data reduction.\n", - "``ess.reduce.parameter.Parameter`` is an interface between ``params`` of ``sciline.Pipeline``(workflow) and GUI components.\n", + "# Graphical user interfaces for data reduction\n", "\n", - "In this page, we will explain how to implement new Parameter and widgets and map those components with each other." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Registries: How to map ``Parameter``, ``Workflow`` and ``Pipeline``.\n", - "Parameters, workflows and widgets should be registered once they are implemented\n", - "in order to be used automatically by workflow-widget building helper.\n", + "Workflow interfaces are generated from three pieces of information: a function that returns a ``sciline.Pipeline``, a mapping from Sciline keys to ``ParameterSpec`` objects, and optional workflow metadata such as typical outputs.\n", "\n", - "The helper uses registries to\n", - "- Find workflows\n", - "- Find input arguments that can be set by widget\n", - "- Find output types that can be computed by widget\n", - "- Map ``Parameter`` and ``Pipeline.params`` to build smaller widgets\n", - "- Set ``Pipeline.params`` based on the inputs of corresponding widgets" + "Each ``ParameterSpec`` describes the Python or Pydantic model used to edit a workflow input, the UI category where it should be shown, and optional conversion logic for assigning the widget value back to the workflow." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Tutorial: Implement Random Distribution Histogram Workflow GUI\n", - "0. Prepare ``workflow`` interface that returns a ``sciline.Pipeline``\n", - " We are going to use this random number histogram workflow as an example.\n", - " We would like to implement a widget that users can put ``NumBins`` as an input.\n", + "## Tutorial: Random distribution histogram\n", "\n", - " If you want to specify ``typical_outputs``, the ``Pipeline`` object should have a property called ``typical_outputs: tuple[type, ...]``.\n", - " If it does not have a ``typical_outputs``, leaf nodes will be used as typical outputs." + "This example defines a small workflow that computes a histogram from random values. The workflow has one configurable input, ``NumBins``." ] }, { @@ -45,10 +26,11 @@ "metadata": {}, "outputs": [], "source": [ - "import scipp as sc\n", - "import sciline as sl\n", "from typing import NewType\n", + "\n", "import numpy as np\n", + "import sciline as sl\n", + "import scipp as sc\n", "\n", "\n", "NumBins = NewType('NumBins', int)\n", @@ -62,9 +44,7 @@ "\n", "\n", "def RandomDistributionWorkflow() -> sl.Pipeline:\n", - " wf = sl.Pipeline(providers=(histogram,))\n", - " # wf.typical_outputs = (Histogram, ) # Can be skipped since it's the only leaf node.\n", - " return wf\n", + " return sl.Pipeline(providers=(histogram,))\n", "\n", "\n", "RandomDistributionWorkflow()\n" @@ -74,7 +54,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "1. Register workflow to the ``workflow_registry``." + "Register the workflow together with the parameter specification used by the generated UI. The Sciline graph remains the source of truth for applicability: the widget only shows parameters that are required for the selected output quantities." ] }, { @@ -83,54 +63,32 @@ "metadata": {}, "outputs": [], "source": [ + "from ess.reduce.parameter import ParameterRegistry, ParameterSpec\n", "from ess.reduce.workflow import register_workflow\n", "\n", - "register_workflow(RandomDistributionWorkflow)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "2. Register ``domain-type``-``Parameter`` instance mapping to ``parameter_registry``.\n", - "\n", - "``Parameter`` and its subclasses have a class method ``from_type`` that helps to create a new parameter instance for a specific domain type.\n", - "\n", - "There are various parameter types already exist in ``ess.reduce.parameter`` module, but here we will show you how to make a new one.\n", - "\n", - "Once you know which ``Parameter`` to use for the specific ``domain-type``, \n", - "\n", - "you can register the new **parameter instance** to the **domain type** in the ``parameter_registry``." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from ess.reduce.parameter import parameter_registry, Parameter\n", - "\n", - "\n", - "class BinNumberParameter(Parameter): ...\n", "\n", - "\n", - "parameter_registry[NumBins] = BinNumberParameter(\n", - " name=NumBins.__name__,\n", - " description='Number of bins in the histogram',\n", - " default=NumBins(10),\n", + "parameters = ParameterRegistry()\n", + "parameters[NumBins] = ParameterSpec(\n", + " model=int,\n", + " category='Histogram',\n", + " title='Number of bins',\n", + " description='Number of bins in the histogram.',\n", + " default=10,\n", + " transform=NumBins,\n", ")\n", "\n", - "# You can also use ``from_type``(class method) helper to instantiate the parameter.\n", - "# parameter_registry[NumBins] = BinNumberParameter.from_type(NumBins)" + "register_workflow(\n", + " parameters=parameters,\n", + " typical_outputs=(Histogram,),\n", + " title='Random distribution histogram',\n", + ")(RandomDistributionWorkflow)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "3. Register ``type[Parameter]`` - ``type[Widget]`` mapping to ``create_parameter_widget`` distpatch.\n", - "\n" + "Create the workflow widget, select the example workflow, select the histogram output, and run the workflow. The order matters for the docs build because the widget must exist before it is manipulated programmatically." ] }, { @@ -139,54 +97,14 @@ "metadata": {}, "outputs": [], "source": [ - "from ess.reduce.widgets import create_parameter_widget\n", - "import ipywidgets as widgets\n", + "from ess.reduce.ui import WorkflowWidget, workflow_widget\n", + "from ess.reduce.workflow import workflow_registry\n", "\n", "\n", - "@create_parameter_widget.register(BinNumberParameter)\n", - "def scalar_parameter_widget(param: BinNumberParameter):\n", - " return widgets.IntText(\n", - " value=param.default, description=param.name, tooltip=param.description\n", - " )\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Example UI from the tutorial." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from ess.reduce.ui import workflow_widget\n", - "\n", - "ess_widget = workflow_widget()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that the order of operations here is important.\n", - "We have to first set the workflow, then display the widget, and then interact with the widget programmatically.\n", - "Otherwise, the docs build can freeze.\n", - "It is unclear why this happens." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from ess.reduce.ui import WorkflowWidget\n", - "\n", - "ess_widget.children[0].children[0].value = RandomDistributionWorkflow" + "ess_widget = workflow_widget()\n", + "ess_widget.children[0].children[0].value = workflow_registry.get(\n", + " RandomDistributionWorkflow\n", + ")\n" ] }, { @@ -205,9 +123,8 @@ "outputs": [], "source": [ "body_widget: WorkflowWidget = ess_widget.children[1].children[0]\n", - "body_widget.output_selection_box.typical_outputs_widget.value = [Histogram]\n", + "body_widget.output_selection_box.typical_outputs_widget.value = (Histogram,)\n", "\n", - "body_widget.parameter_box.parameter_refresh_button.click()\n", "body_widget.result_box.run_button.click()" ] }, @@ -215,34 +132,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Wrapper Widgets\n", - "\n", - "In order to handle special cases of parameter settings, we have wrapper widgets.\n", - "\n", - "Each wrapper widget is associated with certain attribute of ``Parameter`` object.\n", - "\n", - "They are implemented as a decorator around widget type dispatch function like below.\n", - "\n", - "It is because of ``@singledispatch`` decorator.\n", - "\n", - "```python\n", - "# In ess.reduce.widgets module\n", - "@switchable_widget\n", - "@optional_widget # optional_widget should be applied first\n", - "@singledispatch\n", - "def create_parameter_widget(param: Parameter) -> widgets.Widget: ...\n", - "```\n", - "\n", - "### Switchable Widget: ``Parameter.switchable``\n", + "## Pydantic parameter models\n", "\n", - "Widgets are wrapped in ``SwitchableWidget`` if ``Parameter`` is ``switchable``.\n", - "\n", - "The wrapped parameter input widget can be turned off and on.\n", - "\n", - "If the widget is `enabled`(on), the workflow-compute handling widget should set the value as a parameter into the `Pipeline(workflow)`,\n", - "but if the widget is not `enabled`(off), the workflow-compute handling widget should skip setting the value as a parameter.\n", - "\n", - "It means it will either use the default parameter that was set or computed by providers." + "Scalar parameters can use plain Python types such as ``int`` or ``str``. More structured parameters should use Pydantic models. Field titles become labels, descriptions are exposed as help text, defaults initialize the widgets, and Pydantic validation runs before the workflow is executed." ] }, { @@ -251,73 +143,36 @@ "metadata": {}, "outputs": [], "source": [ - "from ess.reduce.widgets import create_parameter_widget\n", - "from ess.reduce.parameter import Parameter\n", + "from pydantic import BaseModel, Field\n", "\n", - "switchable_parameter = Parameter(\n", - " name='SwitchableParameter', description=\"\", default=\"\", switchable=True\n", - ")\n", - "switchable_widget = create_parameter_widget(switchable_parameter)\n", - "switchable_widget.enabled = True\n", - "switchable_widget" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Optional Widget: ``Parameter.optional``\n", + "from ess.reduce.widgets import PydanticParameterValueWidget\n", "\n", - "Widgets are wrapped in a ``OptionalWidget`` if ``Parameter`` is ``optional``.\n", "\n", - "The wrapped parameter input widget can select ``None`` as a value.\n", - "\n", - "If ``None`` is selected, the workflow-compute handling widget should set ``None`` as a parameter into the `Pipeline(workflow)`,\n", - "but if the widget is not ``None``, the workflow-compute handling widget will retrieve the value from the wrapped widget.\n", - "\n", - "This wrapper is for the providers expecting optional arguments and handle the ``None`` itself." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from ess.reduce.widgets import create_parameter_widget\n", - "from ess.reduce.parameter import Parameter\n", + "class RangeInput(BaseModel):\n", + " start: float = Field(\n", + " default=0.0,\n", + " title='Start',\n", + " description='Lower edge of the histogram range.',\n", + " )\n", + " stop: float = Field(\n", + " default=1.0,\n", + " title='Stop',\n", + " description='Upper edge of the histogram range.',\n", + " )\n", + " bins: int = Field(\n", + " default=10,\n", + " ge=1,\n", + " title='Bins',\n", + " description='Number of bins. Must be at least one.',\n", + " )\n", "\n", - "optional_parameter = Parameter(\n", - " name='OptionalParameter', description=\"\", default=\"\", optional=True\n", - ")\n", - "optional_widget = create_parameter_widget(optional_parameter)\n", - "optional_widget.value = \"Test String\"\n", - "optional_widget" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If ``Parameter`` object is both ``switchable`` and ``optional``, the widget is wrapped both in ``SwitchWidget`` and ``OptionalWidget``." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from ess.reduce.widgets import create_parameter_widget\n", - "from ess.reduce.parameter import Parameter\n", "\n", - "switchable_and_optional_widget = create_parameter_widget(\n", - " Parameter(\n", - " name='Parameter', description=\"\", default=\"\", switchable=True, optional=True\n", - " )\n", + "range_widget = PydanticParameterValueWidget(\n", + " RangeInput,\n", + " title='Histogram range',\n", + " description='Range and resolution for a histogram axis.',\n", ")\n", - "switchable_and_optional_widget.enabled = True\n", - "switchable_and_optional_widget" + "range_widget" ] } ], @@ -337,7 +192,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.11.15" } }, "nbformat": 4, diff --git a/packages/essreduce/pyproject.toml b/packages/essreduce/pyproject.toml index cacd35069..b5563d456 100644 --- a/packages/essreduce/pyproject.toml +++ b/packages/essreduce/pyproject.toml @@ -32,6 +32,7 @@ dynamic = ["version"] dependencies = [ "dask>=2022.1.0", "graphviz>=0.20", + "pydantic>=2", "sciline>=25.11.0", "scipp>=26.3.1", "scippneutron>=26.6.0", diff --git a/packages/essreduce/src/ess/reduce/parameter.py b/packages/essreduce/src/ess/reduce/parameter.py index 8137bce57..560b4a18a 100644 --- a/packages/essreduce/src/ess/reduce/parameter.py +++ b/packages/essreduce/src/ess/reduce/parameter.py @@ -2,12 +2,11 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) from __future__ import annotations -from collections.abc import MutableMapping -from dataclasses import dataclass -from enum import Enum -from typing import Generic, Self, TypeVar +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass, replace +from typing import Any, Generic, TypeVar -import scipp as sc +from sciline import Pipeline from sciline._utils import key_name from sciline.typing import Key @@ -21,158 +20,51 @@ class KeepDefaultType: keep_default = KeepDefaultType() -@dataclass -class Parameter(Generic[T]): - """Interface between workflow parameters and widgets. +@dataclass(frozen=True) +class ParameterSpec(Generic[T]): + """Specification for configuring a Sciline workflow key.""" - .. versionadded:: RELEASE_PLACEHOLDER - """ + model: Any + category: str + title: str | None = None + description: str | None = None + default: T | KeepDefaultType = keep_default + transform: Callable[[T], Any] | None = None + apply: Callable[[Pipeline, T], Pipeline] | None = None + use_workflow_default: bool = True + key: Key | None = None - name: str - description: str - default: T - optional: bool = False - """If True, widget has radio buttons switch between "None" and param widget.""" - switchable: bool = False - """If True, widget has checkbox to enable/disable parameter.""" + def bind(self, key: Key) -> ParameterSpec[T]: + return replace(self, key=key) - def with_default(self: Self, default: T | KeepDefaultType = keep_default) -> Self: - if default == keep_default: + def with_default( + self, default: T | KeepDefaultType = keep_default + ) -> ParameterSpec[T]: + if default is keep_default or not self.use_workflow_default: return self - # TODO I think some subclasses currently cannot be instantiated with this method - return type(self)( - self.name, self.description, default, self.optional, self.switchable - ) - - @classmethod - def from_type( - cls: type[Self], - t: type[T], - default: T | None = None, - optional: bool = False, - switchable: bool = False, - ) -> Self: - # TODO __doc__ not correct when using NewType - # TODO __doc__ not correct when using Generic - # use sciline type->string helper - return cls( - name=key_name(t), - description=t.__doc__, - default=default, - optional=optional, - switchable=switchable, - ) + return replace(self, default=default) + def with_apply( + self, apply: Callable[[Pipeline, T], Pipeline] | None + ) -> ParameterSpec[T]: + return replace(self, apply=apply) -@dataclass(kw_only=True) -class ParamWithOptions(Parameter[T]): - options: Enum - - @classmethod - def from_enum(cls: type[Self], t: type[T], default: T) -> Self: - return cls( - name=t.__name__, - description=t.__doc__, - options=t.__members__, - default=default, + @property + def name(self) -> str: + return self.title or ( + key_name(self.key) if self.key is not None else 'Parameter' ) -@dataclass -class FilenameParameter(Parameter[str]): - """Widget for entering a filename or selecting one in a file dialog.""" - - # TODO need specifics for different file types, nexus, ... - - -@dataclass -class MultiFilenameParameter(Parameter[tuple[str, ...]]): - """Widget for entering multiple filenames or selecting multiple in a file dialog.""" - - -@dataclass(kw_only=True) -class BinEdgesParameter(Parameter[sc.Variable]): - """Widget for entering bin edges.""" - - dim: str - start: float | None = None - stop: float | None = None - nbins: int = 1 - unit: str | None = "undefined" # If "undefined", the unit is deduced from the dim - log: bool = False - - def __init__( - self, - t: type[T], - dim: str, - start: float | None = None, - stop: float | None = None, - nbins: int = 1, - unit: str | None = "undefined", - log: bool = False, - ): - self.dim = dim - self.start = start - self.stop = stop - self.nbins = nbins - self.unit = unit - self.log = log - super().__init__(name=key_name(t), description=t.__doc__, default=None) - - -@dataclass -class BooleanParameter(Parameter[bool]): - pass - - -@dataclass -class StringParameter(Parameter[str]): - pass - - -@dataclass -class MultiStringParameter(Parameter[tuple[str, ...]]): - """Widget for entering multiple strings.""" - - -@dataclass(kw_only=True) -class ParamWithBounds(Parameter[T]): - bounds: tuple[T, T] - - -@dataclass(kw_only=True) -class ScalarParameter(Parameter[T]): - """Fixed unit displayed in widget""" - - unit: str - - -@dataclass(kw_only=True) -class ScalarParamWithUnitOptions(Parameter[T]): - """User can select between compatible units""" - - unit_options: list[str] - - -@dataclass(kw_only=True) -class Vector2dParameter(Parameter[sc.Variable]): - """Widget for entering a 2d vector.""" - - -@dataclass(kw_only=True) -class Vector3dParameter(Parameter[sc.Variable]): - """Widget for entering a 3d vector.""" - - class ParameterRegistry(MutableMapping): def __init__(self): - self._parameters = {} + self._parameters: dict[Key, ParameterSpec] = {} - def __getitem__(self, key: Key) -> Parameter: + def __getitem__(self, key: Key) -> ParameterSpec: return self._parameters[key] - def __setitem__(self, key: Key, value: Parameter): - self._parameters[key] = value + def __setitem__(self, key: Key, value: ParameterSpec): + self._parameters[key] = value.bind(key) def __delitem__(self, key: Key): del self._parameters[key] @@ -185,6 +77,3 @@ def __len__(self) -> int: parameter_registry = ParameterRegistry() - - -parameter_mappers = {} diff --git a/packages/essreduce/src/ess/reduce/parameter_models.py b/packages/essreduce/src/ess/reduce/parameter_models.py new file mode 100644 index 000000000..2b398c7ca --- /dev/null +++ b/packages/essreduce/src/ess/reduce/parameter_models.py @@ -0,0 +1,283 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) +""" +Models for data reduction workflow inputs. + +These models define inputs to Sciline workflows. They can be used for creating +user interfaces for configuring workflows as well as validation of the inputs +before values are applied to a workflow. +""" + +from __future__ import annotations + +import json +from abc import ABC +from enum import StrEnum +from pathlib import Path + +import scipp as sc +from pydantic import BaseModel, Field, field_validator, model_validator + + +def parse_number_list(value: str) -> list[float]: + """Parse a comma-separated string of numbers into floats.""" + value = value.strip() + if not value: + return [] + try: + parsed = json.loads(f"[{value}]") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid number list: {e}") from e + if any(isinstance(x, bool) or not isinstance(x, int | float) for x in parsed): + raise ValueError("All entries must be numbers") + return [float(x) for x in parsed] + + +class RangeModel(BaseModel, ABC): + """Base model for range with common fields and validation.""" + + start: float = Field(default=0.0, description="Start of the range.") + stop: float = Field(default=10.0, description="Stop of the range.") + + @field_validator('stop') + @classmethod + def stop_must_be_greater_than_start(cls, v, info): + start = info.data.get('start') + if start is not None and v <= start: + raise ValueError('stop must be greater than start') + return v + + def get_start(self) -> sc.Variable: + """Get the start of the range as a scipp variable.""" + return sc.scalar(self.start, unit=self.unit.value) + + def get_stop(self) -> sc.Variable: + """Get the stop of the range as a scipp variable.""" + return sc.scalar(self.stop, unit=self.unit.value) + + +class Scale(StrEnum): + """Allowed scales for data reduction.""" + + LINEAR = 'linear' + LOG = 'log' + + +class EdgesModel(BaseModel, ABC): + """Base model for edges with common fields and validation.""" + + start: float = Field(default=1.0, description="Start of the edges.") + stop: float = Field(default=10.0, description="Stop of the edges.") + num_bins: int = Field(default=100, ge=1, le=10000, description="Number of bins.") + scale: Scale = Field( + default=Scale.LINEAR, + description="Scale of the edges, either 'linear' or 'log'.", + ) + + @field_validator('stop') + @classmethod + def stop_must_be_greater_than_start(cls, v, info): + start = info.data.get('start') + if start is not None and v <= start: + raise ValueError('stop must be greater than start') + return v + + @model_validator(mode='after') + def start_must_be_positive_if_log(self): + if self.scale == Scale.LOG and self.start <= 0: + raise ValueError("start must be positive when scale is 'log'") + return self + + +class TimeUnit(StrEnum): + """Allowed units for time.""" + + NS = 'ns' + US = 'us' + MICROSECOND = 'μs' + MS = 'ms' + S = 's' + + +class WavelengthUnit(StrEnum): + """Allowed units for wavelength.""" + + ANGSTROM = 'Å' + NANOMETER = 'nm' + + +class DspacingUnit(StrEnum): + """Allowed units for d-spacing.""" + + ANGSTROM = 'Å' + NANOMETER = 'nm' + + +class LengthUnit(StrEnum): + """Allowed units for length.""" + + METER = 'm' + CENTIMETER = 'cm' + MILLIMETER = 'mm' + + +class AngleUnit(StrEnum): + """Allowed units for angles.""" + + DEGREE = 'deg' + RADIAN = 'rad' + + +class QUnit(StrEnum): + """Allowed units for Q.""" + + INVERSE_ANGSTROM = '1/Å' + INVERSE_NANOMETER = '1/nm' + + +class Angle(BaseModel): + """Model for an angle value.""" + + value: float = Field(default=0.0, description="Angle value.") + unit: AngleUnit = Field(default=AngleUnit.DEGREE, description="Unit of the angle.") + + def get_value(self) -> sc.Variable: + """Get the angle as a scipp scalar.""" + return sc.scalar(self.value, unit=self.unit.value) + + +class Filename(BaseModel): + """Model for a filename.""" + + value: Path = Field(..., description="Path to the file.") + + +class WavelengthRange(RangeModel): + """Model for wavelength range.""" + + unit: WavelengthUnit = Field( + default=WavelengthUnit.ANGSTROM, description="Unit of the wavelength range." + ) + + +class TOARange(RangeModel): + """Time of arrival range filter settings.""" + + enabled: bool = Field(default=False, description="Enable the range filter.") + unit: TimeUnit = Field( + default=TimeUnit.MICROSECOND, description="Unit of the interval bounds." + ) + + @property + def range(self) -> tuple[sc.Variable, sc.Variable]: + """Time of arrival range as a tuple of scipp scalars.""" + return (self.get_start(), self.get_stop()) + + +class TOAEdges(EdgesModel): + """Model for time of arrival edges.""" + + unit: TimeUnit = Field(default=TimeUnit.MS, description="Unit of the edges.") + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='time_of_arrival', unit=self.unit.value) + + +class WavelengthEdges(EdgesModel): + """Model for wavelength edges.""" + + unit: WavelengthUnit = Field( + default=WavelengthUnit.ANGSTROM, description="Unit of the edges." + ) + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='wavelength', unit=self.unit.value) + + +class WavelengthRangeFilter(RangeModel): + """Wavelength range filter settings for detector view.""" + + enabled: bool = Field(default=False, description="Enable the range filter.") + unit: WavelengthUnit = Field( + default=WavelengthUnit.ANGSTROM, description="Unit of the range bounds." + ) + + @property + def range(self) -> tuple[sc.Variable, sc.Variable]: + """Wavelength range as a tuple of scipp scalars.""" + return (self.get_start(), self.get_stop()) + + +class DspacingEdges(EdgesModel): + """Model for d-spacing edges.""" + + unit: DspacingUnit = Field( + default=DspacingUnit.ANGSTROM, description="Unit of the edges." + ) + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='dspacing', unit=self.unit.value) + + +class TwoTheta(EdgesModel): + """Model for two-theta edges.""" + + unit: AngleUnit = Field(default=AngleUnit.DEGREE, description="Unit of the edges.") + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='two_theta', unit=self.unit.value) + + +class ThetaEdges(EdgesModel): + """Model for theta bin edges.""" + + unit: AngleUnit = Field( + default=AngleUnit.DEGREE, description="Unit of the theta bin edges." + ) + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='theta', unit=self.unit.value) + + +class QEdges(EdgesModel): + """Model for Q edges.""" + + unit: QUnit = Field( + default=QUnit.INVERSE_ANGSTROM, description="Unit of the Q edges." + ) + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='Q', unit=self.unit.value) + + +class EnergyUnit(StrEnum): + """Allowed units for energy transfer.""" + + MILLI_EV = 'meV' + MICRO_EV = 'μeV' + + +class EnergyEdges(EdgesModel): + """Model for energy transfer edges.""" + + unit: EnergyUnit = Field( + default=EnergyUnit.MILLI_EV, description="Unit of the energy transfer edges." + ) + + def get_edges(self) -> sc.Variable: + """Get the edges as a scipp variable.""" + return make_edges(model=self, dim='ΔE', unit=self.unit.value) + + +def make_edges(*, model: EdgesModel, dim: str, unit: str) -> sc.Variable: + """Convert the edges to a scipp variable.""" + op = {Scale.LINEAR: sc.linspace, Scale.LOG: sc.geomspace}[model.scale] + return op( + dim=dim, start=model.start, stop=model.stop, num=model.num_bins + 1, unit=unit + ) diff --git a/packages/essreduce/src/ess/reduce/ui.py b/packages/essreduce/src/ess/reduce/ui.py index fef5c2117..dd5e5c339 100644 --- a/packages/essreduce/src/ess/reduce/ui.py +++ b/packages/essreduce/src/ess/reduce/ui.py @@ -6,13 +6,17 @@ import ipywidgets as widgets import sciline as sl from IPython import display -from ipywidgets import Layout - -from .parameter import Parameter -from .widgets import Spinner, SwitchWidget, create_parameter_widget, default_layout -from .widgets._base import get_fields, set_fields +from pydantic_core import PydanticUndefined +from sciline.typing import Key + +from .parameter import ParameterSpec, keep_default +from .widgets import ( + PydanticParameterValueWidget, + Spinner, + full_width_layout, +) from .workflow import ( - Key, + WorkflowSpec, assign_parameter_values, get_parameters, get_possible_outputs, @@ -22,31 +26,36 @@ def _wrap_foldable( - wrapped: widgets.Widget, title: str | None = None + wrapped: widgets.Widget, title: str | None = None, *, expanded: bool = False ) -> widgets.Accordion: - return widgets.Accordion( + accordion = widgets.Accordion( [wrapped], - layout=Layout(width='99%', height='auto'), + layout=full_width_layout(width='auto', height='auto'), titles=(title,), ) + if expanded: + accordion.selected_index = 0 + return accordion class OutputSelectionWidget(widgets.VBox): - def __init__(self, workflow: sl.Pipeline, **kwargs): + def __init__( + self, + workflow: sl.Pipeline, + typical_outputs: tuple[Key, ...] | None = None, + **kwargs, + ): self.typical_outputs_widget = widgets.SelectMultiple( - options=get_typical_outputs(workflow), - layout=Layout(width='90%', height='250px'), + options=get_typical_outputs(workflow, typical_outputs), + layout=full_width_layout(width='auto', height='250px'), ) self.possible_outputs_widget = widgets.SelectMultiple( options=get_possible_outputs(workflow), - layout=Layout(width='90%', height='auto'), + layout=full_width_layout(width='auto', height='auto'), ) - # Wrapping the selection widget in an individual accordion. - # It is not possible to have multiple sections open in one ``Accordion`` widget. _typical_selection = _wrap_foldable( - self.typical_outputs_widget, title='Typical Outputs' + self.typical_outputs_widget, title='Typical Outputs', expanded=True ) - _typical_selection.selected_index = 0 # Open typical outputs by default _possible_selection = _wrap_foldable( self.possible_outputs_widget, title='Extended Outputs' ) @@ -62,46 +71,97 @@ def value(self) -> set[Key]: class ParameterBox(widgets.VBox): def __init__( self, - registry_getter: Callable[[], dict[Key, Parameter]], + registry_getter: Callable[[], dict[Key, ParameterSpec]], **kwargs, ): - self.parameter_refresh_button = widgets.Button( - description='Refresh Parameters', - disabled=False, - button_style='success', - tooltip='Generate Parameter Input Widgets', - ) - self._input_registry = {} - self._input_widgets = {} - self._input_box = widgets.VBox() - - def _refresh_input_box(_: widgets.Button): - new_input_parameters = registry_getter() - self._input_registry.clear() - self._input_registry.update(new_input_parameters) - self._input_widgets.clear() - self._input_widgets.update( - { - node: create_parameter_widget(parameter) - for node, parameter in new_input_parameters.items() - } - ) + self._registry_getter = registry_getter + self._input_registry: dict[Key, ParameterSpec] = {} + self._input_widgets: dict[Key, PydanticParameterValueWidget] = {} + self._expanded_categories: dict[str, bool] = {} + self._input_box = widgets.VBox(layout=full_width_layout()) + kwargs.setdefault('layout', full_width_layout()) + super().__init__([self._input_box], **kwargs) + + def refresh(self) -> None: + self._remember_expanded_categories() + existing_fields = self.get_fields() + new_input_parameters = dict(self._registry_getter()) + new_input_widgets: dict[Key, PydanticParameterValueWidget] = {} + + grouped: dict[str, list[widgets.Widget]] = {} + for key, spec in new_input_parameters.items(): + widget = _create_parameter_widget(spec) + if key in existing_fields: + widget.set_fields(existing_fields[key]) + new_input_widgets[key] = widget + grouped.setdefault(spec.category, []).append(widget) + + self._input_registry = new_input_parameters + self._input_widgets = new_input_widgets + + if not grouped: self._input_box.children = [ - widgets.HBox([widget]) for widget in self._input_widgets.values() + widgets.HTML("No parameters", layout=full_width_layout()) ] + return - self.parameter_refresh_button.on_click(_refresh_input_box) + self._input_box.children = [ + _wrap_foldable( + widgets.VBox(grouped[category], layout=full_width_layout()), + title=category, + expanded=self._expanded_categories.get(category, True), + ) + for category in grouped + ] - super().__init__([self.parameter_refresh_button, self._input_box], **kwargs) + def _remember_expanded_categories(self) -> None: + for child in self._input_box.children: + if not isinstance(child, widgets.Accordion): + continue + title = child.get_title(0) + if title is not None: + self._expanded_categories[title] = child.selected_index == 0 @property def value(self) -> dict[Key, Any]: - """Return the current parameter values with matching types as a dictionary.""" - return { - node: widget.value - for node, widget_box in self._input_widgets.items() - if (not isinstance((widget := widget_box), SwitchWidget)) or widget.enabled - } + return {key: widget.value for key, widget in self._input_widgets.items()} + + @property + def parameters(self) -> dict[Key, ParameterSpec]: + return dict(self._input_registry) + + def validate(self) -> tuple[bool, list[str]]: + errors = [] + for key, widget in self._input_widgets.items(): + is_valid, error_msg = widget.validate() + if not is_valid: + spec = self._input_registry[key] + errors.append(f"{spec.name}: {error_msg}") + widget.set_error_state(True, error_msg) + else: + widget.set_error_state(False, "") + return len(errors) == 0, errors + + def set_values(self, values: dict[Key, Any]) -> None: + for key, value in values.items(): + if key in self._input_widgets: + self._input_widgets[key].set_value(value) + + def get_values(self) -> dict[Key, Any]: + return {key: widget.value for key, widget in self._input_widgets.items()} + + def get_fields(self) -> dict[Key, dict[str, Any]]: + return {key: widget.get_fields() for key, widget in self._input_widgets.items()} + + +def _create_parameter_widget(spec: ParameterSpec) -> PydanticParameterValueWidget: + default = spec.default if spec.default is not keep_default else PydanticUndefined + return PydanticParameterValueWidget( + spec.model, + title=spec.name, + description=spec.description, + default=default, + ) class ResultBox(widgets.VBox): @@ -128,7 +188,12 @@ def run_workflow(_: widgets.Button) -> None: self.output.clear_output() with self.output: display.display(Spinner()) - compute_result = workflow_runner() + try: + compute_result = workflow_runner() + except Exception as e: + display.clear_output() + display.display(widgets.HTML(f"
{type(e).__name__}: {e}
")) + return display.clear_output() if result_registry is not None: result_registry.clear() @@ -145,66 +210,57 @@ def clear_output(_: widgets.Button) -> None: super().__init__([button_box, self.output], **kwargs) -def connect_refresh_button( - refresh_button: widgets.Button, output_widget: widgets.Output -) -> None: - def refresh_output(_: widgets.Button): - output_widget.clear_output() - - refresh_button.on_click(refresh_output) - - -def connect_output_selection_and_parameter_run_button( - *output_selection_widgets: widgets.Widget, - parameter_refresh_button: widgets.Button, - run_button: widgets.Button, -) -> None: - # Disable run button when output selection changes - def observe_selection_change(_) -> None: - run_button.disabled = True - run_button.tooltip = 'To run the workflow, refresh parameters.' - - for output_selection_widget in output_selection_widgets: - output_selection_widget.observe(observe_selection_change) - - # Enable run button when parameters are generated - original_run_button_tooltip = run_button.tooltip - - def observe_parameter_refrheshed(_) -> None: - run_button.disabled = False - run_button.tooltip = original_run_button_tooltip - - parameter_refresh_button.on_click(observe_parameter_refrheshed) - - class WorkflowWidget(widgets.TwoByTwoLayout): def __init__( - self, workflow: sl.Pipeline, result_registry: dict | None = None, **kwargs + self, + workflow: WorkflowSpec, + result_registry: dict | None = None, + **kwargs, ): - self.output_selection_box = OutputSelectionWidget(workflow) + self.workflow = workflow + self._pipeline = self.workflow.create_workflow() + self.output_selection_box = OutputSelectionWidget( + self._pipeline, self.workflow.typical_outputs + ) - def registry_getter() -> dict[Key, Parameter]: - """Return the parameter registry for the workflow.""" - return get_parameters(workflow, tuple(self.output_selection_box.value)) + def registry_getter() -> dict[Key, ParameterSpec]: + return get_parameters( + self._pipeline, + tuple(self.output_selection_box.value), + self.workflow.parameters, + ) self.parameter_box = ParameterBox(registry_getter) def workflow_runner() -> dict[type, Any]: - """Run the workflow with the current parameter values.""" - return assign_parameter_values( - workflow.copy(), self.parameter_box.value - ).compute(self.output_selection_box.value) + is_valid, errors = self.parameter_box.validate() + if not is_valid: + raise ValueError('\n'.join(errors)) + pipeline = assign_parameter_values( + self._pipeline, + self.parameter_box.value, + self.parameter_box.parameters, + ) + return pipeline.compute(self.output_selection_box.value) self.result_box = ResultBox(workflow_runner, result_registry) - connect_refresh_button( - self.parameter_box.parameter_refresh_button, self.result_box.output - ) - connect_output_selection_and_parameter_run_button( + + def refresh_parameters(_) -> None: + self.parameter_box.refresh() + self.result_box.output.clear_output() + has_outputs = bool(self.output_selection_box.value) + self.result_box.run_button.disabled = not has_outputs + self.result_box.run_button.tooltip = ( + 'Run' if has_outputs else 'Select output quantities.' + ) + + for output_selection_widget in ( self.output_selection_box.typical_outputs_widget, self.output_selection_box.possible_outputs_widget, - parameter_refresh_button=self.parameter_box.parameter_refresh_button, - run_button=self.result_box.run_button, - ) + ): + output_selection_widget.observe(refresh_parameters, names='value') + refresh_parameters(None) + for box in (self.output_selection_box, self.parameter_box, self.result_box): box.layout.border = '1px solid black' @@ -213,7 +269,7 @@ def workflow_runner() -> dict[type, Any]: top_right=self.parameter_box, bottom_left=self.result_box, grid_gap="10px", - layout=default_layout, + layout=full_width_layout(), **kwargs, ) @@ -221,20 +277,23 @@ def workflow_runner() -> dict[type, Any]: def workflow_widget(result_registry: dict | None = None) -> widgets.Widget: """Create a widget for a workflow selected from a dropdown.""" workflow_select = widgets.Dropdown( - options=[(workflow.__name__, workflow) for workflow in workflow_registry], + options=[ + (workflow.title or workflow.name or workflow.factory.__name__, workflow) + for workflow in workflow_registry + ], description='Workflow:', value=None, - layout=default_layout, + layout=full_width_layout(), tooltip='Select a workflow.', ) def refresh_workflow_box(change) -> None: - workflow_box.children = [WorkflowWidget(change.new(), result_registry)] + workflow_box.children = [WorkflowWidget(change.new, result_registry)] workflow_select.observe(refresh_workflow_box, names='value') - workflow_selection_box = widgets.HBox([workflow_select], layout=default_layout) - workflow_box = widgets.Box(layout=default_layout) + workflow_selection_box = widgets.HBox([workflow_select], layout=full_width_layout()) + workflow_box = widgets.Box(layout=full_width_layout()) return widgets.VBox([workflow_selection_box, workflow_box]) @@ -251,67 +310,14 @@ def _get_parameter_box(widget: WorkflowWidget | ParameterBox) -> ParameterBox: def set_parameter_widget_values( - widget: WorkflowWidget | ParameterBox, new_parameter_values: dict[type, Any] + widget: WorkflowWidget | ParameterBox, new_parameter_values: dict[Key, Any] ) -> None: - """Set the values of the input widgets in the target widget. - - Nodes that don't exist in the input widgets will be ignored. - - Example - ------- - .. code-block:: - - set_parameter_widget_values(widget, { - 'WavelengthBins': {'start': 1.0, 'stop': 14.0, 'nbins': 500} - }) - - Parameters - ---------- - widget: - The widget containing the input widgets. - new_parameter_values: - A dictionary of values/state to set each fields/state or value of input widgets. - - Raises - ------ - TypeError: - If the widget is not a WorkflowWidget or a ParameterBox. - - """ - parameter_box = _get_parameter_box(widget) - # Walk through the existing input widgets and set the values - # ``node`s that don't exist in the input widgets will be ignored. - for node, widget in parameter_box._input_widgets.items(): - if node in new_parameter_values: - # We shouldn't use `get` here because ``None`` is a valid value. - set_fields(widget, new_parameter_values[node]) + """Set the values of the input widgets in the target widget.""" + _get_parameter_box(widget).set_values(new_parameter_values) def get_parameter_widget_values( widget: WorkflowWidget | ParameterBox, -) -> dict[type, Any]: - """Return the current values of the input widgets in the target widget. - - The result of this function can be used to set the values of the input widgets - using the :py:func:`~set_parameter_widget_values` function. - - Parameters - ---------- - widget: - The widget containing the input widgets. - - Returns - ------- - : - A dictionary of the current values/state of each input widget. - - Raises - ------ - TypeError: - If the widget is not a WorkflowWidget or a ParameterBox. - - """ - return { - node: get_fields(widget) - for node, widget in _get_parameter_box(widget)._input_widgets.items() - } +) -> dict[Key, dict[str, Any]]: + """Return the current values of the input widgets in the target widget.""" + return _get_parameter_box(widget).get_fields() diff --git a/packages/essreduce/src/ess/reduce/widgets/__init__.py b/packages/essreduce/src/ess/reduce/widgets/__init__.py index 0ec2c3f6a..beccc4b58 100644 --- a/packages/essreduce/src/ess/reduce/widgets/__init__.py +++ b/packages/essreduce/src/ess/reduce/widgets/__init__.py @@ -1,191 +1,30 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -from collections.abc import Callable -from functools import singledispatch, wraps from typing import Any, Protocol -import ipywidgets as widgets - -from ..parameter import ( - BinEdgesParameter, - BooleanParameter, - FilenameParameter, - MultiFilenameParameter, - MultiStringParameter, - Parameter, - ParamWithBounds, - ParamWithOptions, - StringParameter, - Vector2dParameter, - Vector3dParameter, +from ._config import default_layout, default_style, full_width_layout +from ._pydantic import ( + PydanticModelWidget, + PydanticParameterValueWidget, + PydanticParameterWidget, ) -from ._binedges_widget import BinEdgesWidget -from ._bounds_widget import BoundsWidget -from ._config import default_layout, default_style -from ._filename_widget import FilenameWidget, MultiFilenameWidget -from ._linspace_widget import LinspaceWidget -from ._optional_widget import OptionalWidget from ._spinner import Spinner -from ._string_widget import MultiStringWidget, StringWidget -from ._switchable_widget import SwitchWidget -from ._vector_widget import VectorWidget class EssWidget(Protocol): - """Protocol for ESS widgets. - - All widgets should have a `value` property that returns the value of the widget. - It can be composed from multiple widgets. - """ + """Protocol for ESS widgets.""" @property def value(self) -> Any: ... -def switchable_widget( - func: Callable[[Parameter], widgets.Widget], -) -> Callable[[Parameter], widgets.Widget]: - """Wrap a widget in a switchable widget.""" - - @wraps(func) - def wrapper(param: Parameter) -> widgets.Widget: - widget = func(param) - if param.switchable: - return SwitchWidget(widget, name=param.name) - return widget - - return wrapper - - -def optional_widget( - func: Callable[[Parameter], widgets.Widget], -) -> Callable[[Parameter], widgets.Widget]: - """Wrap a widget in a optional widget.""" - - @wraps(func) - def wrapper(param: Parameter) -> widgets.Widget: - widget = func(param) - if param.optional: - return OptionalWidget(widget, name=param.name) - return widget - - return wrapper - - -@switchable_widget -@optional_widget # optional_widget should be applied first -@singledispatch -def create_parameter_widget(param: Parameter) -> widgets.Widget: - """Create a widget for a parameter depending on the ``param`` type. - - If the type of the parameter is not supported, a text widget is returned. - """ - return widgets.Text( - '', description=param.name, layout=default_layout, style=default_style - ) - - -@create_parameter_widget.register(BooleanParameter) -def boolean_parameter_widget(param: BooleanParameter): - name = param.name.split('.')[-1] - description = param.description - return widgets.Checkbox( - value=param.default, - description=name, - tooltip=description, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(StringParameter) -def string_parameter_widget(param: StringParameter): - return StringWidget( - description=param.name, - value=param.default, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(MultiStringParameter) -def multi_string_parameter_widget(param: MultiStringParameter): - return MultiStringWidget( - description=param.name, - value=param.default, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(FilenameParameter) -def filename_parameter_widget(param: FilenameParameter): - return FilenameWidget( - description=param.name, - value=param.default, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(MultiFilenameParameter) -def multi_filename_parameter_widget(param: MultiFilenameParameter): - return MultiFilenameWidget( - description=param.name, - value=param.default, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(ParamWithOptions) -def param_with_option_widget(param: ParamWithOptions): - return widgets.Dropdown( - description=param.name, - options=param.options, - layout=default_layout, - style=default_style, - ) - - -@create_parameter_widget.register(ParamWithBounds) -def param_with_bounds_widget(param: ParamWithBounds): - return BoundsWidget() - - -@create_parameter_widget.register(BinEdgesParameter) -def bin_edges_parameter_widget(param: BinEdgesParameter): - return BinEdgesWidget( - name=param.name, - dim=param.dim, - start=param.start, - stop=param.stop, - nbins=param.nbins, - unit=param.unit, - log=param.log, - ) - - -@create_parameter_widget.register(Vector2dParameter) -def vector_2d_parameter_widget(param: Vector2dParameter): - return VectorWidget(name=param.name, variable=param.default, components="xy") - - -@create_parameter_widget.register(Vector3dParameter) -def vector_3d_parameter_widget(param: Vector3dParameter): - return VectorWidget(name=param.name, variable=param.default, components="xyz") - - __all__ = [ - 'BinEdgesWidget', - 'BoundsWidget', 'EssWidget', - 'FilenameWidget', - 'LinspaceWidget', - 'MultiFilenameWidget', - 'OptionalWidget', + 'PydanticModelWidget', + 'PydanticParameterValueWidget', + 'PydanticParameterWidget', 'Spinner', - 'SwitchWidget', - 'VectorWidget', - 'create_parameter_widget', + 'default_layout', + 'default_style', + 'full_width_layout', ] diff --git a/packages/essreduce/src/ess/reduce/widgets/_binedges_widget.py b/packages/essreduce/src/ess/reduce/widgets/_binedges_widget.py deleted file mode 100644 index 984b941de..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_binedges_widget.py +++ /dev/null @@ -1,74 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -import ipywidgets as ipw -import scipp as sc - -from ._base import WidgetWithFieldsMixin - -UNITS_LIBRARY = { - "wavelength": {"options": ("angstrom", "nm")}, - "Q": {"options": ("1/angstrom", "1/nm")}, - "Qx": {"options": ("1/angstrom", "1/nm")}, - "Qy": {"options": ("1/angstrom", "1/nm")}, - "tof": {"options": ("s", "ms", "us", "ns"), "selected": "us"}, - "dspacing": {"options": ("angstrom", "nm")}, - "energy_transfer": {"options": ("meV",)}, - "theta": {"options": ("rad", "deg")}, - "two_theta": {"options": ("rad", "deg")}, - "phi": {"options": ("rad", "deg")}, - "time": {"options": ("s", "ms", "us", "ns")}, - "temperature": {"options": ("K", "C", "F")}, -} - - -class BinEdgesWidget(ipw.HBox, ipw.ValueWidget, WidgetWithFieldsMixin): - def __init__( - self, - name: str, - dim: str, - start: float | None = None, - stop: float | None = None, - nbins: int = 1, - unit: str | None = "undefined", - log: bool = False, - ): - super().__init__() - style = { - "layout": {"width": "100px"}, - "style": {"description_width": "initial"}, - } - units = UNITS_LIBRARY[dim] if unit == "undefined" else unit - if isinstance(units, str): - units = {"options": (units,)} - if not isinstance(units, dict): - units = {"options": units} - self.fields = { - "dim": ipw.Label(str(dim)), - "unit": ipw.Dropdown( - options=units["options"], - value=units.get("selected", units["options"][0]), - layout={"width": "initial"}, - ), - "start": ipw.FloatText(description='start:', value=start, **style), - "stop": ipw.FloatText(description='stop:', value=stop, **style), - "nbins": ipw.BoundedIntText( - description='nbins:', value=nbins, min=1, max=int(1e9), **style - ), # Note that a max value is required as it defaults to 100 without it. - "spacing": ipw.Dropdown( - options=['linear', 'log'], - value='log' if log else 'linear', - layout={"width": "initial"}, - ), - } - self.children = [ipw.Label(f"{name}:"), *list(self.fields.values())[1:]] - - @property - def value(self) -> sc.Variable: - func = sc.geomspace if self.fields["spacing"].value == "log" else sc.linspace - return func( - dim=self.fields["dim"].value, - start=self.fields["start"].value, - stop=self.fields["stop"].value, - num=self.fields["nbins"].value + 1, - unit=self.fields["unit"].value, - ) diff --git a/packages/essreduce/src/ess/reduce/widgets/_bounds_widget.py b/packages/essreduce/src/ess/reduce/widgets/_bounds_widget.py deleted file mode 100644 index 2324427cb..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_bounds_widget.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -import scipp as sc -from ipywidgets import FloatText, GridBox, Label, Text, ValueWidget - -from ..parameter import ParamWithBounds -from ._base import WidgetWithFieldsMixin - - -class BoundsWidget(GridBox, ValueWidget, WidgetWithFieldsMixin): - def __init__(self): - super().__init__() - - self.fields = { - 'start': FloatText(description='start'), - 'end': FloatText(description='end'), - 'unit': Text(description='unit'), - } - self.children = [ - Label(value="Select bound:"), - self.fields['unit'], - self.fields['start'], - self.fields['end'], - ] - - @property - def value(self): - return ( - sc.scalar(self.fields['start'].value, unit=self.fields['unit']), - sc.scalar(self.fields['end'].value, unit=self.fields['unit']), - ) - - @staticmethod - def from_ess_parameter(_: ParamWithBounds) -> 'BoundsWidget': - return BoundsWidget() diff --git a/packages/essreduce/src/ess/reduce/widgets/_config.py b/packages/essreduce/src/ess/reduce/widgets/_config.py index 0e28be290..9c513d171 100644 --- a/packages/essreduce/src/ess/reduce/widgets/_config.py +++ b/packages/essreduce/src/ess/reduce/widgets/_config.py @@ -2,5 +2,11 @@ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) from ipywidgets import Layout -default_layout = Layout(width='100%') + +def full_width_layout(**kwargs) -> Layout: + kwargs.setdefault('width', '100%') + return Layout(**kwargs) + + +default_layout = full_width_layout() default_style = {'description_width': 'auto'} diff --git a/packages/essreduce/src/ess/reduce/widgets/_filename_widget.py b/packages/essreduce/src/ess/reduce/widgets/_filename_widget.py deleted file mode 100644 index ebda89d00..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_filename_widget.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) - -from ._string_widget import MultiStringWidget, StringWidget - - -class FilenameWidget(StringWidget): ... - - -class MultiFilenameWidget(MultiStringWidget): ... diff --git a/packages/essreduce/src/ess/reduce/widgets/_linspace_widget.py b/packages/essreduce/src/ess/reduce/widgets/_linspace_widget.py deleted file mode 100644 index d6142bbb6..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_linspace_widget.py +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -import scipp as sc -from ipywidgets import FloatText, GridBox, IntText, Label, ValueWidget - -from ._base import WidgetWithFieldsMixin - - -class LinspaceWidget(GridBox, ValueWidget, WidgetWithFieldsMixin): - def __init__(self, dim: str, unit: str): - super().__init__() - - self.fields = { - 'dim': Label(value=dim, description='dim'), - 'start': FloatText(description='start', value=0.0), - 'end': FloatText(description='end', value=1.0), - 'num': IntText(description='num', value=100), - 'unit': Label(description='unit', value=unit), - } - self.children = [ - Label(value="Select range:"), - self.fields['dim'], - self.fields['unit'], - self.fields['start'], - self.fields['end'], - self.fields['num'], - ] - - @property - def value(self) -> sc.Variable: - return sc.linspace( - self.fields['dim'].value, - self.fields['start'].value, - self.fields['end'].value, - self.fields['num'].value, - unit=self.fields['unit'].value, - ) diff --git a/packages/essreduce/src/ess/reduce/widgets/_optional_widget.py b/packages/essreduce/src/ess/reduce/widgets/_optional_widget.py deleted file mode 100644 index 73c72abe5..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_optional_widget.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -from typing import Any - -from ipywidgets import HTML, HBox, Layout, RadioButtons, Widget - -from ._base import get_fields, set_fields -from ._config import default_style - - -class OptionalWidget(HBox): - """Wrapper widget to handle optional widgets. - - When you retrieve the value of this widget, - it will return the value of the wrapped widget. - The parameter should be set as ``None`` or the actual value. - """ - - def __init__(self, wrapped: Widget, name: str = '', **kwargs) -> None: - self.name = name - self.wrapped = wrapped - self._option_box = RadioButtons( - description="", - style=default_style, - layout=Layout(width="auto", min_width="80px"), - options={str(None): None, "": self.name}, - ) - self._option_box.value = None - if hasattr(wrapped, "disabled"): - # Disable the wrapped widget by default if possible - # since the option box is set to None by default - wrapped.disabled = True - - # Make the wrapped radio box horizontal - self.add_class("widget-optional") - _style_html = HTML( - "", - layout=Layout(display="none"), - ) - - def disable_wrapped(change) -> None: - if change["new"] is None: - if hasattr(wrapped, "disabled"): - wrapped.disabled = True - else: - if hasattr(wrapped, "disabled"): - wrapped.disabled = False - - self._option_box.observe(disable_wrapped, names="value") - - super().__init__([self._option_box, wrapped, _style_html], **kwargs) - - @property - def value(self) -> Any: - if self._option_box.value is None: - self._option_box.value = None - return None - return self.wrapped.value - - @value.setter - def value(self, value: Any) -> None: - if value is None: - self._option_box.value = None - else: - self._option_box.value = self.name - self.wrapped.value = value - - def set_fields(self, new_values: dict[str, Any]) -> None: - new_values = dict(new_values) - # Set the value of the option box - opted_out_flag = new_values.pop( - # We assume ``essreduce-opted-out`` is not used in any wrapped widget - 'essreduce-opted-out', - self._option_box.value is None, - ) - if not isinstance(opted_out_flag, bool): - raise ValueError( - f"Invalid value for 'essreduce-opted-out' field: {opted_out_flag}." - " The value should be a boolean." - ) - self._option_box.value = None if opted_out_flag else self.name - # Set the value of the wrapped widget - set_fields(self.wrapped, new_values) - - def get_fields(self) -> dict[str, Any] | None: - return { - **(get_fields(self.wrapped) or {}), - 'essreduce-opted-out': self._option_box.value is None, - } diff --git a/packages/essreduce/src/ess/reduce/widgets/_pydantic.py b/packages/essreduce/src/ess/reduce/widgets/_pydantic.py new file mode 100644 index 000000000..99c93275d --- /dev/null +++ b/packages/essreduce/src/ess/reduce/widgets/_pydantic.py @@ -0,0 +1,577 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +from __future__ import annotations + +from collections.abc import Iterable +from enum import Enum +from html import escape +from pathlib import Path +from types import NoneType, UnionType +from typing import Any, Literal, get_args, get_origin + +import ipywidgets as ipw +import pydantic +from pydantic import Field +from pydantic_core import PydanticUndefined + +from ._base import WidgetWithFieldsMixin +from ._config import default_style, full_width_layout + + +def snake_to_camel(snake_str: str) -> str: + """Convert snake_case to CamelCase.""" + components = snake_str.split('_') + return ''.join(word.capitalize() for word in components) + + +def get_defaults(model: type[pydantic.BaseModel]) -> dict[str, Any]: + """Get default values for all fields in a Pydantic model.""" + defaults = {} + for field_name, field_info in model.model_fields.items(): + if field_info.default is not PydanticUndefined: + defaults[field_name] = field_info.default + elif callable(field_info.default_factory): + defaults[field_name] = field_info.default_factory() + return defaults + + +def _enum_options(enum_type: type[Enum]) -> dict[str, Enum]: + options: dict[str, Enum] = {} + for enum_val in enum_type: + options[ + str(enum_val.value) if isinstance(enum_val.value, str) else enum_val.name + ] = enum_val + return options + + +def _strip_optional(field_type: Any) -> tuple[Any, bool]: + origin = get_origin(field_type) + if origin not in (UnionType, None) and not hasattr(field_type, '__args__'): + return field_type, False + args = get_args(field_type) or getattr(field_type, '__args__', ()) + if NoneType not in args: + return field_type, False + non_none = [arg for arg in args if arg is not NoneType] + return (non_none[0] if len(non_none) == 1 else field_type), True + + +def _is_string_sequence(field_type: Any) -> bool: + origin = get_origin(field_type) + args = get_args(field_type) + if origin in (list, set, frozenset) and len(args) == 1: + return args[0] is str + if origin is tuple and args: + return args in ((str,), (str, ...)) + return False + + +def _is_set_of_enum(field_type: Any) -> bool: + """True for ``set[E]`` / ``frozenset[E]`` where ``E`` is an ``Enum``.""" + if get_origin(field_type) not in (set, frozenset): + return False + args = get_args(field_type) + if len(args) != 1: + return False + inner = args[0] + return isinstance(inner, type) and issubclass(inner, Enum) + + +def _is_pydantic_model(field_type: Any) -> bool: + return isinstance(field_type, type) and issubclass(field_type, pydantic.BaseModel) + + +def _sequence_to_text(value: Iterable[str] | str | None) -> str: + if value is None: + return '' + if isinstance(value, str): + return value + return ', '.join(value) + + +def _text_to_sequence(value: str) -> tuple[str, ...]: + if not value.strip(): + return () + return tuple(item.strip() for item in value.split(',') if item.strip()) + + +def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, pydantic.BaseModel): + return value.model_dump() + return dict(value or {}) + + +def _model_description(model: type[pydantic.BaseModel]) -> str | None: + doc = model.__doc__ + if doc is None: + return None + first_paragraph = doc.strip().split('\n\n', maxsplit=1)[0].strip() + return first_paragraph or None + + +def _help_icon(description: str) -> ipw.HTML: + escaped = escape(description, quote=True) + return ipw.HTML( + ( + f'' + 'ⓘ' + ), + layout=ipw.Layout(width='1.4em'), + ) + + +def _title_html(title: str, description: str | None = None) -> ipw.HTML: + escaped_title = escape(title) + if description is None: + return ipw.HTML(f"{escaped_title}") + escaped_description = escape(description, quote=True) + return ipw.HTML( + f'{escaped_title}' + f'' + 'ⓘ' + ) + + +class FieldWithHelp(ipw.HBox, ipw.ValueWidget): + """Add a compact hover target for a field description.""" + + def __init__(self, widget: ipw.Widget, description: str): + self.widget = widget + if not isinstance(widget, ipw.Checkbox): + widget.layout.flex = '1 1 auto' + super().__init__( + [widget, _help_icon(description)], + layout=full_width_layout(width='auto'), + ) + + @property + def value(self) -> Any: + return self.widget.value + + @value.setter + def value(self, value: Any) -> None: + self.widget.value = value + + +def _with_help(widget: ipw.Widget, description: str | None) -> ipw.Widget: + if description is None: + return widget + return FieldWithHelp(widget, description) + + +class PydanticFieldWidget(ipw.VBox, ipw.ValueWidget): + """Labelled wrapper for nested Pydantic model fields.""" + + def __init__( + self, + title: str, + widget: PydanticParameterWidget, + description: str | None = None, + ): + self.widget = widget + super().__init__( + [_title_html(title, description), widget], + layout=full_width_layout(), + ) + + @property + def value(self) -> pydantic.BaseModel: + return self.widget.value + + def set_values(self, values: dict[str, Any] | pydantic.BaseModel) -> None: + self.widget.set_values(values) + + +class PydanticParameterValueWidget(ipw.VBox, ipw.ValueWidget): + """Widget for one workflow parameter value.""" + + def __init__( + self, + model: Any, + *, + title: str, + description: str | None = None, + default: Any = PydanticUndefined, + ): + self._returns_model = _is_pydantic_model(model) + if self._returns_model: + initial_values = default if default is not PydanticUndefined else None + self._widget = PydanticParameterWidget(model, initial_values=initial_values) + child = PydanticFieldWidget( + title, self._widget, description or _model_description(model) + ) + else: + if default is PydanticUndefined: + field = Field(title=title, description=description) + else: + field = Field(default=default, title=title, description=description) + model_class = pydantic.create_model('ParameterValue', value=(model, field)) + self._widget = PydanticParameterWidget(model_class) + child = self._widget + super().__init__([child], layout=full_width_layout()) + + @property + def value(self) -> Any: + value = self._widget.create_model() + return value if self._returns_model else value.value + + def set_value(self, value: Any) -> None: + if self._returns_model: + self._widget.set_values(value) + elif isinstance(value, dict): + self._widget.set_values(value) + else: + self._widget.set_values({'value': value}) + + def get_fields(self) -> dict[str, Any]: + return self._widget.get_values() + + def set_fields(self, values: dict[str, Any]) -> None: + self.set_value(values) + + def validate(self) -> tuple[bool, str]: + return self._widget.validate() + + def set_error_state(self, has_error: bool, error_message: str) -> None: + self._widget.set_error_state(has_error, error_message) + + +class PydanticParameterWidget(ipw.VBox, ipw.ValueWidget, WidgetWithFieldsMixin): + """Widget for creating and validating Pydantic model instances.""" + + def __init__( + self, + model_class: type[pydantic.BaseModel], + initial_values: dict[str, Any] | None = None, + **kwargs: Any, + ): + kwargs.setdefault('layout', full_width_layout()) + super().__init__(**kwargs) + self.model_class = model_class + self.widgets: dict[str, ipw.Widget] = {} + self.fields = self.widgets + self._error = ipw.HTML() + self._create_widgets() + if initial_values: + self.set_values(initial_values) + self.children = [*self.widgets.values(), self._error] + + def _create_widgets(self) -> None: + for field_name, field_info in self.model_class.model_fields.items(): + self.widgets[field_name] = self._create_widget_for_field( + field_name, field_info + ) + + def _create_widget_for_field( + self, field_name: str, field_info: pydantic.fields.FieldInfo + ) -> ipw.Widget: + field_type, optional = _strip_optional(field_info.annotation) + default_value = self._field_default(field_info) + display_name = field_info.title or snake_to_camel(field_name) + help_text = field_info.description + description = help_text or display_name + disabled = bool(field_info.frozen) + common = { + 'description': display_name, + 'disabled': disabled, + 'tooltip': description, + 'layout': full_width_layout(width='auto'), + 'style': default_style, + } + + if field_type is float: + return _with_help( + ipw.FloatText(value=default_value or 0.0, **common), help_text + ) + if field_type is int: + return _with_help( + ipw.IntText(value=default_value or 0, **common), help_text + ) + if field_type is bool: + return _with_help( + ipw.Checkbox(value=default_value or False, **common), help_text + ) + if field_type in (Path, str): + value = '' if default_value is None else str(default_value) + return _with_help( + ipw.Text(value=value, placeholder=description, **common), help_text + ) + if isinstance(field_type, type) and issubclass(field_type, Enum): + options = _enum_options(field_type) + value = ( + default_value + if default_value is not None + else next(iter(options.values())) + ) + return _with_help( + ipw.Dropdown(options=options, value=value, **common), help_text + ) + if _is_set_of_enum(field_type): + (enum_type,) = get_args(field_type) + options = _enum_options(enum_type) + value = tuple(default_value or ()) + return _with_help( + ipw.SelectMultiple( + options=options, + value=value, + rows=min(len(options), 10), + **common, + ), + help_text, + ) + if get_origin(field_type) is Literal: + literal_values = get_args(field_type) + options = {str(val): val for val in literal_values} + value = default_value if default_value is not None else literal_values[0] + return _with_help( + ipw.Dropdown(options=options, value=value, **common), help_text + ) + if _is_string_sequence(field_type): + value = _sequence_to_text(default_value) + return _with_help( + ipw.Text(value=value, placeholder=description, **common), help_text + ) + if _is_pydantic_model(field_type): + return PydanticFieldWidget( + display_name, + PydanticParameterWidget( + field_type, initial_values=_as_dict(default_value) + ), + help_text or _model_description(field_type), + ) + + value = '' if default_value is None and optional else str(default_value or '') + return _with_help( + ipw.Text(value=value, placeholder=description, **common), help_text + ) + + @staticmethod + def _field_default(field_info: pydantic.fields.FieldInfo) -> Any: + if field_info.default is not PydanticUndefined: + return field_info.default + if callable(field_info.default_factory): + return field_info.default_factory() + return None + + def get_values(self) -> dict[str, Any]: + values = {} + for field_name, widget in self.widgets.items(): + field_type, optional = _strip_optional( + self.model_class.model_fields[field_name].annotation + ) + if _is_pydantic_model(field_type): + values[field_name] = widget.widget.get_values() + continue + value = widget.value + if field_type is Path: + value = Path(value) if value else None + elif _is_string_sequence(field_type): + value = _text_to_sequence(value) + elif _is_set_of_enum(field_type): + pass + elif isinstance(field_type, type) and issubclass(field_type, Enum): + pass + elif optional and value == '': + value = None + values[field_name] = value + return values + + def set_values(self, values: dict[str, Any] | pydantic.BaseModel) -> None: + if isinstance(values, pydantic.BaseModel): + values = values.model_dump() + for field_name, value in values.items(): + if field_name not in self.widgets: + continue + field_type, _ = _strip_optional( + self.model_class.model_fields[field_name].annotation + ) + if value is None and field_type in (Path, str): + value = '' + elif isinstance(value, Path): + value = str(value) + elif _is_string_sequence(field_type): + value = _sequence_to_text(value) + elif _is_set_of_enum(field_type): + (enum_type,) = get_args(field_type) + value = tuple(enum_type(v) for v in value) + elif isinstance(field_type, type) and issubclass(field_type, Enum): + value = field_type(value) + elif _is_pydantic_model(field_type): + self.widgets[field_name].set_values(value) + continue + elif isinstance(value, pydantic.BaseModel): + value = value.model_dump() + self.widgets[field_name].value = value + + def create_model(self) -> pydantic.BaseModel: + return self.model_class(**self.get_values()) + + @property + def value(self) -> pydantic.BaseModel: + return self.create_model() + + def validate(self) -> tuple[bool, str]: + try: + self.create_model() + return True, "Valid" + except pydantic.ValidationError as e: + error_details = e.errors() + if error_details: + field_name = error_details[0].get('loc', [''])[0] + error_msg = error_details[0].get('msg', str(e)) + return False, f"{field_name}: {error_msg}" if field_name else error_msg + return False, str(e) + except ValueError as e: + return False, str(e) + + def set_error_state(self, has_error: bool, error_message: str) -> None: + for widget in self.widgets.values(): + widget.layout.border = '' + if not has_error: + self._error.value = '' + return + try: + self.create_model() + except pydantic.ValidationError as e: + error_details = e.errors() + if error_details: + field_name = error_details[0].get('loc', [''])[0] + if field_name in self.widgets: + self.widgets[field_name].layout.border = '2px solid #dc3545' + self._error.value = ( + "

" + f"{error_message}

" + ) + + +class PydanticModelWidget(ipw.VBox): + """Build ipywidgets for flat or grouped Pydantic parameter models.""" + + def __init__( + self, + model_class: type[pydantic.BaseModel], + initial_values: dict[str, Any] | pydantic.BaseModel | None = None, + hidden_fields: frozenset[str] = frozenset(), + **kwargs: Any, + ) -> None: + kwargs.setdefault('layout', full_width_layout()) + super().__init__(**kwargs) + self._model_class = model_class + self._hidden_fields = hidden_fields + self._parameter_widgets: dict[str, PydanticParameterWidget] = {} + self._failing_field_names: list[str] = [] + if isinstance(initial_values, pydantic.BaseModel): + initial_values = initial_values.model_dump() + self._initial_values = initial_values or {} + self._build() + + def _build(self) -> None: + if self._is_grouped_model(): + self._build_grouped() + else: + widget = PydanticParameterWidget( + self._model_class, initial_values=self._initial_values + ) + self._parameter_widgets[''] = widget + self.children = [widget] + + def _is_grouped_model(self) -> bool: + visible_fields = [ + field_info.annotation + for name, field_info in self._model_class.model_fields.items() + if not name.startswith('_') and name not in self._hidden_fields + ] + return bool(visible_fields) and all( + _is_pydantic_model(field_type) for field_type in visible_fields + ) + + def _build_grouped(self) -> None: + children = [] + titles = [] + root_defaults = get_defaults(self._model_class) + for field_name, field_info in self._model_class.model_fields.items(): + if field_name.startswith('_') or field_name in self._hidden_fields: + continue + field_type = field_info.annotation + values = get_defaults(field_type) + values.update(self._as_dict(root_defaults.get(field_name, {}))) + values.update(self._as_dict(self._initial_values.get(field_name, {}))) + widget = PydanticParameterWidget(field_type, initial_values=values) + self._parameter_widgets[field_name] = widget + title = field_info.title or field_name.replace('_', ' ').title() + widget = PydanticFieldWidget( + title, + widget, + field_info.description or _model_description(field_type), + ) + titles.append(title) + children.append(widget) + accordion = ipw.Accordion( + children=children, layout=full_width_layout(width='auto') + ) + for index, title in enumerate(titles): + accordion.set_title(index, title) + if children: + accordion.selected_index = 0 + self.children = [accordion] + + @staticmethod + def _as_dict(value: Any) -> dict[str, Any]: + return _as_dict(value) + + @property + def parameter_values(self) -> pydantic.BaseModel: + if '' in self._parameter_widgets: + return self._parameter_widgets[''].create_model() + widget_values = { + name: widget.create_model() + for name, widget in self._parameter_widgets.items() + } + return self._model_class(**widget_values) + + @property + def value(self) -> pydantic.BaseModel: + return self.parameter_values + + def validate_parameters(self) -> tuple[bool, list[str]]: + errors = [] + self._failing_field_names = [] + for field_name, widget in self._parameter_widgets.items(): + is_valid, error_msg = widget.validate() + if not is_valid: + errors.append(f"{field_name}: {error_msg}" if field_name else error_msg) + self._failing_field_names.append(field_name) + widget.set_error_state(True, error_msg) + else: + widget.set_error_state(False, "") + return len(errors) == 0, errors + + def get_failing_field_names(self) -> list[str]: + return list(self._failing_field_names) + + def clear_validation_errors(self) -> None: + self._failing_field_names = [] + for widget in self._parameter_widgets.values(): + widget.set_error_state(False, "") + + def set_values(self, values: dict[str, Any] | pydantic.BaseModel) -> None: + if isinstance(values, pydantic.BaseModel): + values = values.model_dump() + if '' in self._parameter_widgets: + self._parameter_widgets[''].set_values(values) + return + for field_name, field_values in values.items(): + if field_name in self._parameter_widgets: + self._parameter_widgets[field_name].set_values(field_values) + + def get_values(self) -> dict[str, Any]: + if '' in self._parameter_widgets: + return self._parameter_widgets[''].get_values() + return { + field_name: widget.get_values() + for field_name, widget in self._parameter_widgets.items() + } + + def get_parameter_widget(self, field_name: str) -> PydanticParameterWidget | None: + return self._parameter_widgets.get(field_name) diff --git a/packages/essreduce/src/ess/reduce/widgets/_string_widget.py b/packages/essreduce/src/ess/reduce/widgets/_string_widget.py deleted file mode 100644 index c91fb70ba..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_string_widget.py +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -from ipywidgets import HBox, Text, ValueWidget - -from ._config import default_layout - - -class StringWidget(HBox, ValueWidget): - def __init__(self, description: str, value: str | None = None, **kwargs): - super().__init__(layout=default_layout) - self.text_widget = Text(description=description, value=value, **kwargs) - self.children = [self.text_widget] - - @property - def value(self) -> str | None: - v = self.text_widget.value.strip() - if not v: - return None - return v - - @value.setter - def value(self, value: str | None): - if value is None: - self.text_widget.value = '' - else: - self.text_widget.value = value - - -class MultiStringWidget(StringWidget): - def __init__( - self, description: str, value: tuple[str, ...] | None = None, **kwargs - ): - # Special case handling to allow initialising with a single string - if not isinstance(value, str) and value is not None: - value = ', '.join(value) - - super().__init__(description, value, **kwargs) - - @property - def value(self) -> tuple[str, ...]: - v = super().value - if v is None: - return () - return tuple(s.strip() for s in v.split(',')) - - @value.setter - def value(self, value: tuple[str, ...]): - self.text_widget.value = ', '.join(value) diff --git a/packages/essreduce/src/ess/reduce/widgets/_switchable_widget.py b/packages/essreduce/src/ess/reduce/widgets/_switchable_widget.py deleted file mode 100644 index 85924b64b..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_switchable_widget.py +++ /dev/null @@ -1,66 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) -from typing import Any - -from ipywidgets import Checkbox, HBox, Label, Stack, Widget - -from ._base import get_fields, set_fields -from ._config import default_style - - -class SwitchWidget(HBox): - """Wrapper widget to handle switchable widgets. - - When you retrieve the value of this widget, - it will return the value of the wrapped widget. - It is expected not to be set in the workflow if ``enabled`` is False. - """ - - def __init__(self, wrapped: Widget, name: str = '') -> None: - super().__init__() - self._enable_box = Checkbox(description='', style=default_style) - # The layout is not applied if they are set in the constructor - self._enable_box.layout.description_width = '0px' - self._enable_box.layout.width = 'auto' - - self.wrapped = wrapped - wrapped_stack = Stack([Label(name, style=default_style), self.wrapped]) - # We wanted to implement this by greying out the widget when disabled - # but ``disabled`` is not a common property of all widgets - wrapped_stack.selected_index = 0 - - def handle_checkbox(change) -> None: - wrapped_stack.selected_index = 1 if change.new else 0 - - self._enable_box.observe(handle_checkbox, names='value') - self.children = [self._enable_box, wrapped_stack] - - @property - def enabled(self) -> bool: - return self._enable_box.value - - @enabled.setter - def enabled(self, value: bool) -> None: - self._enable_box.value = value - - @property - def value(self) -> Any: - return self.wrapped.value - - @value.setter - def value(self, value: Any) -> None: - self.wrapped.value = value - - def set_fields(self, new_values: dict[str, Any]) -> None: - # Retrieve and set the enabled flag first - new_values = dict(new_values) - enabled_flag = new_values.pop('enabled', self.enabled) - if not isinstance(enabled_flag, bool): - raise ValueError(f"`enabled` must be a boolean, got {enabled_flag}") - self.enabled = enabled_flag - # Set the rest of the fields - set_fields(self.wrapped, new_values) - - def get_fields(self) -> dict[str, Any]: - wrapped_fields = get_fields(self.wrapped) - return {'enabled': self.enabled, **(wrapped_fields or {})} diff --git a/packages/essreduce/src/ess/reduce/widgets/_vector_widget.py b/packages/essreduce/src/ess/reduce/widgets/_vector_widget.py deleted file mode 100644 index def9236df..000000000 --- a/packages/essreduce/src/ess/reduce/widgets/_vector_widget.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) - -import scipp as sc -from ipywidgets import FloatText, HBox, Label, Text, ValueWidget - -from ._base import WidgetWithFieldsMixin - - -class VectorWidget(HBox, ValueWidget, WidgetWithFieldsMixin): - def __init__(self, name: str, variable: sc.Variable, components: str): - super().__init__() - - style = { - "layout": {"width": "130px"}, - "style": {"description_width": "initial"}, - } - self.fields = { - c: FloatText( - description=f"{c} =", value=getattr(variable.fields, c).value, **style - ) - for c in components - } - self.fields["unit"] = Text( - description="unit:", value=str(variable.unit), **style - ) - self.children = [Label(value=f"{name}: "), *list(self.fields.values())] - - @property - def value(self): - return sc.vector( - value=[ - self.fields['x'].value, - self.fields['y'].value, - self.fields.get('z', sc.scalar(0.0)).value, - ], - unit=self.fields['unit'].value, - ) diff --git a/packages/essreduce/src/ess/reduce/workflow.py b/packages/essreduce/src/ess/reduce/workflow.py index 641246161..b35632e66 100644 --- a/packages/essreduce/src/ess/reduce/workflow.py +++ b/packages/essreduce/src/ess/reduce/workflow.py @@ -2,25 +2,83 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) from __future__ import annotations -from collections.abc import Callable, MutableSet, Sequence +from collections.abc import Callable, Mapping, MutableSet, Sequence +from dataclasses import dataclass, field from typing import Any, TypeVar -import networkx as nx from sciline import Pipeline from sciline._utils import key_name +from sciline.handler import HandleAsComputeTimeException from sciline.typing import Key -from .parameter import Parameter, keep_default, parameter_mappers, parameter_registry +from .parameter import ( + ParameterRegistry, + ParameterSpec, + keep_default, + parameter_registry, +) T = TypeVar("T") +WorkflowFactory = Callable[..., Pipeline] + + +@dataclass(frozen=True) +class WorkflowSpec: + """Workflow factory and metadata needed to generate user interfaces.""" + + factory: WorkflowFactory + parameters: Mapping[Key, ParameterSpec] = field(default_factory=ParameterRegistry) + typical_outputs: tuple[Key, ...] | None = None + name: str | None = None + title: str | None = None + description: str | None = None + version: str | None = None + + def __post_init__(self) -> None: + if self.name is None: + object.__setattr__(self, 'name', self.factory.__name__) + + def __call__(self, *args: Any, **kwargs: Any) -> Pipeline: + return self.factory(*args, **kwargs) + + @property + def __name__(self) -> str: + return self.factory.__name__ + + def create_workflow(self) -> Pipeline: + return self.factory() + + @classmethod + def from_factory( + cls, + factory: WorkflowFactory, + *, + parameters: Mapping[Key, ParameterSpec] | None = None, + typical_outputs: Sequence[Key] | None = None, + name: str | None = None, + title: str | None = None, + description: str | None = None, + version: str | None = None, + ) -> WorkflowSpec: + return cls( + factory=factory, + parameters=parameters if parameters is not None else ParameterRegistry(), + typical_outputs=None if typical_outputs is None else tuple(typical_outputs), + name=name, + title=title, + description=description, + version=version, + ) class WorkflowRegistry(MutableSet): def __init__(self): - self._workflows: dict[str, type] = {} + self._workflows: dict[str, WorkflowSpec] = {} def __contains__(self, item: object) -> bool: - return item in self._workflows.values() + return item in self._workflows.values() or any( + item is spec.factory for spec in self._workflows.values() + ) def __iter__(self): return iter(self._workflows.values()) @@ -28,20 +86,60 @@ def __iter__(self): def __len__(self) -> int: return len(self._workflows) - def add(self, value: type) -> None: + def add(self, value: WorkflowFactory | WorkflowSpec) -> None: + if isinstance(value, WorkflowSpec): + spec = value + else: + spec = WorkflowSpec.from_factory(value) + key = spec.factory.__qualname__ + self._workflows[key] = spec + + def discard(self, value: WorkflowFactory | WorkflowSpec) -> None: + self._workflows = { + k: v + for k, v in self._workflows.items() + if v != value and v.factory is not value + } + + def get(self, value: WorkflowFactory | WorkflowSpec) -> WorkflowSpec: + if isinstance(value, WorkflowSpec): + return value key = value.__qualname__ - self._workflows[key] = value - - def discard(self, value: type) -> None: - self._workflows = {k: v for k, v in self._workflows.items() if v != value} + try: + return self._workflows[key] + except KeyError as e: + raise KeyError(f"Workflow {value.__qualname__!r} is not registered.") from e workflow_registry = WorkflowRegistry() -def register_workflow(cls: Callable[[], Pipeline]) -> Callable[[], Pipeline]: - workflow_registry.add(cls) - return cls +def register_workflow( + *, + parameters: Mapping[Key, ParameterSpec] | None = None, + typical_outputs: Sequence[Key] | None = None, + name: str | None = None, + title: str | None = None, + description: str | None = None, + version: str | None = None, +) -> Callable[[WorkflowFactory], WorkflowFactory]: + """Register a workflow factory for generated user interfaces.""" + + def decorator(factory: WorkflowFactory) -> WorkflowFactory: + workflow_registry.add( + WorkflowSpec.from_factory( + factory, + parameters=parameters, + typical_outputs=typical_outputs, + name=name, + title=title, + description=description, + version=version, + ) + ) + return factory + + return decorator def _get_defaults_from_workflow(workflow: Pipeline) -> dict[Key, Any]: @@ -49,8 +147,10 @@ def _get_defaults_from_workflow(workflow: Pipeline) -> dict[Key, Any]: return {key: values["value"] for key, values in nodes.items() if "value" in values} -def get_typical_outputs(pipeline: Pipeline) -> tuple[Key, ...]: - if (typical_outputs := getattr(pipeline, "typical_outputs", None)) is None: +def get_typical_outputs( + pipeline: Pipeline, typical_outputs: Sequence[Key] | None = None +) -> tuple[Key, ...]: + if typical_outputs is None: graph = pipeline.underlying_graph sink_nodes = [node for node, degree in graph.out_degree if degree == 0] return sorted(_with_pretty_names(sink_nodes), key=lambda x: x[0]) @@ -69,27 +169,35 @@ def _with_pretty_names(outputs: Sequence[Key]) -> tuple[tuple[str, Key], ...]: def get_parameters( - pipeline: Pipeline, outputs: tuple[Key, ...] -) -> dict[Key, Parameter]: + pipeline: Pipeline, + outputs: tuple[Key, ...], + parameters: Mapping[Key, ParameterSpec] = parameter_registry, +) -> dict[Key, ParameterSpec]: """Return a dictionary of parameters for the workflow.""" - subgraph = set(outputs) - graph = pipeline.underlying_graph - for key in outputs: - subgraph.update(nx.ancestors(graph, key)) + required_keys = set( + pipeline.get(outputs, handler=HandleAsComputeTimeException()).keys() + ) defaults = _get_defaults_from_workflow(pipeline) return { - key: param.with_default(defaults.get(key, keep_default)) - for key, param in parameter_registry.items() - if key in subgraph + key: spec.with_default(defaults.get(key, keep_default)) + for key, spec in parameters.items() + if key in required_keys } -def assign_parameter_values(pipeline: Pipeline, values: dict[Key, Any]) -> Pipeline: +def assign_parameter_values( + pipeline: Pipeline, + values: dict[Key, Any], + parameters: Mapping[Key, ParameterSpec] = parameter_registry, +) -> Pipeline: """Set a value for a parameter in the pipeline.""" pipeline = pipeline.copy() for key, value in values.items(): - if (mapper := parameter_mappers.get(key)) is not None: - pipeline = mapper(pipeline, value) + spec = parameters[key] + if spec.transform is not None: + value = spec.transform(value) + if spec.apply is not None: + pipeline = spec.apply(pipeline, value) else: pipeline[key] = value return pipeline diff --git a/packages/essreduce/tests/parameter_models_test.py b/packages/essreduce/tests/parameter_models_test.py new file mode 100644 index 000000000..f88c420d0 --- /dev/null +++ b/packages/essreduce/tests/parameter_models_test.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +import pytest +import scipp as sc +from pydantic import ValidationError + +from ess.reduce.parameter_models import DspacingEdges, Scale + + +def test_dspacing_edges_create_scipp_bin_edges() -> None: + model = DspacingEdges(start=0.1, stop=1.1, num_bins=2) + + assert sc.identical( + model.get_edges(), + sc.linspace(dim='dspacing', start=0.1, stop=1.1, num=3, unit='Å'), + ) + + +def test_log_edges_require_positive_start() -> None: + with pytest.raises(ValidationError, match="start must be positive"): + DspacingEdges(start=0.0, stop=1.0, scale=Scale.LOG) diff --git a/packages/essreduce/tests/widget_test.py b/packages/essreduce/tests/widget_test.py index b20b57480..a158234b0 100644 --- a/packages/essreduce/tests/widget_test.py +++ b/packages/essreduce/tests/widget_test.py @@ -2,590 +2,441 @@ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) from collections.abc import Callable, Generator from contextlib import contextmanager -from typing import Any, NewType +from enum import Enum +from pathlib import Path +from typing import Literal, NewType import pytest import sciline as sl -import scipp as sc -from ipywidgets import FloatText, IntText - -from ess.reduce.parameter import ( - BinEdgesParameter, - FilenameParameter, - MultiFilenameParameter, - Parameter, - Vector3dParameter, - parameter_registry, +from ipywidgets import VBox +from pydantic import BaseModel, Field + +from ess.reduce.parameter import ParameterRegistry, ParameterSpec +from ess.reduce.ui import ( + WorkflowWidget, + get_parameter_widget_values, + set_parameter_widget_values, + workflow_widget, +) +from ess.reduce.widgets import ( + PydanticModelWidget, + PydanticParameterValueWidget, + PydanticParameterWidget, +) +from ess.reduce.workflow import ( + WorkflowSpec, + assign_parameter_values, + get_parameters, + register_workflow, + workflow_registry, ) -from ess.reduce.ui import ResultBox, WorkflowWidget, workflow_widget -from ess.reduce.widgets import OptionalWidget, SwitchWidget, create_parameter_widget -from ess.reduce.widgets._base import WidgetWithFieldsProtocol, get_fields, set_fields -from ess.reduce.workflow import register_workflow, workflow_registry - -SwitchableInt = NewType('SwitchableInt', int) -SwitchableFloat = NewType('SwitchableFloat', float) -OptionalInt = int | None -OptionalFloat = float | None - - -class IntParam(Parameter): ... - - -class FloatParam(Parameter): ... - - -parameter_registry[SwitchableInt] = IntParam('_', '_', 1, switchable=True) -parameter_registry[SwitchableFloat] = FloatParam('_', '_', 2.0, switchable=True) -parameter_registry[int] = IntParam('_', '_', 1) -parameter_registry[float] = FloatParam('_', '_', 2.0) -parameter_registry[OptionalInt] = IntParam('_', '_', 1, optional=True) -parameter_registry[OptionalFloat] = FloatParam('_', '_', 2.0, optional=True) - - -@create_parameter_widget.register(IntParam) -def _(param: IntParam) -> IntText: - return IntText(value=param.default, description=param.name) - -@create_parameter_widget.register(FloatParam) -def _(param: FloatParam) -> FloatText: - return FloatText(value=param.default, description=param.name) +class Mode(Enum): + fast = 'fast' + slow = 'slow' -def _refresh_widget_parameter( - *, widget: WorkflowWidget, output_selections: list[type] -) -> None: - widget.output_selection_box.typical_outputs_widget.value = output_selections - widget.parameter_box.parameter_refresh_button.click() +class FlatParams(BaseModel): + count: int = Field(default=1, title="Count") + scale: float = Field(default=2.0, title="Scale") + enabled: bool = Field(default=True, title="Enabled") + path: Path | None = Field(default=None, title="Path") + mode: Mode = Field(default=Mode.fast, title="Mode") + choice: Literal['a', 'b'] = Field(default='a', title="Choice") + names: tuple[str, ...] = Field(default=("sample",), title="Names") -def _ready_widget( - *, - providers: list[Callable] | None = None, - params: dict[type, Any] | None = None, - output_selections: list[type], - result_registry: dict[type, Any] | None = None, -) -> WorkflowWidget: - widget = WorkflowWidget( - sl.Pipeline(providers or [], params=params or {}), - result_registry=result_registry, - ) - _refresh_widget_parameter(widget=widget, output_selections=output_selections) - return widget +class GroupParams(BaseModel): + value: int = 2 -def strict_provider(a: int, b: float) -> str: - return f"{a} + {b}" +class NestedParams(BaseModel): + group: GroupParams = Field(default_factory=GroupParams, title="Group") -def provider_with_switch(a: SwitchableInt, b: SwitchableFloat) -> str: - return f"{a} + {b}" +class LimitedParams(BaseModel): + value: int = Field(default=1, ge=0) -def provider_with_optional(a: OptionalInt, b: OptionalFloat) -> str: - parts = [] if a is None else [str(a)] - return ' + '.join([*parts] if b is None else [*parts, str(b)]) +Count = NewType("Count", int) +Label = NewType("Label", str) +Unused = NewType("Unused", int) +Limited = NewType("Limited", int) -def _get_param_widget(widget: WorkflowWidget, param_type: type) -> Any: - return widget.parameter_box._input_widgets[param_type] +def test_pydantic_parameter_widget_creates_model_from_widget_values() -> None: + widget = PydanticParameterWidget(FlatParams) -def test_parameter_default_value_test() -> None: - widget = _ready_widget(providers=[strict_provider], output_selections=[str]) - assert _get_param_widget(widget, int).value == 1 - assert _get_param_widget(widget, float).value == 2.0 + widget.widgets['count'].value = 3 + widget.widgets['scale'].value = 0.5 + widget.widgets['enabled'].value = False + widget.widgets['path'].value = 'data.nxs' + widget.widgets['mode'].value = Mode.slow + widget.widgets['choice'].value = 'b' + widget.widgets['names'].value = 'a, b' + params = widget.create_model() -def test_parameter_registry() -> None: - assert isinstance(create_parameter_widget(IntParam('_a', '_a', 1)), IntText) - assert isinstance(create_parameter_widget(FloatParam('_b', '_b', 2.0)), FloatText) - + assert params == FlatParams( + count=3, + scale=0.5, + enabled=False, + path=Path('data.nxs'), + mode=Mode.slow, + choice='b', + names=('a', 'b'), + ) -def test_run_not_allowed_when_parameter_not_refreshed_after_output_selected() -> None: - widget = _ready_widget(providers=[strict_provider], output_selections=[str]) - # Clear the value of the output selection box - widget.output_selection_box.typical_outputs_widget.value = [] - assert widget.result_box.run_button.disabled - # Click the refresh button - widget.parameter_box.parameter_refresh_button.click() - assert not widget.result_box.run_button.disabled - # Add a value to the parameter - widget.output_selection_box.typical_outputs_widget.value = [str] - assert widget.result_box.run_button.disabled - # Click the refresh button again - widget.parameter_box.parameter_refresh_button.click() - assert not widget.result_box.run_button.disabled +def test_pydantic_parameter_widget_reports_validation_errors() -> None: + class Params(BaseModel): + value: int = Field(default=1, ge=0) -def test_result_registry() -> None: - registry = {} - widget = _ready_widget( - providers=[strict_provider], output_selections=[str], result_registry=registry - ) - _get_param_widget(widget, int).value = 2 - _get_param_widget(widget, float).value = 0.1 - assert registry == {} - widget.result_box.run_button.click() - assert registry == {str: '2 + 0.1'} + widget = PydanticParameterWidget(Params) + widget.widgets['value'].value = -1 + is_valid, errors = widget.validate() -def test_switchable_widget_dispatch() -> None: - switchable_param = Parameter('a', 'a', 1, switchable=True) - assert isinstance(create_parameter_widget(switchable_param), SwitchWidget) - non_switchable_param = Parameter('b', 'b', 2, switchable=False) - assert not isinstance(create_parameter_widget(non_switchable_param), SwitchWidget) + assert not is_valid + assert 'value' in errors -def test_switchable_parameter_switch_widget() -> None: - widget = _ready_widget(providers=[provider_with_switch], output_selections=[str]) +def test_pydantic_parameter_widget_set_values_accepts_none_for_optional_text() -> None: + class Params(BaseModel): + value: str | None = None - int_widget = _get_param_widget(widget, SwitchableInt) - float_widget = _get_param_widget(widget, SwitchableFloat) + widget = PydanticParameterWidget(Params, initial_values=Params()) - assert isinstance(int_widget, SwitchWidget) - assert isinstance(float_widget, SwitchWidget) + assert widget.widgets['value'].value == '' + assert widget.create_model() == Params(value=None) - assert not float_widget.enabled - assert not int_widget.enabled +def test_pydantic_parameter_widget_supports_nested_model_fields() -> None: + class Params(BaseModel): + group: GroupParams = Field(default_factory=GroupParams) -def test_collect_values_from_disabled_switchable_widget() -> None: - widget = _ready_widget(providers=[provider_with_switch], output_selections=[str]) + widget = PydanticParameterWidget(Params, initial_values=Params()) - assert not _get_param_widget(widget, SwitchableFloat).enabled - assert not _get_param_widget(widget, SwitchableInt).enabled - assert widget.parameter_box.value == {} + widget.widgets['group'].widget.widgets['value'].value = 5 + assert widget.create_model() == Params(group=GroupParams(value=5)) -def test_collect_values_from_enabled_switchable_widget() -> None: - widget = _ready_widget(providers=[provider_with_switch], output_selections=[str]) - float_widget = _get_param_widget(widget, SwitchableFloat) - float_widget.enabled = True - float_widget.value = 0.2 +def test_pydantic_parameter_widget_exposes_field_descriptions_as_help() -> None: + class Params(BaseModel): + count: int = Field(default=1, title='Count', description='Number of items.') - assert widget.parameter_box.value == {SwitchableFloat: 0.2} + widget = PydanticParameterWidget(Params) + field = widget.widgets['count'] + assert 'Number of items.' in field.children[1].value + field.value = 3 + assert widget.create_model() == Params(count=3) -def test_switchable_optional_parameter_switchable_first() -> None: - dummy_param = Parameter('a', 'a', 1, switchable=True, optional=True) - dummy_widget = create_parameter_widget(dummy_param) - assert isinstance(dummy_widget, SwitchWidget) - assert isinstance(dummy_widget.wrapped, OptionalWidget) +def test_pydantic_parameter_value_widget_labels_composite_parameters() -> None: + widget = PydanticParameterValueWidget( + GroupParams, + title='Grouped Parameter', + description='Controls for the grouped parameter.', + default=GroupParams(), + ) -def test_optional_widget_set_value_get_fields() -> None: - optional_param = IntParam('a', 'a', 1, optional=True) - optional_widget = create_parameter_widget(optional_param) - assert isinstance(optional_widget, WidgetWithFieldsProtocol) - assert isinstance(optional_widget, OptionalWidget) - # Check initial state - assert optional_widget._option_box.value is None - assert get_fields(optional_widget) == {'essreduce-opted-out': True, 'value': 1} - # Update the value of the wrapped widget and check the fields - set_fields(optional_widget, {'value': 2}) - assert optional_widget.value is None # Opted-out is not changed - assert get_fields(optional_widget) == {'essreduce-opted-out': True, 'value': 2} - optional_widget.value = 3 - assert get_fields(optional_widget) == {'essreduce-opted-out': False, 'value': 3} + title = widget.children[0].children[0].value + assert 'Grouped Parameter' in title + assert 'Controls for the grouped parameter.' in title -def test_optional_widget_set_fields_get_fields() -> None: - optional_param = Vector3dParameter( - 'a', 'a', sc.vector([1, 2, 3], unit='m'), optional=True - ) - optional_widget = create_parameter_widget(optional_param) - assert isinstance(optional_widget, WidgetWithFieldsProtocol) - assert isinstance(optional_widget, OptionalWidget) - # Check initial state - assert optional_widget._option_box.value is None - expected = {'essreduce-opted-out': True, 'x': 1, 'y': 2, 'z': 3, 'unit': 'm'} - assert optional_widget.get_fields() == expected - # Update the value of the wrapped widget - optional_widget.set_fields( - {'essreduce-opted-out': True, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'} - ) - assert optional_widget.value is None # Opted-out is not changed - optional_widget.set_fields({'essreduce-opted-out': False}) - assert optional_widget.value == sc.vector([4, 5, 6], unit='m') - # Check the fields and the option box value - expected = {'essreduce-opted-out': False, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'} - assert optional_widget.get_fields() == expected - assert optional_widget._option_box.value == optional_param.name +def test_pydantic_parameter_value_widget_uses_title_as_placeholder_fallback() -> None: + widget = PydanticParameterValueWidget(str | None, title='Sample Run', default=None) -def test_optional_widget_dispatch() -> None: - optional_param = Parameter('a', 'a', 1, optional=True) - assert isinstance(create_parameter_widget(optional_param), OptionalWidget) - non_optional_param = Parameter('b', 'b', 2, optional=False) - assert not isinstance(create_parameter_widget(non_optional_param), OptionalWidget) + assert widget._widget.widgets['value'].placeholder == 'Sample Run' -def test_optional_parameter_optional_widget() -> None: - widget = _ready_widget(providers=[provider_with_optional], output_selections=[str]) +def test_pydantic_model_widget_supports_nested_parameter_groups() -> None: + widget = PydanticModelWidget(NestedParams) + group = widget.get_parameter_widget('group') + assert group is not None + group.widgets['value'].value = 4 - int_widget = _get_param_widget(widget, OptionalInt) - float_widget = _get_param_widget(widget, OptionalFloat) + assert widget.parameter_values == NestedParams(group=GroupParams(value=4)) - assert isinstance(int_widget, OptionalWidget) - assert isinstance(float_widget, OptionalWidget) - assert float_widget.value is None - assert int_widget.value is None +def provider(count: Count, label: Label) -> str: + return f"{count}:{label}" -def test_collect_values_from_optional_widget() -> None: - widget = _ready_widget(providers=[provider_with_optional], output_selections=[str]) +test_parameters = ParameterRegistry() +test_parameters[Count] = ParameterSpec( + model=int, category='General', title='Count', default=1, transform=Count +) +test_parameters[Label] = ParameterSpec( + model=str, + category='Text', + title='Label', + default='fallback', + transform=Label, +) +test_parameters[Unused] = ParameterSpec( + model=int, category='Other', title='Unused', default=2, transform=Unused +) - float_widget = _get_param_widget(widget, OptionalFloat) - float_widget.value = 0.2 - assert widget.parameter_box.value == {OptionalFloat: 0.2, OptionalInt: None} +def make_workflow() -> sl.Pipeline: + pipeline = sl.Pipeline([provider]) + pipeline[Label] = Label('a') + return pipeline -def test_collect_values_from_optional_widget_compute_result() -> None: - result_registry = {} - widget = _ready_widget( - providers=[provider_with_optional], - output_selections=[str], - result_registry=result_registry, - ) +def other_provider(unused: Unused) -> int: + return unused - float_widget = _get_param_widget(widget, OptionalFloat) - float_widget.value = 0.2 - widget.result_box.run_button.click() - assert result_registry == {str: '0.2'} +def limited_provider(limited: Limited) -> float: + return float(limited) - int_widget = _get_param_widget(widget, OptionalInt) - int_widget.value = 2 - widget.result_box.run_button.click() - assert result_registry == {str: '2 + 0.2'} +def make_workflow_with_alternative_output() -> sl.Pipeline: + pipeline = sl.Pipeline([provider, other_provider]) + pipeline[Label] = Label('a') + return pipeline -def dummy_workflow_constructor() -> sl.Pipeline: - return sl.Pipeline([strict_provider]) +def make_workflow_with_limited_parameter() -> sl.Pipeline: + pipeline = sl.Pipeline([provider, limited_provider]) + pipeline[Label] = Label('a') + return pipeline @contextmanager def temporary_workflow_registry( - *constructors: Callable[[], sl.Pipeline], + *constructors: Callable[..., sl.Pipeline], ) -> Generator[None, None, None]: - existance_flags = { + existence_flags = { constructor: constructor in workflow_registry for constructor in constructors } - for constructor in constructors: - register_workflow(constructor) - yield - for constructor, flag in existance_flags.items(): - if not flag: - workflow_registry.discard(constructor) + try: + for constructor in constructors: + register_workflow( + parameters=test_parameters, + typical_outputs=(str,), + title="Example", + )(constructor) + yield + finally: + for constructor, existed in existence_flags.items(): + if not existed: + workflow_registry.discard(constructor) + + +def test_register_workflow_with_metadata() -> None: + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) + assert spec.factory is make_workflow + assert spec.parameters is test_parameters + assert spec.typical_outputs == (str,) + assert spec.title == "Example" + + with pytest.raises(KeyError): + workflow_registry.get(make_workflow) -def test_register_workflow() -> None: - with temporary_workflow_registry(dummy_workflow_constructor): - assert dummy_workflow_constructor in workflow_registry +def _get_selection_widget(widget): + return widget.children[0].children[0] + - assert dummy_workflow_constructor not in workflow_registry +def _category_sections(widget: WorkflowWidget): + return { + section.get_title(0): section + for section in widget.parameter_box._input_box.children + } -def _get_selection_widget(widget): - return widget.children[0].children[0] +def _category_expansion_state(widget: WorkflowWidget): + return { + title: section.selected_index + for title, section in _category_sections(widget).items() + } def test_workflow_registry_applied_to_selector() -> None: - expected_constructor_pair = ( - 'dummy_workflow_constructor', - dummy_workflow_constructor, - ) - with temporary_workflow_registry(dummy_workflow_constructor): + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) selection_widget = _get_selection_widget(workflow_widget()) - assert expected_constructor_pair in selection_widget.options - - selection_widget = _get_selection_widget(workflow_widget()) - assert expected_constructor_pair not in selection_widget.options + assert ("Example", spec) in selection_widget.options -def dummy_second_workflow_constructor() -> sl.Pipeline: - return sl.Pipeline([provider_with_switch]) - -def test_workflow_selection() -> None: - # Prepare - with temporary_workflow_registry( - dummy_workflow_constructor, dummy_second_workflow_constructor - ): +def test_workflow_selection_builds_workflow_widget() -> None: + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) widget = workflow_widget() selection_widget = _get_selection_widget(widget) - # Before selection - assert len(widget.children[1].children) == 0 - # Select first workflow - selection_widget.value = dummy_workflow_constructor + + selection_widget.value = spec + assert len(widget.children[1].children) == 1 - # Test created WorkflowWidget - first_widget = widget.children[1].children[0] - assert isinstance(first_widget, WorkflowWidget) - assert first_widget.output_selection_box.typical_outputs_widget.options == ( + selected_widget = widget.children[1].children[0] + assert isinstance(selected_widget, WorkflowWidget) + assert selected_widget.output_selection_box.typical_outputs_widget.options == ( ('str', str), ) - _refresh_widget_parameter(widget=first_widget, output_selections=[str]) - assert first_widget.parameter_box._input_widgets.keys() == {int, float} - # Select second workflow - selection_widget.value = dummy_second_workflow_constructor - second_widget = widget.children[1].children[0] - _refresh_widget_parameter(widget=second_widget, output_selections=[str]) - assert second_widget.parameter_box._input_widgets.keys() == { - SwitchableInt, - SwitchableFloat, - } - - -WavelengthBins = NewType('WavelengthBins', sc.Variable) -WavelengthBinsWithUnit = NewType('WavelengthBinsWithUnit', sc.Variable) -QBins = NewType('QBins', sc.Variable) -TemperatureBinsWithUnits = NewType('TemperatureBinsWithUnits', sc.Variable) - - -parameter_registry[WavelengthBins] = BinEdgesParameter(WavelengthBins, dim='wavelength') -parameter_registry[WavelengthBinsWithUnit] = BinEdgesParameter( - WavelengthBinsWithUnit, dim='wavelength', unit='m' -) -parameter_registry[QBins] = BinEdgesParameter( - QBins, dim='Q', start=0.01, stop=0.6, nbins=150 -) -parameter_registry[TemperatureBinsWithUnits] = BinEdgesParameter( - TemperatureBinsWithUnits, dim='temperature', unit=('Kelvin', 'Celsius', 'Degrees') -) + assert selected_widget.workflow is spec -def wavelength_bins_print_provider(bins: WavelengthBins) -> str: - return str(bins) +def test_get_parameters_filters_by_selected_output_and_uses_workflow_defaults() -> None: + params = get_parameters(make_workflow(), (str,), test_parameters) + assert set(params) == {Count, Label} + assert params[Count].default == 1 + assert params[Label].default == 'a' -def wavelength_bins_with_unit_print_provider(bins: WavelengthBinsWithUnit) -> str: - return str(bins) +def test_assign_parameter_values_uses_key_specs() -> None: + pipeline = make_workflow() + params = get_parameters(pipeline, (str,), test_parameters) -def q_bins_print_provider(bins: QBins) -> str: - return str(bins) + updated = assign_parameter_values(pipeline, {Count: 5, Label: 'x'}, params) + assert updated.compute(str) == '5:x' -def temperature_bins_print_provider(bins: TemperatureBinsWithUnits) -> str: - return str(bins) +def test_workflow_widget_runs_with_keyed_parameter_values() -> None: + registry = {} + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec, result_registry=registry) + widget.output_selection_box.typical_outputs_widget.value = (str,) -def test_bin_edges_widget_simple() -> None: - widget = _ready_widget( - providers=[wavelength_bins_print_provider], output_selections=[str] - ) - param_widget = _get_param_widget(widget, WavelengthBins) - assert sc.identical( - param_widget.value, - sc.linspace(dim='wavelength', start=0.0, stop=0.0, num=2, unit='angstrom'), - ) - assert set(param_widget.fields['unit'].options) == {'angstrom', 'nm'} - assert param_widget.fields['nbins'].value == 1 - assert param_widget.fields['spacing'].value == 'linear' - # Modify the values - param_widget.fields['start'].value = 1.0 - param_widget.fields['stop'].value = 200.0 - param_widget.fields['nbins'].value = 10 - assert sc.identical( - param_widget.value, - sc.linspace(dim='wavelength', start=1.0, stop=200.0, num=11, unit='angstrom'), - ) + assert not hasattr(widget.parameter_box, 'parameter_refresh_button') + assert set(widget.parameter_box.parameters) == {Count, Label} + set_parameter_widget_values(widget, {Count: 5, Label: 'x'}) + widget.result_box.run_button.click() -def test_bin_edges_widget_override_unit() -> None: - widget = _ready_widget( - providers=[wavelength_bins_with_unit_print_provider], output_selections=[str] - ) - param_widget = _get_param_widget(widget, WavelengthBinsWithUnit) - assert sc.identical( - param_widget.value, - sc.linspace(dim='wavelength', start=0.0, stop=0.0, num=2, unit='m'), - ) - assert set(param_widget.fields['unit'].options) == {'m'} - assert param_widget.fields['nbins'].value == 1 + assert registry == {str: '5:x'} -def test_bin_edges_widget_override_unit_multiple() -> None: - widget = _ready_widget( - providers=[temperature_bins_print_provider], output_selections=[str] - ) - param_widget = _get_param_widget(widget, TemperatureBinsWithUnits) - assert sc.identical( - param_widget.value, - sc.linspace(dim='temperature', start=0.0, stop=0.0, num=2, unit='Kelvin'), - ) - assert set(param_widget.fields['unit'].options) == {'Kelvin', 'Celsius', 'Degrees'} +def test_workflow_widget_clears_parameters_when_no_output_is_selected() -> None: + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec) + assert widget.result_box.run_button.disabled + assert widget.parameter_box.parameters == {} -def test_bin_edges_widget_log_spacing() -> None: - widget = _ready_widget( - providers=[wavelength_bins_print_provider], output_selections=[str] - ) - param_widget = _get_param_widget(widget, WavelengthBins) - param_widget.fields['spacing'].value = 'log' - param_widget.fields['start'].value = 1.0e3 - param_widget.fields['stop'].value = 1.0e5 - param_widget.fields['nbins'].value = 7 - assert sc.identical( - param_widget.value, - sc.geomspace(dim='wavelength', start=1.0e3, stop=1.0e5, num=8, unit='angstrom'), - ) - assert param_widget.fields['spacing'].value == 'log' + widget.output_selection_box.typical_outputs_widget.value = (str,) + assert not widget.result_box.run_button.disabled + assert set(widget.parameter_box.parameters) == {Count, Label} + widget.output_selection_box.typical_outputs_widget.value = () + assert widget.result_box.run_button.disabled + assert widget.parameter_box.parameters == {} -def test_bin_edges_widget_with_default_values() -> None: - widget = _ready_widget(providers=[q_bins_print_provider], output_selections=[str]) - param_widget = _get_param_widget(widget, QBins) - assert sc.identical( - param_widget.value, - sc.linspace(dim='Q', start=0.01, stop=0.6, num=151, unit='1/angstrom'), - ) - assert set(param_widget.fields['unit'].options) == {'1/angstrom', '1/nm'} - assert param_widget.fields['start'].value == 0.01 - assert param_widget.fields['stop'].value == 0.6 - assert param_widget.fields['nbins'].value == 150 - assert param_widget.fields['spacing'].value == 'linear' - - -@pytest.mark.parametrize( - 'output', - [ - (sc.scalar(1), sc.scalar(2)), - 'Test with a string', - sc.data.binned_xy(100, 10, 10), - ], -) -def test_result_box_can_handle_different_outputs(output): - was_called = False - - def run_workflow(): - nonlocal was_called - was_called = True - return dict(enumerate(output)) - - ResultBox(run_workflow).run_button.click() - assert was_called - - -def test_switchable_widget_set_values() -> None: - param = IntParam('a', 'a', 1, switchable=True) - widget = create_parameter_widget(param) - assert isinstance(widget, SwitchWidget) - assert not widget.enabled - widget.set_fields({'enabled': True}) - assert widget.enabled - widget.set_fields({'enabled': False}) - assert not widget.enabled - widget.set_fields({'enabled': True, 'value': 2}) - assert widget.enabled - assert widget.value == 2 - widget.set_fields({'enabled': False, 'value': 3}) - assert not widget.enabled - assert widget.value == 3 - widget.set_fields({'value': 4}) - assert not widget.enabled - assert widget.value == 4 - - -def test_switchable_widget_get_fields_only_value() -> None: - param = IntParam('a', 'a', 1, switchable=True) - widget = create_parameter_widget(param) - assert isinstance(widget, SwitchWidget) - assert widget.get_fields() == {'enabled': False, 'value': 1} - widget.enabled = True - assert widget.get_fields() == {'enabled': True, 'value': 1} - widget.value = 2 - assert widget.get_fields() == {'enabled': True, 'value': 2} - widget.enabled = False - assert widget.get_fields() == {'enabled': False, 'value': 2} - - -def test_switchable_widget_set_fields() -> None: - param = Vector3dParameter('a', 'a', sc.vector([1, 2, 3], unit='m'), switchable=True) - widget = create_parameter_widget(param) - assert isinstance(widget, SwitchWidget) - assert not widget.enabled - assert widget.value == sc.vector([1, 2, 3], unit='m') - widget.set_fields({'enabled': True, 'x': 4, 'y': 5, 'z': 6, 'unit': 'm'}) - assert widget.enabled - assert widget.value == sc.vector([4, 5, 6], unit='m') - widget.set_fields({'x': 7, 'y': 8}) - assert widget.enabled - assert widget.value == sc.vector([7, 8, 6], unit='m') - - -def test_switchable_widget_get_fields_sub_fields() -> None: - param = Vector3dParameter('a', 'a', sc.vector([1, 2, 3], unit='m'), switchable=True) - widget = create_parameter_widget(param) - assert isinstance(widget, SwitchWidget) - assert widget.get_fields() == { - 'enabled': False, - 'x': 1, - 'y': 2, - 'z': 3, - 'unit': 'm', - } - widget.enabled = True - assert widget.get_fields() == {'enabled': True, 'x': 1, 'y': 2, 'z': 3, 'unit': 'm'} - widget.set_fields({'enabled': False, 'x': 4, 'y': 5, 'unit': 'mm'}) - assert widget.get_fields() == { - 'enabled': False, - 'x': 4, - 'y': 5, - 'z': 3, - 'unit': 'mm', - } +def test_get_parameter_widget_values_returns_current_keyed_fields() -> None: + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec) + widget.output_selection_box.typical_outputs_widget.value = (str,) -FilenameSampleRun = NewType('FilenameSampleRun', str) -parameter_registry[FilenameSampleRun] = FilenameParameter.from_type( - FilenameSampleRun, default="SampleRun.hdf" -) -MultiFilenameSampleRun = NewType('MultiFilenameSampleRun', list[str]) -parameter_registry[MultiFilenameSampleRun] = MultiFilenameParameter.from_type( - MultiFilenameSampleRun, default=["file1.hdf", "file2.hdf"] -) -MultiFilenameBackgroundRun = NewType('MultiFilenameBackgroundRun', list[str]) -parameter_registry[MultiFilenameBackgroundRun] = MultiFilenameParameter.from_type( - MultiFilenameBackgroundRun, default="background.hdf" -) + set_parameter_widget_values(widget, {Count: 7}) + assert get_parameter_widget_values(widget)[Count] == {'value': 7} -def filename_print_provider(fname: FilenameSampleRun) -> str: - return str(fname) +def test_parameter_categories_are_independently_open_and_full_width() -> None: + with temporary_workflow_registry(make_workflow): + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec) + widget.output_selection_box.typical_outputs_widget.value = (str,) -def multi_filename_print_provider(fnames: MultiFilenameSampleRun) -> list[str]: - return [str(fname) for fname in fnames] + sections = widget.parameter_box._input_box.children + + assert [section.get_title(0) for section in sections] == ['General', 'Text'] + assert [section.selected_index for section in sections] == [0, 0] + sections[0].selected_index = None + assert [section.selected_index for section in sections] == [None, 0] + assert isinstance(widget.parameter_box._input_box, VBox) + assert widget.parameter_box.layout.width == '100%' + assert widget.parameter_box._input_box.layout.width == '100%' + assert all(section.layout.width == 'auto' for section in sections) + assert all(section.children[0].layout.width == '100%' for section in sections) + assert all( + field.layout.width == 'auto' + for parameter_widget in widget.parameter_box._input_widgets.values() + for field in parameter_widget._widget.widgets.values() + ) + + +def test_parameter_category_expansion_state_is_preserved_by_category() -> None: + widget = WorkflowWidget( + WorkflowSpec.from_factory( + make_workflow_with_alternative_output, + parameters=test_parameters, + typical_outputs=(str, int), + ) + ) + output_widget = widget.output_selection_box.typical_outputs_widget + output_widget.value = (str,) + assert _category_expansion_state(widget) == { + 'General': 0, + 'Text': 0, + } -def multi_filename_print_provider_bis(fnames: MultiFilenameBackgroundRun) -> list[str]: - return [str(fname) for fname in fnames] + sections = _category_sections(widget) + sections['Text'].selected_index = None + output_widget.value = (str, int) + assert _category_expansion_state(widget) == { + 'General': 0, + 'Text': None, + 'Other': 0, + } + sections = _category_sections(widget) + sections['Other'].selected_index = None + output_widget.value = (int,) + assert _category_expansion_state(widget) == { + 'Other': None, + } -def test_filename_widget_single() -> None: - widget = _ready_widget(providers=[filename_print_provider], output_selections=[str]) - param_widget = _get_param_widget(widget, FilenameSampleRun) - assert param_widget.value == "SampleRun.hdf" + output_widget.value = (str, int) + assert _category_expansion_state(widget) == { + 'General': 0, + 'Text': None, + 'Other': None, + } -def test_filename_widget_multiple() -> None: - widget = _ready_widget( - providers=[multi_filename_print_provider], output_selections=[list[str]] +def test_parameter_refresh_preserves_invalid_fields_without_validating() -> None: + parameters = ParameterRegistry() + parameters[Count] = test_parameters[Count] + parameters[Label] = test_parameters[Label] + parameters[Limited] = ParameterSpec( + model=LimitedParams, + category='Limited', + title='Limited', + default=LimitedParams(), + transform=lambda params: Limited(params.value), ) - param_widget = _get_param_widget(widget, MultiFilenameSampleRun) - assert param_widget.value == ("file1.hdf", "file2.hdf") + widget = WorkflowWidget( + WorkflowSpec.from_factory( + make_workflow_with_limited_parameter, + parameters=parameters, + typical_outputs=(float, str), + ) + ) + output_widget = widget.output_selection_box.typical_outputs_widget + output_widget.value = (float,) + widget.parameter_box._input_widgets[Limited]._widget.widgets['value'].value = -1 + output_widget.value = (float, str) -def test_filename_widget_multiple_initialized_with_single_string() -> None: - widget = _ready_widget( - providers=[multi_filename_print_provider_bis], output_selections=[list[str]] - ) - param_widget = _get_param_widget(widget, MultiFilenameBackgroundRun) - assert param_widget.value == ("background.hdf",) + limited_widget = widget.parameter_box._input_widgets[Limited] + assert limited_widget._widget.widgets['value'].value == -1 + is_valid, errors = limited_widget.validate() + assert not is_valid + assert 'greater than or equal to 0' in errors diff --git a/packages/essreflectometry/src/ess/estia/workflow.py b/packages/essreflectometry/src/ess/estia/workflow.py index bd40b0381..f95a47009 100644 --- a/packages/essreflectometry/src/ess/estia/workflow.py +++ b/packages/essreflectometry/src/ess/estia/workflow.py @@ -154,49 +154,49 @@ def EstiaWorkflow( return workflow -@register_workflow +@register_workflow() def EstiaMcStasUnnormalizedWorkflow() -> sciline.Pipeline: """Workflow for Estia McStas data without run normalization.""" return EstiaMcStasWorkflow(run_norm=RunNormalization.none) -@register_workflow +@register_workflow() def EstiaMcStasMonitorHistogramWorkflow() -> sciline.Pipeline: """Workflow for Estia McStas data using histogrammed monitor normalization.""" return EstiaMcStasWorkflow(run_norm=RunNormalization.monitor_histogram) -@register_workflow +@register_workflow() def EstiaMcStasMonitorIntegratedWorkflow() -> sciline.Pipeline: """Workflow for Estia McStas data using integrated monitor normalization.""" return EstiaMcStasWorkflow(run_norm=RunNormalization.monitor_integrated) -@register_workflow +@register_workflow() def EstiaMcStasProtonChargeWorkflow() -> sciline.Pipeline: """Workflow for Estia McStas data using proton charge normalization.""" return EstiaMcStasWorkflow(run_norm=RunNormalization.proton_charge) -@register_workflow +@register_workflow() def EstiaUnnormalizedWorkflow() -> sciline.Pipeline: """Workflow for Estia NeXus data without run normalization.""" return EstiaWorkflow(run_norm=RunNormalization.none) -@register_workflow +@register_workflow() def EstiaMonitorHistogramWorkflow() -> sciline.Pipeline: """Workflow for Estia NeXus data using histogrammed monitor normalization.""" return EstiaWorkflow(run_norm=RunNormalization.monitor_histogram) -@register_workflow +@register_workflow() def EstiaMonitorIntegratedWorkflow() -> sciline.Pipeline: """Workflow for Estia NeXus data using integrated monitor normalization.""" return EstiaWorkflow(run_norm=RunNormalization.monitor_integrated) -@register_workflow +@register_workflow() def EstiaProtonChargeWorkflow() -> sciline.Pipeline: """Workflow for Estia NeXus data using proton charge normalization.""" return EstiaWorkflow(run_norm=RunNormalization.proton_charge) diff --git a/packages/essreflectometry/src/ess/freia/workflow.py b/packages/essreflectometry/src/ess/freia/workflow.py index 327a5f9d9..34464fd37 100644 --- a/packages/essreflectometry/src/ess/freia/workflow.py +++ b/packages/essreflectometry/src/ess/freia/workflow.py @@ -139,49 +139,49 @@ def FreiaWorkflow( return workflow -@register_workflow +@register_workflow() def FreiaMcStasUnnormalizedWorkflow() -> sciline.Pipeline: """Workflow for Freia McStas data without run normalization.""" return FreiaMcStasWorkflow(run_norm=RunNormalization.none) -@register_workflow +@register_workflow() def FreiaMcStasMonitorHistogramWorkflow() -> sciline.Pipeline: """Workflow for Freia McStas data using histogrammed monitor normalization.""" return FreiaMcStasWorkflow(run_norm=RunNormalization.monitor_histogram) -@register_workflow +@register_workflow() def FreiaMcStasMonitorIntegratedWorkflow() -> sciline.Pipeline: """Workflow for Freia McStas data using integrated monitor normalization.""" return FreiaMcStasWorkflow(run_norm=RunNormalization.monitor_integrated) -@register_workflow +@register_workflow() def FreiaMcStasProtonChargeWorkflow() -> sciline.Pipeline: """Workflow for Freia McStas data using proton charge normalization.""" return FreiaMcStasWorkflow(run_norm=RunNormalization.proton_charge) -@register_workflow +@register_workflow() def FreiaUnnormalizedWorkflow() -> sciline.Pipeline: """Workflow for Freia NeXus data without run normalization.""" return FreiaWorkflow(run_norm=RunNormalization.none) -@register_workflow +@register_workflow() def FreiaMonitorHistogramWorkflow() -> sciline.Pipeline: """Workflow for Freia NeXus data using histogrammed monitor normalization.""" return FreiaWorkflow(run_norm=RunNormalization.monitor_histogram) -@register_workflow +@register_workflow() def FreiaMonitorIntegratedWorkflow() -> sciline.Pipeline: """Workflow for Freia NeXus data using integrated monitor normalization.""" return FreiaWorkflow(run_norm=RunNormalization.monitor_integrated) -@register_workflow +@register_workflow() def FreiaProtonChargeWorkflow() -> sciline.Pipeline: """Workflow for Freia NeXus data using proton charge normalization.""" return FreiaWorkflow(run_norm=RunNormalization.proton_charge) diff --git a/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb b/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb index 3f7c51956..ec698fb50 100644 --- a/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb +++ b/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb @@ -44,7 +44,7 @@ }, "outputs": [], "source": [ - "from ess.sans.types import DirectBeam, QBins\n", + "from ess.sans.types import QBins\n", "\n", "select = widget.children[0].children[0]\n", "keys, values = zip(*select.options, strict=True)\n", @@ -56,14 +56,8 @@ "keys, values = zip(*outputs.options, strict=True)\n", "ind = keys.index(\"IntensityQ[SampleRun]\")\n", "outputs.value = (values[ind],)\n", - "# Refresh parameters\n", - "pbox = wfw.parameter_box\n", - "pbox.parameter_refresh_button.click()\n", - "# Enable DirectBeam input\n", - "pbox._input_widgets[DirectBeam].children[0].value = True\n", - "pbox._input_widgets[DirectBeam].children[1].children[1]._option_box.value = None\n", "# Adjust Q range\n", - "pbox._input_widgets[QBins].fields[\"start\"].value = 0.01\n", + "ui.set_parameter_widget_values(wfw, {QBins: {\"start\": 0.01}})\n", "# Run the workflow\n", "rbox = wfw.result_box\n", "rbox.run_button.click()" diff --git a/packages/esssans/src/ess/isissans/sans2d.py b/packages/esssans/src/ess/isissans/sans2d.py index 6f839785a..5cf49e212 100644 --- a/packages/esssans/src/ess/isissans/sans2d.py +++ b/packages/esssans/src/ess/isissans/sans2d.py @@ -5,7 +5,7 @@ import sciline import scipp as sc from ess.sans import SansWorkflow -from ess.sans.parameters import typical_outputs +from ess.sans.parameters import parameters, typical_outputs from ess.sans.types import BeamCenter, DetectorMasks, EmptyDetector, SampleRun from ess.reduce.workflow import register_workflow @@ -83,7 +83,7 @@ def to_detector_masks( providers = (detector_edge_mask, sample_holder_mask, to_detector_masks) -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def Sans2dWorkflow() -> sciline.Pipeline: """Create Sans2d workflow with default parameters.""" from . import providers as isis_providers @@ -95,11 +95,10 @@ def Sans2dWorkflow() -> sciline.Pipeline: workflow.insert(provider) for key, param in default_parameters().items(): workflow[key] = param - workflow.typical_outputs = typical_outputs return workflow -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def Sans2dTutorialWorkflow() -> sciline.Pipeline: """ Create Sans2d tutorial workflow. diff --git a/packages/esssans/src/ess/isissans/zoom.py b/packages/esssans/src/ess/isissans/zoom.py index 81ce3456f..3ac078b92 100644 --- a/packages/esssans/src/ess/isissans/zoom.py +++ b/packages/esssans/src/ess/isissans/zoom.py @@ -3,7 +3,7 @@ import sciline from ess.sans import SansWorkflow from ess.sans.io import read_xml_detector_masking -from ess.sans.parameters import typical_outputs +from ess.sans.parameters import parameters, typical_outputs from ess.reduce.workflow import register_workflow @@ -22,7 +22,7 @@ def set_mantid_log_level(level: int = 3): pass -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def ZoomWorkflow() -> sciline.Pipeline: """Create Zoom workflow with default parameters.""" from . import providers as isis_providers @@ -37,11 +37,10 @@ def ZoomWorkflow() -> sciline.Pipeline: for key, param in default_parameters().items(): workflow[key] = param workflow.insert(read_xml_detector_masking) - workflow.typical_outputs = typical_outputs return workflow -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def ZoomTutorialWorkflow() -> sciline.Pipeline: """ Create Zoom tutorial workflow. diff --git a/packages/esssans/src/ess/loki/larmor_workflow.py b/packages/esssans/src/ess/loki/larmor_workflow.py index 664feee8f..2a54545c1 100644 --- a/packages/esssans/src/ess/loki/larmor_workflow.py +++ b/packages/esssans/src/ess/loki/larmor_workflow.py @@ -9,7 +9,7 @@ import scippnexus as snx from ess import sans from ess.sans.io import read_xml_detector_masking -from ess.sans.parameters import typical_outputs +from ess.sans.parameters import parameters, typical_outputs from scippneutron.conversion.graph import beamline, tof from ess.reduce.workflow import register_workflow @@ -143,7 +143,7 @@ def larmor_monitor_coord_transform_graph( ) -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def LokiAtLarmorWorkflow() -> sciline.Pipeline: """ Workflow with default parameters for Loki test at Larmor. @@ -166,17 +166,17 @@ def LokiAtLarmorWorkflow() -> sciline.Pipeline: for key, param in larmor_default_parameters().items(): workflow[key] = param workflow[NeXusDetectorName] = 'larmor_detector' - workflow.typical_outputs = typical_outputs return workflow -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def LokiAtLarmorTutorialWorkflow() -> sciline.Pipeline: from ess.loki import data workflow = LokiAtLarmorWorkflow() - workflow[PixelMaskFilename] = list(map(str, data.loki_tutorial_mask_filenames())) + (mask_filename,) = data.loki_tutorial_mask_filenames() + workflow[PixelMaskFilename] = str(mask_filename) workflow[Filename[SampleRun]] = str(data.loki_tutorial_sample_run_60339()) workflow[Filename[BackgroundRun]] = str(data.loki_tutorial_background_run_60393()) workflow[Filename[TransmissionRun[SampleRun]]] = str( diff --git a/packages/esssans/src/ess/loki/workflow.py b/packages/esssans/src/ess/loki/workflow.py index 61d6b96a0..c76acaf14 100644 --- a/packages/esssans/src/ess/loki/workflow.py +++ b/packages/esssans/src/ess/loki/workflow.py @@ -8,7 +8,7 @@ import scipp as sc import scippnexus as snx from ess import sans -from ess.sans.parameters import typical_outputs +from ess.sans.parameters import parameters, typical_outputs from ess.reduce.workflow import register_workflow @@ -72,7 +72,7 @@ def load_direct_beam(filename: DirectBeamFilename) -> DirectBeam: loki_providers = (detector_pixel_shape, load_direct_beam) -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def LokiWorkflow() -> sciline.Pipeline: """ Workflow with default parameters for Loki. @@ -87,5 +87,4 @@ def LokiWorkflow() -> sciline.Pipeline: workflow.insert(provider) for key, param in loki_default_parameters().items(): workflow[key] = param - workflow.typical_outputs = typical_outputs return workflow diff --git a/packages/esssans/src/ess/sans/parameters.py b/packages/esssans/src/ess/sans/parameters.py index 561e88d57..5db379a17 100644 --- a/packages/esssans/src/ess/sans/parameters.py +++ b/packages/esssans/src/ess/sans/parameters.py @@ -1,22 +1,21 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) """ -Default parameters, providers and utility functions for the loki workflow. +Default parameter specs for SANS workflows. """ from __future__ import annotations +import sciline import scipp as sc +from pydantic import BaseModel, Field -from ess.reduce.parameter import ( - BinEdgesParameter, - BooleanParameter, - FilenameParameter, - MultiFilenameParameter, - ParamWithOptions, - StringParameter, - Vector2dParameter, - parameter_registry, +from ess.reduce.parameter import ParameterRegistry, ParameterSpec +from ess.reduce.parameter_models import ( + LengthUnit, + QEdges, + QUnit, + WavelengthEdges, ) from ..sans.types import ( @@ -51,71 +50,227 @@ WavelengthMonitor, ) -parameter_registry[CorrectForGravity] = BooleanParameter.from_type( - CorrectForGravity, default=False -) -parameter_registry[NeXusDetectorName] = StringParameter.from_type(NeXusDetectorName) -parameter_registry[NeXusMonitorName[Incident]] = StringParameter.from_type( - NeXusMonitorName[Incident], default='' +class QxEdges(QEdges): + """Model for Qx edges.""" + + start: float = Field(default=-0.5, description="Start of the Qx edges.") + stop: float = Field(default=0.5, description="Stop of the Qx edges.") + + def get_edges(self) -> sc.Variable: + return sc.linspace( + dim='Qx', + start=self.start, + stop=self.stop, + num=self.num_bins + 1, + unit=self.unit.value, + ) + + +class QyEdges(QEdges): + """Model for Qy edges.""" + + start: float = Field(default=-0.5, description="Start of the Qy edges.") + stop: float = Field(default=0.5, description="Stop of the Qy edges.") + + def get_edges(self) -> sc.Variable: + return sc.linspace( + dim='Qy', + start=self.start, + stop=self.stop, + num=self.num_bins + 1, + unit=self.unit.value, + ) + + +class BeamCenterXY(BaseModel): + """Beam center position in detector coordinates.""" + + x: float = Field(default=0.0, description="Beam center x coordinate.") + y: float = Field(default=0.0, description="Beam center y coordinate.") + unit: LengthUnit = Field(default=LengthUnit.METER, description="Beam center unit.") + + def get_vector(self) -> sc.Variable: + return sc.vector([self.x, self.y, 0.0], unit=self.unit.value) + + +def _edges(model): + return model.get_edges() + + +def _beam_center(model: BeamCenterXY) -> sc.Variable: + return model.get_vector() + + +def _direct_beam_filename( + workflow: sciline.Pipeline, filename: str | None +) -> sciline.Pipeline: + workflow = workflow.copy() + if filename: + workflow[DirectBeamFilename] = filename + else: + workflow[DirectBeam] = None + return workflow + + +parameters = ParameterRegistry() + + +parameters[Filename[SampleRun]] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Sample Runs', + description='Comma-separated NeXus file paths for the sample runs.', ) -parameter_registry[NeXusMonitorName[Transmission]] = StringParameter.from_type( - NeXusMonitorName[Transmission], default='' +parameters[Filename[BackgroundRun]] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Background Runs', + description='Comma-separated NeXus file paths for the background runs.', ) -parameter_registry[TransformationPath] = StringParameter.from_type( - TransformationPath, default='' +parameters[Filename[TransmissionRun[SampleRun]]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Transmission', + description='NeXus file path for the sample transmission run.', + default=None, ) -parameter_registry[PixelMaskFilename] = MultiFilenameParameter.from_type( - PixelMaskFilename +parameters[Filename[TransmissionRun[BackgroundRun]]] = ParameterSpec( + model=str | None, + category='Files', + title='Background Transmission', + description='NeXus file path for the background transmission run.', + default=None, ) -parameter_registry[PixelShapePath] = StringParameter.from_type( - PixelShapePath, default='' +parameters[Filename[EmptyBeamRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Empty Beam', + description='NeXus file path for the empty-beam run.', + default=None, ) -# Should this be ReductionMode (EventMode/HistogramMode)? -parameter_registry[ReturnEvents] = BooleanParameter.from_type( - ReturnEvents, default=False +parameters[PixelMaskFilename] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Pixel Masks', + description='Comma-separated paths to detector pixel mask files.', + default=(), ) -parameter_registry[UncertaintyBroadcastMode] = ParamWithOptions.from_enum( - UncertaintyBroadcastMode, default=UncertaintyBroadcastMode.upper_bound +parameters[DirectBeamFilename] = ParameterSpec( + model=str | None, + category='Files', + title='Direct Beam', + description='Direct-beam file path; leave empty to skip direct-beam correction.', + default=None, + apply=_direct_beam_filename, ) -parameter_registry[Filename[SampleRun]] = MultiFilenameParameter.from_type( - Filename[SampleRun] + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', + description='Name of the detector group in the NeXus files.', ) -parameter_registry[Filename[BackgroundRun]] = MultiFilenameParameter.from_type( - Filename[BackgroundRun] +parameters[NeXusMonitorName[Incident]] = ParameterSpec( + model=str, + category='NeXus', + title='Incident Monitor', + description='Name of the incident monitor group in the NeXus files.', + default='', ) -parameter_registry[Filename[TransmissionRun[SampleRun]]] = FilenameParameter.from_type( - Filename[TransmissionRun[SampleRun]] +parameters[NeXusMonitorName[Transmission]] = ParameterSpec( + model=str, + category='NeXus', + title='Transmission Monitor', + description='Name of the transmission monitor group in the NeXus files.', + default='', ) -parameter_registry[Filename[TransmissionRun[BackgroundRun]]] = ( - FilenameParameter.from_type(Filename[TransmissionRun[BackgroundRun]]) +parameters[TransformationPath] = ParameterSpec( + model=str, + category='NeXus', + title='Transform Path', + description='NeXus path containing detector transformation information.', + default='', ) -parameter_registry[Filename[EmptyBeamRun]] = FilenameParameter.from_type( - Filename[EmptyBeamRun] +parameters[PixelShapePath] = ParameterSpec( + model=str, + category='NeXus', + title='Pixel Shape Path', + description='NeXus path containing detector pixel-shape information.', + default='', ) -parameter_registry[WavelengthBins] = BinEdgesParameter( - WavelengthBins, dim='wavelength', start=2, stop=12.0, nbins=300, log=False +parameters[WavelengthBins] = ParameterSpec( + model=WavelengthEdges, + category='Binning', + title='Wavelength Edges', + description='Wavelength bin edges used for monitor and detector histograms.', + default=WavelengthEdges(start=2.0, stop=12.0, num_bins=300), + transform=_edges, + use_workflow_default=False, ) -parameter_registry[QBins] = BinEdgesParameter( - QBins, dim='Q', start=0.1, stop=0.3, nbins=100, log=False +parameters[QBins] = ParameterSpec( + model=QEdges, + category='Binning', + title='Q Edges', + description='Q bin edges used for one-dimensional I(Q) outputs.', + default=QEdges(start=0.1, stop=0.3, num_bins=100, unit=QUnit.INVERSE_ANGSTROM), + transform=_edges, + use_workflow_default=False, ) -parameter_registry[QxBins] = BinEdgesParameter( - QxBins, dim='Qx', start=-0.5, stop=0.5, nbins=100 +parameters[QxBins] = ParameterSpec( + model=QxEdges, + category='Binning', + title='Qx Edges', + description='Qx bin edges used for two-dimensional I(Qx, Qy) outputs.', + default=QxEdges(), + transform=_edges, + use_workflow_default=False, ) -parameter_registry[QyBins] = BinEdgesParameter( - QyBins, dim='Qy', start=-0.5, stop=0.5, nbins=100 +parameters[QyBins] = ParameterSpec( + model=QyEdges, + category='Binning', + title='Qy Edges', + description='Qy bin edges used for two-dimensional I(Qx, Qy) outputs.', + default=QyEdges(), + transform=_edges, + use_workflow_default=False, ) -parameter_registry[DirectBeam] = StringParameter.from_type( - DirectBeam, switchable=True, optional=True, default=None + +parameters[CorrectForGravity] = ParameterSpec( + model=bool, + category='Reduction', + title='Correct Gravity', + description='Apply gravity correction to neutron trajectories before reduction.', + default=False, + transform=CorrectForGravity, +) +parameters[ReturnEvents] = ParameterSpec( + model=bool, + category='Reduction', + title='Return Events', + description='Keep event data in outputs where the workflow supports it.', + default=False, + transform=ReturnEvents, ) -parameter_registry[DirectBeamFilename] = FilenameParameter.from_type( - DirectBeamFilename, switchable=True +parameters[UncertaintyBroadcastMode] = ParameterSpec( + model=UncertaintyBroadcastMode, + category='Reduction', + title='Uncertainty Broadcast', + description='How uncertainties are broadcast when reduced data are combined.', + default=UncertaintyBroadcastMode.upper_bound, ) -parameter_registry[BeamCenter] = Vector2dParameter.from_type( - BeamCenter, default=sc.vector([0, 0, 0], unit='m') +parameters[BeamCenter] = ParameterSpec( + model=BeamCenterXY, + category='Reduction', + title='Beam Center', + description='Detector-plane beam center used for Q conversion.', + default=BeamCenterXY(), + transform=_beam_center, + use_workflow_default=False, ) + typical_outputs = ( BackgroundSubtractedIofQ, BackgroundSubtractedIofQxy, diff --git a/packages/esssans/src/ess/sans/workflow.py b/packages/esssans/src/ess/sans/workflow.py index 16303599b..a2b501429 100644 --- a/packages/esssans/src/ess/sans/workflow.py +++ b/packages/esssans/src/ess/sans/workflow.py @@ -6,10 +6,10 @@ import sciline import scipp as sc -from ess.reduce.parameter import parameter_mappers from ess.reduce.unwrap import GenericUnwrapWorkflow, WavelengthLutMode from . import common, conversions, i_of_q, masking, normalization +from .parameters import parameters from .types import ( BackgroundRun, CorrectForGravity, @@ -58,13 +58,17 @@ def with_pixel_mask_filenames( masks: List or tuple of pixel mask filenames to set. """ - workflow = workflow.copy() - workflow[DetectorMasks] = ( - workflow[DetectorMasks] - .map(pd.DataFrame({PixelMaskFilename: masks}).rename_axis('mask')) - .reduce(index='mask', func=_merge) - ) - return workflow + masks = tuple(masks) + target = workflow.copy() + if masks: + target[DetectorMasks] = ( + target[DetectorMasks] + .map(pd.DataFrame({PixelMaskFilename: masks}).rename_axis('mask')) + .reduce(index='mask', func=_merge) + ) + else: + target[DetectorMasks] = DetectorMasks({}) + return target def with_banks( @@ -141,13 +145,15 @@ def with_background_runs( return _set_runs(workflow, runs, BackgroundRun, 'background_run') -parameter_mappers[PixelMaskFilename] = with_pixel_mask_filenames -# TODO: for now, we leave the mapping over detector banks out, because we do not have a -# method to merge the I(Q) of different banks, and we thus cannot compute a single -# result from the workflow. So only a single detector bank can be processed at a time. -# parameter_mappers[NeXusDetectorName] = with_banks -parameter_mappers[Filename[SampleRun]] = with_sample_runs -parameter_mappers[Filename[BackgroundRun]] = with_background_runs +parameters[PixelMaskFilename] = parameters[PixelMaskFilename].with_apply( + with_pixel_mask_filenames +) +parameters[Filename[SampleRun]] = parameters[Filename[SampleRun]].with_apply( + with_sample_runs +) +parameters[Filename[BackgroundRun]] = parameters[Filename[BackgroundRun]].with_apply( + with_background_runs +) providers = ( diff --git a/packages/esssans/tests/loki/workflow_test.py b/packages/esssans/tests/loki/workflow_test.py index 0d20b94e1..ff2526d60 100644 --- a/packages/esssans/tests/loki/workflow_test.py +++ b/packages/esssans/tests/loki/workflow_test.py @@ -5,17 +5,18 @@ import pytest import scipp as sc from ess import loki -from ess.loki import LokiAtLarmorWorkflow +from ess.loki import LokiAtLarmorTutorialWorkflow, LokiAtLarmorWorkflow +from ess.sans.parameters import parameters as sans_parameters from ess.sans.types import ( - BackgroundRun, BackgroundSubtractedIofQ, BeamCenter, + DetectorMasks, Filename, - IntensityQ, LookupTableFilename, NeXusDetectorName, PixelMaskFilename, QBins, + QxBins, ReturnEvents, SampleRun, UncertaintyBroadcastMode, @@ -31,30 +32,50 @@ def test_sans_workflow_registers_subclasses(): assert LokiAtLarmorWorkflow in workflow.workflow_registry count = len(workflow.workflow_registry) - @workflow.register_workflow + @workflow.register_workflow() class MyWorkflow: ... assert MyWorkflow in workflow.workflow_registry assert len(workflow.workflow_registry) == count + 1 -def test_loki_larmor_workflow_parameters_returns_filtered_params(): - wf = LokiAtLarmorWorkflow() - parameters = workflow.get_parameters(wf, (IntensityQ[SampleRun],)) - assert Filename[SampleRun] in parameters - assert Filename[BackgroundRun] not in parameters +def test_loki_larmor_workflow_registers_parameter_model(): + spec = workflow.workflow_registry.get(LokiAtLarmorWorkflow) + assert spec.parameters is sans_parameters -def test_loki_larmor_workflow_parameters_returns_no_params_for_no_outputs(): +def test_loki_larmor_workflow_applies_parameter_model(): wf = LokiAtLarmorWorkflow() - parameters = workflow.get_parameters(wf, ()) - assert not parameters + spec = workflow.workflow_registry.get(LokiAtLarmorWorkflow) + params = workflow.get_parameters(wf, (ReturnEvents,), spec.parameters) + wf = workflow.assign_parameter_values(wf, {ReturnEvents: True}, params) + + assert wf.compute(ReturnEvents) -def test_loki_larmor_workflow_parameters_with_param_returns_param(): +def test_loki_larmor_workflow_parameters_are_selected_from_graph(): wf = LokiAtLarmorWorkflow() - parameters = workflow.get_parameters(wf, (ReturnEvents,)) - assert parameters.keys() == {ReturnEvents} + spec = workflow.workflow_registry.get(LokiAtLarmorWorkflow) + + params = workflow.get_parameters(wf, (BackgroundSubtractedIofQ,), spec.parameters) + + assert Filename[SampleRun] in params + assert PixelMaskFilename in params + assert QBins in params + assert QxBins not in params + + +def test_loki_larmor_tutorial_workflow_preserves_outputs_and_masks(): + wf = LokiAtLarmorTutorialWorkflow() + spec = workflow.workflow_registry.get(LokiAtLarmorTutorialWorkflow) + + labels = [ + label for label, _ in workflow.get_typical_outputs(wf, spec.typical_outputs) + ] + masks = wf.compute(DetectorMasks) + + assert 'IntensityQ[SampleRun]' in labels + assert tuple(masks) == tuple(map(str, loki.data.loki_tutorial_mask_filenames())) def test_loki_larmor_workflow_compute_with_single_pixel_mask(larmor_workflow):