From 0b4bf7af807b34bef4c37203c85c2e0ce345b0ab Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 26 Jun 2026 15:12:12 +0200 Subject: [PATCH 1/4] refactor: use parameter models from esslivedata --- .../dream/workflow-widget-dream.ipynb | 28 +- .../essdiffraction/src/ess/beer/parameters.py | 121 ++++ .../essdiffraction/src/ess/beer/workflow.py | 76 +- .../src/ess/dream/parameters.py | 119 +++- .../essdiffraction/src/ess/dream/workflows.py | 18 +- .../tests/dream/geant4_reduction_test.py | 27 +- packages/essreduce/pyproject.toml | 1 + .../essreduce/src/ess/reduce/parameter.py | 189 ++--- .../src/ess/reduce/parameter_models.py | 283 ++++++++ packages/essreduce/src/ess/reduce/ui.py | 238 ++++--- .../src/ess/reduce/widgets/__init__.py | 184 +---- .../ess/reduce/widgets/_binedges_widget.py | 74 -- .../src/ess/reduce/widgets/_bounds_widget.py | 35 - .../ess/reduce/widgets/_filename_widget.py | 10 - .../ess/reduce/widgets/_linspace_widget.py | 37 - .../ess/reduce/widgets/_optional_widget.py | 90 --- .../src/ess/reduce/widgets/_pydantic.py | 476 +++++++++++++ .../src/ess/reduce/widgets/_string_widget.py | 48 -- .../ess/reduce/widgets/_switchable_widget.py | 66 -- .../src/ess/reduce/widgets/_vector_widget.py | 38 - packages/essreduce/src/ess/reduce/workflow.py | 164 ++++- .../essreduce/tests/parameter_models_test.py | 22 + packages/essreduce/tests/widget_test.py | 659 +++++------------- .../loki/workflow-widget-loki.ipynb | 11 +- .../esssans/src/ess/loki/larmor_workflow.py | 3 +- packages/esssans/src/ess/sans/parameters.py | 241 +++++-- packages/esssans/src/ess/sans/workflow.py | 56 +- packages/esssans/tests/loki/workflow_test.py | 42 +- 28 files changed, 1846 insertions(+), 1510 deletions(-) create mode 100644 packages/essdiffraction/src/ess/beer/parameters.py create mode 100644 packages/essreduce/src/ess/reduce/parameter_models.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_binedges_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_bounds_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_filename_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_linspace_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_optional_widget.py create mode 100644 packages/essreduce/src/ess/reduce/widgets/_pydantic.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_string_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_switchable_widget.py delete mode 100644 packages/essreduce/src/ess/reduce/widgets/_vector_widget.py create mode 100644 packages/essreduce/tests/parameter_models_test.py 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..1767af564 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,20 @@ "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", + "wfw.parameter_box.parameter_refresh_button.click()\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()" diff --git a/packages/essdiffraction/src/ess/beer/parameters.py b/packages/essdiffraction/src/ess/beer/parameters.py new file mode 100644 index 000000000..abd08dcf4 --- /dev/null +++ b/packages/essdiffraction/src/ess/beer/parameters.py @@ -0,0 +1,121 @@ +# 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, + MaskedDetectorIDs, + 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', + default=None, +) +parameters[Filename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium Run', + default=None, +) +parameters[Filename[EmptyCanRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Empty Can Run', + default=None, +) +parameters[CalibrationFilename] = ParameterSpec( + model=str | None, + category='Files', + title='Calibration', + default=None, +) +parameters[MonitorFilename[SampleRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Monitor', + default=None, +) +parameters[PixelMaskFilename] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Pixel Masks', + default=(), + apply=with_pixel_mask_filenames, + filter_keys=(MaskedDetectorIDs, PixelMaskFilename), +) + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', + default='detector', +) + +parameters[DspacingBins] = ParameterSpec( + model=DspacingEdges, + category='Binning', + title='D-spacing Edges', + 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', + 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', + default=UncertaintyBroadcastMode.upper_bound, +) +parameters[DetectorBank] = ParameterSpec( + model=DetectorBank, + category='Reduction', + title='Detector Bank', + 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..34eefdf15 100644 --- a/packages/essdiffraction/src/ess/beer/workflow.py +++ b/packages/essdiffraction/src/ess/beer/workflow.py @@ -12,41 +12,67 @@ CalibrationData, CaveMonitor, EmptyCanRun, + Filename, + KeepEvents, + MaskedDetectorIDs, + 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, + MaskedDetectorIDs: MaskedDetectorIDs({}), + 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 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 +82,16 @@ def BeerModMcStasWorkflow(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters + return wf +@register_workflow 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 +100,25 @@ def BeerModMcStasWorkflowKnownPeaks(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters + return wf +@register_workflow 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,)}, ) + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters + return wf +@register_workflow def BeerMcStasWorkflowPulseShapingAnalytical(): """Workflow to process BEER pulse-shaping McStas files using analytical frame unwrapping.""" @@ -96,11 +134,16 @@ def BeerMcStasWorkflowPulseShapingAnalytical(): wf[key] = value wf[NeXusName[snx.NXdetector]] = 'detector' wf[LookupTableRelativeErrorThreshold] = {'detector': float('inf')} + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters return wf +@register_workflow def BeerPowderWorkflow( - *, run_norm: RunNormalization = RunNormalization.monitor_integrated, **kwargs + *, + run_norm: RunNormalization = RunNormalization.monitor_integrated, + **kwargs, ) -> sl.Pipeline: """ Beer powder workflow with default parameters. @@ -131,11 +174,16 @@ def BeerPowderWorkflow( insert_run_normalization(wf, run_norm) for key, value in default_parameters.items(): wf[key] = value + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters return wf +@register_workflow 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. @@ -173,22 +221,26 @@ def BeerPowderWorkflowAnalytical( 'monitor_bunker': float('inf'), 'monitor_cave': float('inf'), } + wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters return wf +@register_workflow 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) - + wf.parameter_registry = parameters return wf +@register_workflow 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) - + wf.parameter_registry = parameters return wf diff --git a/packages/essdiffraction/src/ess/dream/parameters.py b/packages/essdiffraction/src/ess/dream/parameters.py index f82c8906e..62bb7eb77 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, @@ -11,63 +14,111 @@ Filename, IntensityDspacing, IntensityDspacingTwoTheta, + MaskedDetectorIDs, MonitorFilename, NeXusDetectorName, 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', + default=None, ) -parameter_registry[Filename[VanadiumRun]] = FilenameParameter.from_type( - Filename[VanadiumRun] +parameters[Filename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium 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', + default=None, ) -parameter_registry[CalibrationFilename] = FilenameParameter.from_type( - CalibrationFilename +parameters[CalibrationFilename] = ParameterSpec( + model=str | None, + category='Files', + title='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', + default=None, ) -parameter_registry[MonitorFilename[SampleRun]] = FilenameParameter.from_type( - MonitorFilename[SampleRun] +parameters[MonitorFilename[VanadiumRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Vanadium Monitor', + default=None, ) -parameter_registry[MonitorFilename[VanadiumRun]] = FilenameParameter.from_type( - MonitorFilename[VanadiumRun] +parameters[PixelMaskFilename] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Pixel Masks', + default=(), + apply=with_pixel_mask_filenames, + filter_keys=(MaskedDetectorIDs, PixelMaskFilename), ) -parameter_registry[PixelMaskFilename] = MultiFilenameParameter.from_type( - PixelMaskFilename, + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', + default='mantle', ) -parameter_registry[NeXusDetectorName] = StringParameter.from_type( - NeXusDetectorName, default="mantle" + +parameters[DspacingBins] = ParameterSpec( + model=DspacingEdges, + category='Binning', + title='D-spacing Edges', + 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', + 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', + 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', + 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..b735c90d8 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": { @@ -140,6 +138,7 @@ def DreamWorkflow( "monitor_bunker": float('inf'), "monitor_cave": float('inf'), } + wf.parameter_registry = parameters return wf @@ -148,8 +147,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 +157,7 @@ def default_parameters() -> dict: TofMask: None, WavelengthMask: None, TwoThetaMask: None, + NeXusDetectorName: "mantle", CIFAuthors: CIFAuthors([]), } @@ -187,10 +185,15 @@ def DreamPowderWorkflow(*, run_norm: RunNormalization, **kwargs) -> sciline.Pipe for key, value in default_parameters().items(): wf[key] = value wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters 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. @@ -246,6 +249,7 @@ def DreamGeant4Workflow(*, run_norm: RunNormalization, **kwargs) -> sciline.Pipe wf[key] = value wf.typical_outputs = typical_outputs + wf.parameter_registry = parameters return wf diff --git a/packages/essdiffraction/tests/dream/geant4_reduction_test.py b/packages/essdiffraction/tests/dream/geant4_reduction_test.py index 31bc513c1..f6116f27e 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"]) @@ -344,8 +347,24 @@ class MyWorkflow: ... assert len(reduce_workflow.workflow_registry) == count + 1 -def test_dream_workflow_parameters_returns_filtered_params(): +def test_dream_workflow_registers_parameter_registry(): wf = DreamGeant4ProtonChargeWorkflow() - parameters = reduce_workflow.get_parameters(wf, (CorrectedDetector[SampleRun],)) - assert Filename[SampleRun] in parameters - assert Filename[EmptyCanRun] not in parameters + assert wf.parameter_registry is dream_parameters + + +def test_dream_workflow_applies_parameter_values(): + wf = DreamGeant4ProtonChargeWorkflow() + specs = reduce_workflow.get_parameters(wf, (IntensityDspacingTwoTheta[SampleRun],)) + 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/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..83d1e6a81 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,53 @@ class KeepDefaultType: keep_default = KeepDefaultType() -@dataclass -class Parameter(Generic[T]): - """Interface between workflow parameters and widgets. - - .. versionadded:: RELEASE_PLACEHOLDER - """ - - 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 with_default(self: Self, default: T | KeepDefaultType = keep_default) -> Self: - if default == keep_default: +@dataclass(frozen=True) +class ParameterSpec(Generic[T]): + """Specification for configuring a Sciline workflow key.""" + + 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 + filter_keys: tuple[Key, ...] = () + use_workflow_default: bool = True + key: Key | None = None + + def bind(self, key: Key) -> ParameterSpec[T]: + filter_keys = self.filter_keys or (key,) + return replace(self, key=key, filter_keys=filter_keys) + + 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) -@dataclass(kw_only=True) -class ParamWithOptions(Parameter[T]): - options: Enum + def with_apply( + self, apply: Callable[[Pipeline, T], Pipeline] | None + ) -> ParameterSpec[T]: + return replace(self, apply=apply) - @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 +79,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..385470179 100644 --- a/packages/essreduce/src/ess/reduce/ui.py +++ b/packages/essreduce/src/ess/reduce/ui.py @@ -7,13 +7,20 @@ 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, + default_layout, +) from .workflow import ( - Key, + RegisteredWorkflow, + WorkflowFactory, assign_parameter_values, + create_workflow, get_parameters, get_possible_outputs, get_typical_outputs, @@ -41,12 +48,10 @@ def __init__(self, workflow: sl.Pipeline, **kwargs): options=get_possible_outputs(workflow), layout=Layout(width='90%', 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' ) - _typical_selection.selected_index = 0 # Open typical outputs by default + _typical_selection.selected_index = 0 _possible_selection = _wrap_foldable( self.possible_outputs_widget, title='Extended Outputs' ) @@ -62,7 +67,7 @@ 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( @@ -71,37 +76,81 @@ def __init__( 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._input_box.children = [ - widgets.HBox([widget]) for widget in self._input_widgets.values() - ] - - self.parameter_refresh_button.on_click(_refresh_input_box) - + self._registry_getter = registry_getter + self._input_registry: dict[Key, ParameterSpec] = {} + self._input_widgets: dict[Key, PydanticParameterValueWidget] = {} + self._input_box = widgets.Box() + self.parameter_refresh_button.on_click(self._refresh_input_box) super().__init__([self.parameter_refresh_button, self._input_box], **kwargs) + def _refresh_input_box(self, _: widgets.Button | None = None) -> None: + existing_values = self.get_values() + new_input_parameters = self._registry_getter() + self._input_registry.clear() + self._input_registry.update(new_input_parameters) + self._input_widgets.clear() + + grouped: dict[str, list[widgets.Widget]] = {} + for key, spec in new_input_parameters.items(): + widget = _create_parameter_widget(spec) + if key in existing_values: + widget.set_value(existing_values[key]) + self._input_widgets[key] = widget + grouped.setdefault(spec.category, []).append(widget) + + if not grouped: + self._input_box.children = [widgets.HTML("No parameters")] + return + + categories = list(grouped) + accordion = widgets.Accordion( + [widgets.VBox(grouped[category]) for category in categories] + ) + for index, category in enumerate(categories): + accordion.set_title(index, category) + accordion.selected_index = 0 + self._input_box.children = [accordion] + @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 +177,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() @@ -159,7 +213,6 @@ def connect_output_selection_and_parameter_run_button( 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.' @@ -167,33 +220,41 @@ def observe_selection_change(_) -> None: 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: + def observe_parameter_refreshed(_) -> None: run_button.disabled = False run_button.tooltip = original_run_button_tooltip - parameter_refresh_button.on_click(observe_parameter_refrheshed) + parameter_refresh_button.on_click(observe_parameter_refreshed) class WorkflowWidget(widgets.TwoByTwoLayout): def __init__( - self, workflow: sl.Pipeline, result_registry: dict | None = None, **kwargs + self, + workflow: sl.Pipeline | WorkflowFactory | RegisteredWorkflow, + result_registry: dict | None = None, + **kwargs, ): - self.output_selection_box = OutputSelectionWidget(workflow) + self.workflow = workflow + self._pipeline = self._create_pipeline() + self.output_selection_box = OutputSelectionWidget(self._pipeline) - 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.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( @@ -217,11 +278,19 @@ def workflow_runner() -> dict[type, Any]: **kwargs, ) + def _create_pipeline(self) -> sl.Pipeline: + if isinstance(self.workflow, sl.Pipeline): + return self.workflow.copy() + return create_workflow(self.workflow) + 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.spec.title or workflow.spec.name, workflow) + for workflow in workflow_registry + ], description='Workflow:', value=None, layout=default_layout, @@ -229,7 +298,7 @@ def workflow_widget(result_registry: dict | None = None) -> widgets.Widget: ) 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') @@ -251,67 +320,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..d8be6d379 100644 --- a/packages/essreduce/src/ess/reduce/widgets/__init__.py +++ b/packages/essreduce/src/ess/reduce/widgets/__init__.py @@ -1,191 +1,29 @@ # 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 ._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 ._pydantic import ( + PydanticModelWidget, + PydanticParameterValueWidget, + PydanticParameterWidget, +) 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', ] 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/_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..bcfd806b9 --- /dev/null +++ b/packages/essreduce/src/ess/reduce/widgets/_pydantic.py @@ -0,0 +1,476 @@ +# 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 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_layout, default_style + + +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 {}) + + +class PydanticFieldWidget(ipw.VBox, ipw.ValueWidget): + """Labelled wrapper for nested Pydantic model fields.""" + + def __init__(self, title: str, widget: PydanticParameterWidget): + self.widget = widget + super().__init__([ipw.HTML(f"{title}"), widget]) + + @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) + 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) + super().__init__([self._widget]) + + @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]: + if self._returns_model: + value = self.value + return ( + value.model_dump() if isinstance(value, pydantic.BaseModel) else value + ) + return {'value': self.value} + + 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, + ): + 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) + description = field_info.description or field_name + display_name = field_info.title or snake_to_camel(field_name) + disabled = bool(field_info.frozen) + common = { + 'description': display_name, + 'disabled': disabled, + 'tooltip': description, + 'layout': default_layout, + 'style': default_style, + } + + if field_type is float: + return ipw.FloatText(value=default_value or 0.0, **common) + if field_type is int: + return ipw.IntText(value=default_value or 0, **common) + if field_type is bool: + return ipw.Checkbox(value=default_value or False, **common) + if field_type in (Path, str): + value = '' if default_value is None else str(default_value) + return ipw.Text(value=value, placeholder=description, **common) + 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 ipw.Dropdown(options=options, value=value, **common) + if _is_set_of_enum(field_type): + (enum_type,) = get_args(field_type) + options = _enum_options(enum_type) + value = tuple(default_value or ()) + return ipw.SelectMultiple( + options=options, + value=value, + rows=min(len(options), 10), + **common, + ) + 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 ipw.Dropdown(options=options, value=value, **common) + if _is_string_sequence(field_type): + value = _sequence_to_text(default_value) + return ipw.Text(value=value, placeholder=description, **common) + if _is_pydantic_model(field_type): + return PydanticFieldWidget( + display_name, + PydanticParameterWidget( + field_type, initial_values=_as_dict(default_value) + ), + ) + + value = '' if default_value is None and optional else str(default_value or '') + return ipw.Text(value=value, placeholder=description, **common) + + @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 + ) + 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: + 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() + titles.append(title) + children.append(widget) + accordion = ipw.Accordion(children=children) + 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..a2ab59d11 100644 --- a/packages/essreduce/src/ess/reduce/workflow.py +++ b/packages/essreduce/src/ess/reduce/workflow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, MutableSet, Sequence +from dataclasses import dataclass from typing import Any, TypeVar import networkx as nx @@ -10,17 +11,62 @@ from sciline._utils import key_name from sciline.typing import Key -from .parameter import Parameter, keep_default, parameter_mappers, parameter_registry +from .parameter import ParameterSpec, keep_default, parameter_registry T = TypeVar("T") +WorkflowFactory = Callable[..., Pipeline] + + +@dataclass(frozen=True) +class WorkflowSpec: + """Metadata needed to generate interfaces for a workflow.""" + + name: str + title: str | None = None + description: str | None = None + version: str | None = None + + @classmethod + def from_factory( + cls, + factory: WorkflowFactory, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + version: str | None = None, + ) -> WorkflowSpec: + return cls( + name=name or factory.__name__, + title=title, + description=description, + version=version, + ) + + +@dataclass(frozen=True) +class RegisteredWorkflow: + """A workflow factory and the interface specification attached to it.""" + + factory: WorkflowFactory + spec: WorkflowSpec + + def __call__(self, *args: Any, **kwargs: Any) -> Pipeline: + return self.factory(*args, **kwargs) + + @property + def __name__(self) -> str: + return self.factory.__name__ class WorkflowRegistry(MutableSet): def __init__(self): - self._workflows: dict[str, type] = {} + self._workflows: dict[str, RegisteredWorkflow] = {} def __contains__(self, item: object) -> bool: - return item in self._workflows.values() + return item in self._workflows.values() or any( + item is workflow.factory for workflow in self._workflows.values() + ) def __iter__(self): return iter(self._workflows.values()) @@ -28,20 +74,89 @@ def __iter__(self): def __len__(self) -> int: return len(self._workflows) - def add(self, value: type) -> None: + def add( + self, + value: WorkflowFactory | RegisteredWorkflow, + spec: WorkflowSpec | None = None, + ) -> None: + workflow = ( + value + if isinstance(value, RegisteredWorkflow) + else _make_registered_workflow(value, spec) + ) + key = workflow.factory.__qualname__ + self._workflows[key] = workflow + + def discard(self, value: WorkflowFactory | RegisteredWorkflow) -> 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 | RegisteredWorkflow) -> RegisteredWorkflow: + if isinstance(value, RegisteredWorkflow): + 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 _make_registered_workflow( + factory: WorkflowFactory, spec: WorkflowSpec | None = None +) -> RegisteredWorkflow: + spec = spec or WorkflowSpec.from_factory(factory) + return RegisteredWorkflow(factory=factory, spec=spec) + + +def register_workflow( + cls: WorkflowFactory | WorkflowSpec | None = None, + /, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + version: str | None = None, +) -> WorkflowFactory | Callable[[WorkflowFactory], WorkflowFactory]: + """Register a workflow factory for generated user interfaces.""" + + def decorator(factory: WorkflowFactory) -> WorkflowFactory: + spec = ( + cls + if isinstance(cls, WorkflowSpec) + else WorkflowSpec.from_factory( + factory, + name=name, + title=title, + description=description, + version=version, + ) + ) + workflow_registry.add(factory, spec=spec) + return factory + + if isinstance(cls, WorkflowSpec) or cls is None: + return decorator + if any(value is not None for value in (name, title, description, version)): + raise TypeError( + "Use register_workflow(...) as a decorator when passing workflow metadata." + ) + return decorator(cls) + + +def create_workflow(workflow: WorkflowFactory | RegisteredWorkflow) -> Pipeline: + """Create a pipeline from a registered workflow.""" + registered = ( + workflow_registry.get(workflow) + if not isinstance(workflow, RegisteredWorkflow) + else workflow + ) + return registered.factory() def _get_defaults_from_workflow(workflow: Pipeline) -> dict[Key, Any]: @@ -70,26 +185,39 @@ def _with_pretty_names(outputs: Sequence[Key]) -> tuple[tuple[str, Key], ...]: def get_parameters( pipeline: Pipeline, outputs: tuple[Key, ...] -) -> dict[Key, Parameter]: +) -> 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)) defaults = _get_defaults_from_workflow(pipeline) + parameters = _get_parameter_registry(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 any(filter_key in subgraph for filter_key in spec.filter_keys) } -def assign_parameter_values(pipeline: Pipeline, values: dict[Key, Any]) -> Pipeline: +def assign_parameter_values( + pipeline: Pipeline, + values: dict[Key, Any], + parameters: dict[Key, ParameterSpec] | None = None, +) -> Pipeline: """Set a value for a parameter in the pipeline.""" pipeline = pipeline.copy() + parameters = parameters or _get_parameter_registry(pipeline) 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 + + +def _get_parameter_registry(pipeline: Pipeline): + return getattr(pipeline, 'parameter_registry', parameter_registry) 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..bde483b53 100644 --- a/packages/essreduce/tests/widget_test.py +++ b/packages/essreduce/tests/widget_test.py @@ -2,293 +2,176 @@ # 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 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, PydanticParameterWidget +from ess.reduce.workflow import ( + 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 Mode(Enum): + fast = 'fast' + slow = 'slow' -class IntParam(Parameter): ... +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") -class FloatParam(Parameter): ... +class GroupParams(BaseModel): + value: int = 2 -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) +class NestedParams(BaseModel): + group: GroupParams = Field(default_factory=GroupParams, title="Group") -@create_parameter_widget.register(IntParam) -def _(param: IntParam) -> IntText: - return IntText(value=param.default, description=param.name) +Count = NewType("Count", int) +Label = NewType("Label", str) +Unused = NewType("Unused", int) -@create_parameter_widget.register(FloatParam) -def _(param: FloatParam) -> FloatText: - return FloatText(value=param.default, description=param.name) +def test_pydantic_parameter_widget_creates_model_from_widget_values() -> None: + widget = PydanticParameterWidget(FlatParams) -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() + 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 _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, + assert params == FlatParams( + count=3, + scale=0.5, + enabled=False, + path=Path('data.nxs'), + mode=Mode.slow, + choice='b', + names=('a', 'b'), ) - _refresh_widget_parameter(widget=widget, output_selections=output_selections) - return widget - - -def strict_provider(a: int, b: float) -> str: - return f"{a} + {b}" - - -def provider_with_switch(a: SwitchableInt, b: SwitchableFloat) -> str: - return f"{a} + {b}" - -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)]) +def test_pydantic_parameter_widget_reports_validation_errors() -> None: + class Params(BaseModel): + value: int = Field(default=1, ge=0) -def _get_param_widget(widget: WorkflowWidget, param_type: type) -> Any: - return widget.parameter_box._input_widgets[param_type] + widget = PydanticParameterWidget(Params) + widget.widgets['value'].value = -1 + is_valid, errors = widget.validate() -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 + assert not is_valid + assert 'value' in errors -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) +def test_pydantic_parameter_widget_set_values_accepts_none_for_optional_text() -> None: + class Params(BaseModel): + value: str | None = None + widget = PydanticParameterWidget(Params, initial_values=Params()) -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_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'} - + assert widget.widgets['value'].value == '' + assert widget.create_model() == Params(value=None) -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) +def test_pydantic_parameter_widget_supports_nested_model_fields() -> None: + class Params(BaseModel): + group: GroupParams = Field(default_factory=GroupParams) -def test_switchable_parameter_switch_widget() -> None: - widget = _ready_widget(providers=[provider_with_switch], output_selections=[str]) + widget = PydanticParameterWidget(Params, initial_values=Params()) - int_widget = _get_param_widget(widget, SwitchableInt) - float_widget = _get_param_widget(widget, SwitchableFloat) + widget.widgets['group'].widget.widgets['value'].value = 5 - assert isinstance(int_widget, SwitchWidget) - assert isinstance(float_widget, SwitchWidget) + assert widget.create_model() == Params(group=GroupParams(value=5)) - assert not float_widget.enabled - assert not int_widget.enabled +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 -def test_collect_values_from_disabled_switchable_widget() -> None: - widget = _ready_widget(providers=[provider_with_switch], output_selections=[str]) + assert widget.parameter_values == NestedParams(group=GroupParams(value=4)) - assert not _get_param_widget(widget, SwitchableFloat).enabled - assert not _get_param_widget(widget, SwitchableInt).enabled - assert widget.parameter_box.value == {} +def provider(count: Count, label: Label) -> str: + return f"{count}:{label}" -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 - - assert widget.parameter_box.value == {SwitchableFloat: 0.2} - - -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_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} - - -def test_optional_widget_set_fields_get_fields() -> None: - optional_param = Vector3dParameter( - 'a', 'a', sc.vector([1, 2, 3], unit='m'), optional=True +def make_workflow() -> sl.Pipeline: + parameters = ParameterRegistry() + parameters[Count] = ParameterSpec( + model=int, category='General', title='Count', default=1, transform=Count ) - 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'} + parameters[Label] = ParameterSpec( + model=str, + category='General', + title='Label', + default='fallback', + transform=Label, ) - 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_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) - - -def test_optional_parameter_optional_widget() -> None: - widget = _ready_widget(providers=[provider_with_optional], output_selections=[str]) - - int_widget = _get_param_widget(widget, OptionalInt) - float_widget = _get_param_widget(widget, OptionalFloat) - - assert isinstance(int_widget, OptionalWidget) - assert isinstance(float_widget, OptionalWidget) - - assert float_widget.value is None - assert int_widget.value is None - - -def test_collect_values_from_optional_widget() -> None: - widget = _ready_widget(providers=[provider_with_optional], output_selections=[str]) - - float_widget = _get_param_widget(widget, OptionalFloat) - float_widget.value = 0.2 - - assert widget.parameter_box.value == {OptionalFloat: 0.2, OptionalInt: None} - - -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, + parameters[Unused] = ParameterSpec( + model=int, category='General', title='Unused', default=2, transform=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'} - - 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 dummy_workflow_constructor() -> sl.Pipeline: - return sl.Pipeline([strict_provider]) + pipeline = sl.Pipeline([provider]) + pipeline[Label] = Label('a') + pipeline.typical_outputs = (str,) + pipeline.parameter_registry = parameters + 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(title="Example")(constructor) + yield + finally: + for constructor, existed in existence_flags.items(): + if not existed: + workflow_registry.discard(constructor) -def test_register_workflow() -> None: - with temporary_workflow_registry(dummy_workflow_constructor): - assert dummy_workflow_constructor in workflow_registry +def test_register_workflow_with_metadata() -> None: + with temporary_workflow_registry(make_workflow): + record = workflow_registry.get(make_workflow) + assert record.factory is make_workflow + assert record.spec.title == "Example" - assert dummy_workflow_constructor not in workflow_registry + with pytest.raises(KeyError): + workflow_registry.get(make_workflow) def _get_selection_widget(widget): @@ -296,296 +179,68 @@ def _get_selection_widget(widget): 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): + record = 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", record) 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): + record = 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 = record + 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') -) - - -def wavelength_bins_print_provider(bins: WavelengthBins) -> str: - return str(bins) - - -def wavelength_bins_with_unit_print_provider(bins: WavelengthBinsWithUnit) -> str: - return str(bins) - - -def q_bins_print_provider(bins: QBins) -> str: - return str(bins) - - -def temperature_bins_print_provider(bins: TemperatureBinsWithUnits) -> str: - return str(bins) - - -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'), - ) - - -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 - - -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_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' - + assert selected_widget._pipeline.parameter_registry is not None -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_parameters_filters_by_selected_output_and_uses_workflow_defaults() -> None: + params = get_parameters(make_workflow(), (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" -) + assert set(params) == {Count, Label} + assert params[Count].default == 1 + assert params[Label].default == 'a' -def filename_print_provider(fname: FilenameSampleRun) -> str: - return str(fname) +def test_assign_parameter_values_uses_key_specs() -> None: + pipeline = make_workflow() + params = get_parameters(pipeline, (str,)) + updated = assign_parameter_values(pipeline, {Count: 5, Label: 'x'}, params) -def multi_filename_print_provider(fnames: MultiFilenameSampleRun) -> list[str]: - return [str(fname) for fname in fnames] + assert updated.compute(str) == '5:x' -def multi_filename_print_provider_bis(fnames: MultiFilenameBackgroundRun) -> list[str]: - return [str(fname) for fname in fnames] +def test_workflow_widget_runs_with_keyed_parameter_values() -> None: + registry = {} + with temporary_workflow_registry(make_workflow): + record = workflow_registry.get(make_workflow) + widget = WorkflowWidget(record, result_registry=registry) + widget.output_selection_box.typical_outputs_widget.value = (str,) + widget.parameter_box.parameter_refresh_button.click() + set_parameter_widget_values(widget, {Count: 5, Label: 'x'}) + widget.result_box.run_button.click() -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" + assert registry == {str: '5:x'} -def test_filename_widget_multiple() -> None: - widget = _ready_widget( - providers=[multi_filename_print_provider], output_selections=[list[str]] - ) - param_widget = _get_param_widget(widget, MultiFilenameSampleRun) - assert param_widget.value == ("file1.hdf", "file2.hdf") +def test_get_parameter_widget_values_returns_current_keyed_fields() -> None: + with temporary_workflow_registry(make_workflow): + record = workflow_registry.get(make_workflow) + widget = WorkflowWidget(record) + widget.output_selection_box.typical_outputs_widget.value = (str,) + widget.parameter_box.parameter_refresh_button.click() + set_parameter_widget_values(widget, {Count: 7}) -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",) + assert get_parameter_widget_values(widget)[Count] == {'value': 7} 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..3b93b6a54 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,9 @@ "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", + "wfw.parameter_box.parameter_refresh_button.click()\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/loki/larmor_workflow.py b/packages/esssans/src/ess/loki/larmor_workflow.py index 664feee8f..0d6567dd0 100644 --- a/packages/esssans/src/ess/loki/larmor_workflow.py +++ b/packages/esssans/src/ess/loki/larmor_workflow.py @@ -176,7 +176,8 @@ def LokiAtLarmorTutorialWorkflow() -> sciline.Pipeline: 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/sans/parameters.py b/packages/esssans/src/ess/sans/parameters.py index 561e88d57..c09f26dbd 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,207 @@ 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', ) -parameter_registry[NeXusMonitorName[Transmission]] = StringParameter.from_type( - NeXusMonitorName[Transmission], default='' +parameters[Filename[BackgroundRun]] = ParameterSpec( + model=tuple[str, ...], + category='Files', + title='Background Runs', ) -parameter_registry[TransformationPath] = StringParameter.from_type( - TransformationPath, default='' +parameters[Filename[TransmissionRun[SampleRun]]] = ParameterSpec( + model=str | None, + category='Files', + title='Sample Transmission', + default=None, ) -parameter_registry[PixelMaskFilename] = MultiFilenameParameter.from_type( - PixelMaskFilename +parameters[Filename[TransmissionRun[BackgroundRun]]] = ParameterSpec( + model=str | None, + category='Files', + title='Background Transmission', + default=None, ) -parameter_registry[PixelShapePath] = StringParameter.from_type( - PixelShapePath, default='' +parameters[Filename[EmptyBeamRun]] = ParameterSpec( + model=str | None, + category='Files', + title='Empty Beam', + 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', ) -parameter_registry[UncertaintyBroadcastMode] = ParamWithOptions.from_enum( - UncertaintyBroadcastMode, default=UncertaintyBroadcastMode.upper_bound +parameters[DirectBeamFilename] = ParameterSpec( + model=str | None, + category='Files', + title='Direct Beam', + default=None, + apply=_direct_beam_filename, + filter_keys=(DirectBeam, DirectBeamFilename), ) -parameter_registry[Filename[SampleRun]] = MultiFilenameParameter.from_type( - Filename[SampleRun] + +parameters[NeXusDetectorName] = ParameterSpec( + model=str, + category='NeXus', + title='Detector', ) -parameter_registry[Filename[BackgroundRun]] = MultiFilenameParameter.from_type( - Filename[BackgroundRun] +parameters[NeXusMonitorName[Incident]] = ParameterSpec( + model=str, + category='NeXus', + title='Incident Monitor', + default='', ) -parameter_registry[Filename[TransmissionRun[SampleRun]]] = FilenameParameter.from_type( - Filename[TransmissionRun[SampleRun]] +parameters[NeXusMonitorName[Transmission]] = ParameterSpec( + model=str, + category='NeXus', + title='Transmission Monitor', + default='', ) -parameter_registry[Filename[TransmissionRun[BackgroundRun]]] = ( - FilenameParameter.from_type(Filename[TransmissionRun[BackgroundRun]]) +parameters[TransformationPath] = ParameterSpec( + model=str, + category='NeXus', + title='Transform Path', + default='', ) -parameter_registry[Filename[EmptyBeamRun]] = FilenameParameter.from_type( - Filename[EmptyBeamRun] +parameters[PixelShapePath] = ParameterSpec( + model=str, + category='NeXus', + title='Pixel Shape Path', + 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', + 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', + 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', + 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', + 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', + default=False, + transform=CorrectForGravity, +) +parameters[ReturnEvents] = ParameterSpec( + model=bool, + category='Reduction', + title='Return Events', + default=False, + transform=ReturnEvents, ) -parameter_registry[DirectBeamFilename] = FilenameParameter.from_type( - DirectBeamFilename, switchable=True +parameters[UncertaintyBroadcastMode] = ParameterSpec( + model=UncertaintyBroadcastMode, + category='Reduction', + title='Uncertainty Broadcast', + 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', + 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..a05763809 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, @@ -38,6 +38,14 @@ def _merge(*dicts: dict) -> dict: return {key: value for d in dicts for key, value in d.items()} +def _preserve_typical_outputs( + source: sciline.Pipeline, target: sciline.Pipeline +) -> sciline.Pipeline: + if (typical_outputs := getattr(source, 'typical_outputs', None)) is not None: + target.typical_outputs = typical_outputs + return target + + def merge_contributions(*data: sc.DataArray) -> sc.DataArray: if len(data) == 1: return data[0] @@ -58,13 +66,18 @@ 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 + source = 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 _preserve_typical_outputs(source, target) def with_banks( @@ -89,14 +102,18 @@ def with_banks( Index to use for the DataFrame. If not provided, the bank names are used. """ index = index or banks - return workflow.map( - pd.DataFrame({NeXusDetectorName: banks}, index=index).rename_axis('bank') + return _preserve_typical_outputs( + workflow, + workflow.map( + pd.DataFrame({NeXusDetectorName: banks}, index=index).rename_axis('bank') + ), ) def _set_runs( pipeline: sciline.Pipeline, runs: Iterable[str], key: Hashable, axis_name: str ) -> sciline.Pipeline: + source = pipeline pipeline = pipeline.copy() runs = pd.DataFrame({Filename[key]: runs}).rename_axis(axis_name) for part in (Numerator, Denominator): @@ -106,7 +123,7 @@ def _set_runs( .map(runs) .reduce(index=axis_name, func=merge_contributions) ) - return pipeline + return _preserve_typical_outputs(source, pipeline) def with_sample_runs( @@ -141,13 +158,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 = ( @@ -204,4 +223,5 @@ def SansWorkflow( workflow[TransformationPath] = TransformationPath('transform') workflow[WavelengthBands] = WavelengthBands(None) workflow[WavelengthMask] = WavelengthMask(None) + workflow.parameter_registry = parameters return workflow diff --git a/packages/esssans/tests/loki/workflow_test.py b/packages/esssans/tests/loki/workflow_test.py index 0d20b94e1..070421143 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, @@ -38,23 +39,38 @@ class MyWorkflow: ... assert len(workflow.workflow_registry) == count + 1 -def test_loki_larmor_workflow_parameters_returns_filtered_params(): +def test_loki_larmor_workflow_registers_parameter_model(): wf = LokiAtLarmorWorkflow() - parameters = workflow.get_parameters(wf, (IntensityQ[SampleRun],)) - assert Filename[SampleRun] in parameters - assert Filename[BackgroundRun] not in parameters + assert wf.parameter_registry 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 + params = workflow.get_parameters(wf, (ReturnEvents,)) + 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} + + params = workflow.get_parameters(wf, (BackgroundSubtractedIofQ,)) + + 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() + + labels = [label for label, _ in workflow.get_typical_outputs(wf)] + 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): From a7bf4a6703c7252cf2aefe77b499e64df938a969 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 29 Jun 2026 14:20:18 +0200 Subject: [PATCH 2/4] refactor: make WorkflowSpec own parameter registry and workflow factory --- .../essdiffraction/src/ess/beer/parameters.py | 13 +- .../essdiffraction/src/ess/beer/workflow.py | 32 +--- .../src/ess/dream/parameters.py | 14 +- .../essdiffraction/src/ess/dream/workflows.py | 11 +- .../tests/beer/mcstas_reduction_test.py | 17 ++ .../tests/dream/geant4_reduction_test.py | 11 +- packages/essnmx/src/ess/nmx/workflows.py | 2 +- .../essreduce/src/ess/reduce/parameter.py | 4 +- packages/essreduce/src/ess/reduce/workflow.py | 152 ++++++++---------- .../src/ess/estia/workflow.py | 16 +- .../src/ess/freia/workflow.py | 16 +- packages/esssans/src/ess/isissans/sans2d.py | 7 +- packages/esssans/src/ess/isissans/zoom.py | 7 +- .../esssans/src/ess/loki/larmor_workflow.py | 7 +- packages/esssans/src/ess/loki/workflow.py | 5 +- packages/esssans/src/ess/sans/parameters.py | 22 ++- packages/esssans/src/ess/sans/workflow.py | 22 +-- packages/esssans/tests/loki/workflow_test.py | 17 +- 18 files changed, 189 insertions(+), 186 deletions(-) diff --git a/packages/essdiffraction/src/ess/beer/parameters.py b/packages/essdiffraction/src/ess/beer/parameters.py index abd08dcf4..86f8403a8 100644 --- a/packages/essdiffraction/src/ess/beer/parameters.py +++ b/packages/essdiffraction/src/ess/beer/parameters.py @@ -14,7 +14,6 @@ Filename, IntensityDspacing, IntensityDspacingTwoTheta, - MaskedDetectorIDs, MonitorFilename, NeXusDetectorName, PixelMaskFilename, @@ -41,45 +40,51 @@ def _edges(model): 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, - filter_keys=(MaskedDetectorIDs, PixelMaskFilename), ) parameters[NeXusDetectorName] = ParameterSpec( model=str, category='NeXus', title='Detector', + description='Name of the detector group in the NeXus files.', default='detector', ) @@ -87,6 +92,7 @@ def _edges(model): 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, @@ -95,6 +101,7 @@ def _edges(model): 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, @@ -104,12 +111,14 @@ def _edges(model): 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, ) diff --git a/packages/essdiffraction/src/ess/beer/workflow.py b/packages/essdiffraction/src/ess/beer/workflow.py index 34eefdf15..4a18f47e9 100644 --- a/packages/essdiffraction/src/ess/beer/workflow.py +++ b/packages/essdiffraction/src/ess/beer/workflow.py @@ -14,7 +14,6 @@ EmptyCanRun, Filename, KeepEvents, - MaskedDetectorIDs, NeXusDetectorName, RunType, SampleRun, @@ -59,7 +58,6 @@ ), TofMask: None, WavelengthMask: None, - MaskedDetectorIDs: MaskedDetectorIDs({}), NeXusDetectorName: "detector", DetectorBank: DetectorBank.south, Filename[SampleRun]: str(mcstas_powder_silicon_in_vanadium_can()), @@ -68,7 +66,7 @@ } -@register_workflow +@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.""" @@ -82,12 +80,10 @@ def BeerModMcStasWorkflow(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@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.""" @@ -100,12 +96,10 @@ def BeerModMcStasWorkflowKnownPeaks(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerMcStasWorkflowPulseShaping(): """Workflow to process BEER (pulse shaping modes) McStas files""" wf = sl.Pipeline( @@ -113,12 +107,10 @@ def BeerMcStasWorkflowPulseShaping(): params=default_parameters, constraints={RunType: (SampleRun,)}, ) - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerMcStasWorkflowPulseShapingAnalytical(): """Workflow to process BEER pulse-shaping McStas files using analytical frame unwrapping.""" @@ -134,12 +126,10 @@ def BeerMcStasWorkflowPulseShapingAnalytical(): wf[key] = value wf[NeXusName[snx.NXdetector]] = 'detector' wf[LookupTableRelativeErrorThreshold] = {'detector': float('inf')} - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderWorkflow( *, run_norm: RunNormalization = RunNormalization.monitor_integrated, @@ -174,12 +164,10 @@ def BeerPowderWorkflow( insert_run_normalization(wf, run_norm) for key, value in default_parameters.items(): wf[key] = value - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@register_workflow(parameters=parameters, typical_outputs=typical_outputs) def BeerPowderWorkflowAnalytical( *, run_norm: RunNormalization = RunNormalization.monitor_integrated, @@ -221,26 +209,22 @@ def BeerPowderWorkflowAnalytical( 'monitor_bunker': float('inf'), 'monitor_cave': float('inf'), } - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters return wf -@register_workflow +@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) - wf.parameter_registry = parameters return wf -@register_workflow +@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) - wf.parameter_registry = parameters return wf diff --git a/packages/essdiffraction/src/ess/dream/parameters.py b/packages/essdiffraction/src/ess/dream/parameters.py index 62bb7eb77..097043e97 100644 --- a/packages/essdiffraction/src/ess/dream/parameters.py +++ b/packages/essdiffraction/src/ess/dream/parameters.py @@ -14,7 +14,6 @@ Filename, IntensityDspacing, IntensityDspacingTwoTheta, - MaskedDetectorIDs, MonitorFilename, NeXusDetectorName, PixelMaskFilename, @@ -41,51 +40,58 @@ def _edges(model): 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[MonitorFilename[VanadiumRun]] = ParameterSpec( model=str | None, category='Files', title='Vanadium Monitor', + description='NeXus file path for the vanadium 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, - filter_keys=(MaskedDetectorIDs, PixelMaskFilename), ) parameters[NeXusDetectorName] = ParameterSpec( model=str, category='NeXus', title='Detector', + description='Name of the detector group in the NeXus files.', default='mantle', ) @@ -93,6 +99,7 @@ def _edges(model): 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, @@ -101,6 +108,7 @@ def _edges(model): 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, @@ -110,12 +118,14 @@ def _edges(model): model=InstrumentConfiguration, category='Reduction', title='Instrument Configuration', + description='DREAM chopper system configuration used in the measurement.', default=InstrumentConfiguration.high_flux_BC215, ) parameters[UncertaintyBroadcastMode] = ParameterSpec( model=UncertaintyBroadcastMode, category='Reduction', title='Uncertainty Broadcast', + description='How uncertainties are treated when uncertain quantities are combined.', default=UncertaintyBroadcastMode.upper_bound, ) diff --git a/packages/essdiffraction/src/ess/dream/workflows.py b/packages/essdiffraction/src/ess/dream/workflows.py index b735c90d8..47fd80d2c 100644 --- a/packages/essdiffraction/src/ess/dream/workflows.py +++ b/packages/essdiffraction/src/ess/dream/workflows.py @@ -138,7 +138,6 @@ def DreamWorkflow( "monitor_bunker": float('inf'), "monitor_cave": float('inf'), } - wf.parameter_registry = parameters return wf @@ -184,8 +183,6 @@ 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 - wf.parameter_registry = parameters return wf @@ -248,12 +245,10 @@ def DreamGeant4Workflow( for key, value in additional_parameters.items(): wf[key] = value - wf.typical_outputs = typical_outputs - wf.parameter_registry = parameters 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 @@ -262,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 @@ -271,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 f6116f27e..4b5bef608 100644 --- a/packages/essdiffraction/tests/dream/geant4_reduction_test.py +++ b/packages/essdiffraction/tests/dream/geant4_reduction_test.py @@ -340,7 +340,7 @@ 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 @@ -348,13 +348,16 @@ class MyWorkflow: ... def test_dream_workflow_registers_parameter_registry(): - wf = DreamGeant4ProtonChargeWorkflow() - assert wf.parameter_registry is dream_parameters + spec = reduce_workflow.workflow_registry.get(DreamGeant4ProtonChargeWorkflow) + assert spec.parameters is dream_parameters def test_dream_workflow_applies_parameter_values(): wf = DreamGeant4ProtonChargeWorkflow() - specs = reduce_workflow.get_parameters(wf, (IntensityDspacingTwoTheta[SampleRun],)) + 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() 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/src/ess/reduce/parameter.py b/packages/essreduce/src/ess/reduce/parameter.py index 83d1e6a81..560b4a18a 100644 --- a/packages/essreduce/src/ess/reduce/parameter.py +++ b/packages/essreduce/src/ess/reduce/parameter.py @@ -31,13 +31,11 @@ class ParameterSpec(Generic[T]): default: T | KeepDefaultType = keep_default transform: Callable[[T], Any] | None = None apply: Callable[[Pipeline, T], Pipeline] | None = None - filter_keys: tuple[Key, ...] = () use_workflow_default: bool = True key: Key | None = None def bind(self, key: Key) -> ParameterSpec[T]: - filter_keys = self.filter_keys or (key,) - return replace(self, key=key, filter_keys=filter_keys) + return replace(self, key=key) def with_default( self, default: T | KeepDefaultType = keep_default diff --git a/packages/essreduce/src/ess/reduce/workflow.py b/packages/essreduce/src/ess/reduce/workflow.py index a2ab59d11..b35632e66 100644 --- a/packages/essreduce/src/ess/reduce/workflow.py +++ b/packages/essreduce/src/ess/reduce/workflow.py @@ -2,16 +2,21 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) from __future__ import annotations -from collections.abc import Callable, MutableSet, Sequence -from dataclasses import dataclass +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 ParameterSpec, keep_default, parameter_registry +from .parameter import ( + ParameterRegistry, + ParameterSpec, + keep_default, + parameter_registry, +) T = TypeVar("T") WorkflowFactory = Callable[..., Pipeline] @@ -19,53 +24,60 @@ @dataclass(frozen=True) class WorkflowSpec: - """Metadata needed to generate interfaces for a workflow.""" + """Workflow factory and metadata needed to generate user interfaces.""" - name: str + 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( - name=name or factory.__name__, + 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, ) -@dataclass(frozen=True) -class RegisteredWorkflow: - """A workflow factory and the interface specification attached to it.""" - - factory: WorkflowFactory - spec: WorkflowSpec - - def __call__(self, *args: Any, **kwargs: Any) -> Pipeline: - return self.factory(*args, **kwargs) - - @property - def __name__(self) -> str: - return self.factory.__name__ - - class WorkflowRegistry(MutableSet): def __init__(self): - self._workflows: dict[str, RegisteredWorkflow] = {} + self._workflows: dict[str, WorkflowSpec] = {} def __contains__(self, item: object) -> bool: return item in self._workflows.values() or any( - item is workflow.factory for workflow in self._workflows.values() + item is spec.factory for spec in self._workflows.values() ) def __iter__(self): @@ -74,28 +86,23 @@ def __iter__(self): def __len__(self) -> int: return len(self._workflows) - def add( - self, - value: WorkflowFactory | RegisteredWorkflow, - spec: WorkflowSpec | None = None, - ) -> None: - workflow = ( - value - if isinstance(value, RegisteredWorkflow) - else _make_registered_workflow(value, spec) - ) - key = workflow.factory.__qualname__ - self._workflows[key] = workflow + 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 | RegisteredWorkflow) -> None: + 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 | RegisteredWorkflow) -> RegisteredWorkflow: - if isinstance(value, RegisteredWorkflow): + def get(self, value: WorkflowFactory | WorkflowSpec) -> WorkflowSpec: + if isinstance(value, WorkflowSpec): return value key = value.__qualname__ try: @@ -107,56 +114,32 @@ def get(self, value: WorkflowFactory | RegisteredWorkflow) -> RegisteredWorkflow workflow_registry = WorkflowRegistry() -def _make_registered_workflow( - factory: WorkflowFactory, spec: WorkflowSpec | None = None -) -> RegisteredWorkflow: - spec = spec or WorkflowSpec.from_factory(factory) - return RegisteredWorkflow(factory=factory, spec=spec) - - def register_workflow( - cls: WorkflowFactory | WorkflowSpec | None = None, - /, *, + 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, -) -> WorkflowFactory | Callable[[WorkflowFactory], WorkflowFactory]: +) -> Callable[[WorkflowFactory], WorkflowFactory]: """Register a workflow factory for generated user interfaces.""" def decorator(factory: WorkflowFactory) -> WorkflowFactory: - spec = ( - cls - if isinstance(cls, WorkflowSpec) - else WorkflowSpec.from_factory( + workflow_registry.add( + WorkflowSpec.from_factory( factory, + parameters=parameters, + typical_outputs=typical_outputs, name=name, title=title, description=description, version=version, ) ) - workflow_registry.add(factory, spec=spec) return factory - if isinstance(cls, WorkflowSpec) or cls is None: - return decorator - if any(value is not None for value in (name, title, description, version)): - raise TypeError( - "Use register_workflow(...) as a decorator when passing workflow metadata." - ) - return decorator(cls) - - -def create_workflow(workflow: WorkflowFactory | RegisteredWorkflow) -> Pipeline: - """Create a pipeline from a registered workflow.""" - registered = ( - workflow_registry.get(workflow) - if not isinstance(workflow, RegisteredWorkflow) - else workflow - ) - return registered.factory() + return decorator def _get_defaults_from_workflow(workflow: Pipeline) -> dict[Key, Any]: @@ -164,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]) @@ -184,30 +169,29 @@ def _with_pretty_names(outputs: Sequence[Key]) -> tuple[tuple[str, Key], ...]: def get_parameters( - pipeline: Pipeline, outputs: tuple[Key, ...] + 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) - parameters = _get_parameter_registry(pipeline) return { key: spec.with_default(defaults.get(key, keep_default)) for key, spec in parameters.items() - if any(filter_key in subgraph for filter_key in spec.filter_keys) + if key in required_keys } def assign_parameter_values( pipeline: Pipeline, values: dict[Key, Any], - parameters: dict[Key, ParameterSpec] | None = None, + parameters: Mapping[Key, ParameterSpec] = parameter_registry, ) -> Pipeline: """Set a value for a parameter in the pipeline.""" pipeline = pipeline.copy() - parameters = parameters or _get_parameter_registry(pipeline) for key, value in values.items(): spec = parameters[key] if spec.transform is not None: @@ -217,7 +201,3 @@ def assign_parameter_values( else: pipeline[key] = value return pipeline - - -def _get_parameter_registry(pipeline: Pipeline): - return getattr(pipeline, 'parameter_registry', parameter_registry) 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/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 0d6567dd0..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,11 +166,10 @@ 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 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 c09f26dbd..5db379a17 100644 --- a/packages/esssans/src/ess/sans/parameters.py +++ b/packages/esssans/src/ess/sans/parameters.py @@ -120,71 +120,83 @@ def _direct_beam_filename( model=tuple[str, ...], category='Files', title='Sample Runs', + description='Comma-separated NeXus file paths for the sample runs.', ) parameters[Filename[BackgroundRun]] = ParameterSpec( model=tuple[str, ...], category='Files', title='Background Runs', + description='Comma-separated NeXus file paths for the background runs.', ) parameters[Filename[TransmissionRun[SampleRun]]] = ParameterSpec( model=str | None, category='Files', title='Sample Transmission', + description='NeXus file path for the sample transmission run.', default=None, ) parameters[Filename[TransmissionRun[BackgroundRun]]] = ParameterSpec( model=str | None, category='Files', title='Background Transmission', + description='NeXus file path for the background transmission run.', default=None, ) parameters[Filename[EmptyBeamRun]] = ParameterSpec( model=str | None, category='Files', title='Empty Beam', + description='NeXus file path for the empty-beam run.', default=None, ) parameters[PixelMaskFilename] = ParameterSpec( model=tuple[str, ...], category='Files', title='Pixel Masks', + description='Comma-separated paths to detector pixel mask files.', + default=(), ) 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, - filter_keys=(DirectBeam, DirectBeamFilename), ) parameters[NeXusDetectorName] = ParameterSpec( model=str, category='NeXus', title='Detector', + description='Name of the detector group in the NeXus files.', ) parameters[NeXusMonitorName[Incident]] = ParameterSpec( model=str, category='NeXus', title='Incident Monitor', + description='Name of the incident monitor group in the NeXus files.', default='', ) parameters[NeXusMonitorName[Transmission]] = ParameterSpec( model=str, category='NeXus', title='Transmission Monitor', + description='Name of the transmission monitor group in the NeXus files.', default='', ) parameters[TransformationPath] = ParameterSpec( model=str, category='NeXus', title='Transform Path', + description='NeXus path containing detector transformation information.', default='', ) parameters[PixelShapePath] = ParameterSpec( model=str, category='NeXus', title='Pixel Shape Path', + description='NeXus path containing detector pixel-shape information.', default='', ) @@ -192,6 +204,7 @@ def _direct_beam_filename( 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, @@ -200,6 +213,7 @@ def _direct_beam_filename( 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, @@ -208,6 +222,7 @@ def _direct_beam_filename( 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, @@ -216,6 +231,7 @@ def _direct_beam_filename( 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, @@ -225,6 +241,7 @@ def _direct_beam_filename( model=bool, category='Reduction', title='Correct Gravity', + description='Apply gravity correction to neutron trajectories before reduction.', default=False, transform=CorrectForGravity, ) @@ -232,6 +249,7 @@ def _direct_beam_filename( model=bool, category='Reduction', title='Return Events', + description='Keep event data in outputs where the workflow supports it.', default=False, transform=ReturnEvents, ) @@ -239,12 +257,14 @@ def _direct_beam_filename( model=UncertaintyBroadcastMode, category='Reduction', title='Uncertainty Broadcast', + description='How uncertainties are broadcast when reduced data are combined.', default=UncertaintyBroadcastMode.upper_bound, ) 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, diff --git a/packages/esssans/src/ess/sans/workflow.py b/packages/esssans/src/ess/sans/workflow.py index a05763809..a2b501429 100644 --- a/packages/esssans/src/ess/sans/workflow.py +++ b/packages/esssans/src/ess/sans/workflow.py @@ -38,14 +38,6 @@ def _merge(*dicts: dict) -> dict: return {key: value for d in dicts for key, value in d.items()} -def _preserve_typical_outputs( - source: sciline.Pipeline, target: sciline.Pipeline -) -> sciline.Pipeline: - if (typical_outputs := getattr(source, 'typical_outputs', None)) is not None: - target.typical_outputs = typical_outputs - return target - - def merge_contributions(*data: sc.DataArray) -> sc.DataArray: if len(data) == 1: return data[0] @@ -66,7 +58,6 @@ def with_pixel_mask_filenames( masks: List or tuple of pixel mask filenames to set. """ - source = workflow masks = tuple(masks) target = workflow.copy() if masks: @@ -77,7 +68,7 @@ def with_pixel_mask_filenames( ) else: target[DetectorMasks] = DetectorMasks({}) - return _preserve_typical_outputs(source, target) + return target def with_banks( @@ -102,18 +93,14 @@ def with_banks( Index to use for the DataFrame. If not provided, the bank names are used. """ index = index or banks - return _preserve_typical_outputs( - workflow, - workflow.map( - pd.DataFrame({NeXusDetectorName: banks}, index=index).rename_axis('bank') - ), + return workflow.map( + pd.DataFrame({NeXusDetectorName: banks}, index=index).rename_axis('bank') ) def _set_runs( pipeline: sciline.Pipeline, runs: Iterable[str], key: Hashable, axis_name: str ) -> sciline.Pipeline: - source = pipeline pipeline = pipeline.copy() runs = pd.DataFrame({Filename[key]: runs}).rename_axis(axis_name) for part in (Numerator, Denominator): @@ -123,7 +110,7 @@ def _set_runs( .map(runs) .reduce(index=axis_name, func=merge_contributions) ) - return _preserve_typical_outputs(source, pipeline) + return pipeline def with_sample_runs( @@ -223,5 +210,4 @@ def SansWorkflow( workflow[TransformationPath] = TransformationPath('transform') workflow[WavelengthBands] = WavelengthBands(None) workflow[WavelengthMask] = WavelengthMask(None) - workflow.parameter_registry = parameters return workflow diff --git a/packages/esssans/tests/loki/workflow_test.py b/packages/esssans/tests/loki/workflow_test.py index 070421143..ff2526d60 100644 --- a/packages/esssans/tests/loki/workflow_test.py +++ b/packages/esssans/tests/loki/workflow_test.py @@ -32,7 +32,7 @@ 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 @@ -40,13 +40,14 @@ class MyWorkflow: ... def test_loki_larmor_workflow_registers_parameter_model(): - wf = LokiAtLarmorWorkflow() - assert wf.parameter_registry is sans_parameters + spec = workflow.workflow_registry.get(LokiAtLarmorWorkflow) + assert spec.parameters is sans_parameters def test_loki_larmor_workflow_applies_parameter_model(): wf = LokiAtLarmorWorkflow() - params = workflow.get_parameters(wf, (ReturnEvents,)) + 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) @@ -54,8 +55,9 @@ def test_loki_larmor_workflow_applies_parameter_model(): def test_loki_larmor_workflow_parameters_are_selected_from_graph(): wf = LokiAtLarmorWorkflow() + spec = workflow.workflow_registry.get(LokiAtLarmorWorkflow) - params = workflow.get_parameters(wf, (BackgroundSubtractedIofQ,)) + params = workflow.get_parameters(wf, (BackgroundSubtractedIofQ,), spec.parameters) assert Filename[SampleRun] in params assert PixelMaskFilename in params @@ -65,8 +67,11 @@ def test_loki_larmor_workflow_parameters_are_selected_from_graph(): 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)] + labels = [ + label for label, _ in workflow.get_typical_outputs(wf, spec.typical_outputs) + ] masks = wf.compute(DetectorMasks) assert 'IntensityQ[SampleRun]' in labels From 51f9c1b047298ee669eef54502bbec81396ac1cc Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 29 Jun 2026 14:22:06 +0200 Subject: [PATCH 3/4] feat: ipython UI improvements --- .../dream/workflow-widget-dream.ipynb | 3 +- packages/essreduce/docs/developer/gui.ipynb | 1 - packages/essreduce/src/ess/reduce/ui.py | 176 ++++++------ .../src/ess/reduce/widgets/__init__.py | 3 +- .../src/ess/reduce/widgets/_config.py | 8 +- .../src/ess/reduce/widgets/_pydantic.py | 153 ++++++++-- packages/essreduce/tests/widget_test.py | 264 +++++++++++++++--- .../loki/workflow-widget-loki.ipynb | 1 - 8 files changed, 450 insertions(+), 159 deletions(-) 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 1767af564..98276b054 100644 --- a/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb +++ b/packages/essdiffraction/docs/user-guide/dream/workflow-widget-dream.ipynb @@ -68,7 +68,6 @@ "ind = keys.index(\"IntensityDspacingTwoTheta[SampleRun]\")\n", "outputs.value = (values[ind],)\n", "# Set parameters\n", - "wfw.parameter_box.parameter_refresh_button.click()\n", "ui.set_parameter_widget_values(\n", " wfw,\n", " {\n", @@ -146,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/essreduce/docs/developer/gui.ipynb b/packages/essreduce/docs/developer/gui.ipynb index a3144c260..3201173d8 100644 --- a/packages/essreduce/docs/developer/gui.ipynb +++ b/packages/essreduce/docs/developer/gui.ipynb @@ -207,7 +207,6 @@ "body_widget: WorkflowWidget = ess_widget.children[1].children[0]\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()" ] }, diff --git a/packages/essreduce/src/ess/reduce/ui.py b/packages/essreduce/src/ess/reduce/ui.py index 385470179..dd5e5c339 100644 --- a/packages/essreduce/src/ess/reduce/ui.py +++ b/packages/essreduce/src/ess/reduce/ui.py @@ -6,7 +6,6 @@ import ipywidgets as widgets import sciline as sl from IPython import display -from ipywidgets import Layout from pydantic_core import PydanticUndefined from sciline.typing import Key @@ -14,13 +13,11 @@ from .widgets import ( PydanticParameterValueWidget, Spinner, - default_layout, + full_width_layout, ) from .workflow import ( - RegisteredWorkflow, - WorkflowFactory, + WorkflowSpec, assign_parameter_values, - create_workflow, get_parameters, get_possible_outputs, get_typical_outputs, @@ -29,29 +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'), ) _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 _possible_selection = _wrap_foldable( self.possible_outputs_widget, title='Extended Outputs' ) @@ -70,46 +74,53 @@ def __init__( 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._registry_getter = registry_getter self._input_registry: dict[Key, ParameterSpec] = {} self._input_widgets: dict[Key, PydanticParameterValueWidget] = {} - self._input_box = widgets.Box() - self.parameter_refresh_button.on_click(self._refresh_input_box) - super().__init__([self.parameter_refresh_button, self._input_box], **kwargs) + 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_input_box(self, _: widgets.Button | None = None) -> None: - existing_values = self.get_values() - new_input_parameters = self._registry_getter() - self._input_registry.clear() - self._input_registry.update(new_input_parameters) - self._input_widgets.clear() + 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_values: - widget.set_value(existing_values[key]) - self._input_widgets[key] = widget + 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.HTML("No parameters")] + self._input_box.children = [ + widgets.HTML("No parameters", layout=full_width_layout()) + ] return - categories = list(grouped) - accordion = widgets.Accordion( - [widgets.VBox(grouped[category]) for category in categories] - ) - for index, category in enumerate(categories): - accordion.set_title(index, category) - accordion.selected_index = 0 - self._input_box.children = [accordion] + 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 + ] + + 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]: @@ -199,50 +210,24 @@ 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: - 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) - - original_run_button_tooltip = run_button.tooltip - - def observe_parameter_refreshed(_) -> None: - run_button.disabled = False - run_button.tooltip = original_run_button_tooltip - - parameter_refresh_button.on_click(observe_parameter_refreshed) - - class WorkflowWidget(widgets.TwoByTwoLayout): def __init__( self, - workflow: sl.Pipeline | WorkflowFactory | RegisteredWorkflow, + workflow: WorkflowSpec, result_registry: dict | None = None, **kwargs, ): self.workflow = workflow - self._pipeline = self._create_pipeline() - self.output_selection_box = OutputSelectionWidget(self._pipeline) + self._pipeline = self.workflow.create_workflow() + self.output_selection_box = OutputSelectionWidget( + self._pipeline, self.workflow.typical_outputs + ) def registry_getter() -> dict[Key, ParameterSpec]: return get_parameters( - self._pipeline, tuple(self.output_selection_box.value) + self._pipeline, + tuple(self.output_selection_box.value), + self.workflow.parameters, ) self.parameter_box = ParameterBox(registry_getter) @@ -252,20 +237,30 @@ def workflow_runner() -> dict[type, Any]: if not is_valid: raise ValueError('\n'.join(errors)) pipeline = assign_parameter_values( - self._pipeline, self.parameter_box.value, self.parameter_box.parameters + 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' @@ -274,26 +269,21 @@ 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, ) - def _create_pipeline(self) -> sl.Pipeline: - if isinstance(self.workflow, sl.Pipeline): - return self.workflow.copy() - return create_workflow(self.workflow) - 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.spec.title or workflow.spec.name, workflow) + (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.', ) @@ -302,8 +292,8 @@ def refresh_workflow_box(change) -> None: 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]) diff --git a/packages/essreduce/src/ess/reduce/widgets/__init__.py b/packages/essreduce/src/ess/reduce/widgets/__init__.py index d8be6d379..beccc4b58 100644 --- a/packages/essreduce/src/ess/reduce/widgets/__init__.py +++ b/packages/essreduce/src/ess/reduce/widgets/__init__.py @@ -2,7 +2,7 @@ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp) from typing import Any, Protocol -from ._config import default_layout, default_style +from ._config import default_layout, default_style, full_width_layout from ._pydantic import ( PydanticModelWidget, PydanticParameterValueWidget, @@ -26,4 +26,5 @@ def value(self) -> Any: ... 'Spinner', 'default_layout', 'default_style', + 'full_width_layout', ] 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/_pydantic.py b/packages/essreduce/src/ess/reduce/widgets/_pydantic.py index bcfd806b9..99c93275d 100644 --- a/packages/essreduce/src/ess/reduce/widgets/_pydantic.py +++ b/packages/essreduce/src/ess/reduce/widgets/_pydantic.py @@ -4,6 +4,7 @@ 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 @@ -14,7 +15,7 @@ from pydantic_core import PydanticUndefined from ._base import WidgetWithFieldsMixin -from ._config import default_layout, default_style +from ._config import default_style, full_width_layout def snake_to_camel(snake_str: str) -> str: @@ -99,12 +100,80 @@ def _as_dict(value: Any) -> dict[str, Any]: 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): + def __init__( + self, + title: str, + widget: PydanticParameterWidget, + description: str | None = None, + ): self.widget = widget - super().__init__([ipw.HTML(f"{title}"), widget]) + super().__init__( + [_title_html(title, description), widget], + layout=full_width_layout(), + ) @property def value(self) -> pydantic.BaseModel: @@ -129,6 +198,9 @@ def __init__( 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) @@ -136,7 +208,8 @@ def __init__( field = Field(default=default, title=title, description=description) model_class = pydantic.create_model('ParameterValue', value=(model, field)) self._widget = PydanticParameterWidget(model_class) - super().__init__([self._widget]) + child = self._widget + super().__init__([child], layout=full_width_layout()) @property def value(self) -> Any: @@ -152,12 +225,7 @@ def set_value(self, value: Any) -> None: self._widget.set_values({'value': value}) def get_fields(self) -> dict[str, Any]: - if self._returns_model: - value = self.value - return ( - value.model_dump() if isinstance(value, pydantic.BaseModel) else value - ) - return {'value': self.value} + return self._widget.get_values() def set_fields(self, values: dict[str, Any]) -> None: self.set_value(values) @@ -178,6 +246,7 @@ def __init__( 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] = {} @@ -199,26 +268,35 @@ def _create_widget_for_field( ) -> ipw.Widget: field_type, optional = _strip_optional(field_info.annotation) default_value = self._field_default(field_info) - description = field_info.description or field_name 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': default_layout, + 'layout': full_width_layout(width='auto'), 'style': default_style, } if field_type is float: - return ipw.FloatText(value=default_value or 0.0, **common) + return _with_help( + ipw.FloatText(value=default_value or 0.0, **common), help_text + ) if field_type is int: - return ipw.IntText(value=default_value or 0, **common) + return _with_help( + ipw.IntText(value=default_value or 0, **common), help_text + ) if field_type is bool: - return ipw.Checkbox(value=default_value or False, **common) + 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 ipw.Text(value=value, placeholder=description, **common) + 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 = ( @@ -226,35 +304,47 @@ def _create_widget_for_field( if default_value is not None else next(iter(options.values())) ) - return ipw.Dropdown(options=options, value=value, **common) + 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 ipw.SelectMultiple( - options=options, - value=value, - rows=min(len(options), 10), - **common, + 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 ipw.Dropdown(options=options, value=value, **common) + 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 ipw.Text(value=value, placeholder=description, **common) + 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 ipw.Text(value=value, placeholder=description, **common) + return _with_help( + ipw.Text(value=value, placeholder=description, **common), help_text + ) @staticmethod def _field_default(field_info: pydantic.fields.FieldInfo) -> Any: @@ -270,6 +360,9 @@ def get_values(self) -> dict[str, Any]: 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 @@ -362,6 +455,7 @@ def __init__( 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 @@ -406,9 +500,16 @@ def _build_grouped(self) -> None: 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) + accordion = ipw.Accordion( + children=children, layout=full_width_layout(width='auto') + ) for index, title in enumerate(titles): accordion.set_title(index, title) if children: diff --git a/packages/essreduce/tests/widget_test.py b/packages/essreduce/tests/widget_test.py index bde483b53..a158234b0 100644 --- a/packages/essreduce/tests/widget_test.py +++ b/packages/essreduce/tests/widget_test.py @@ -8,6 +8,7 @@ import pytest import sciline as sl +from ipywidgets import VBox from pydantic import BaseModel, Field from ess.reduce.parameter import ParameterRegistry, ParameterSpec @@ -17,8 +18,13 @@ set_parameter_widget_values, workflow_widget, ) -from ess.reduce.widgets import PydanticModelWidget, PydanticParameterWidget +from ess.reduce.widgets import ( + PydanticModelWidget, + PydanticParameterValueWidget, + PydanticParameterWidget, +) from ess.reduce.workflow import ( + WorkflowSpec, assign_parameter_values, get_parameters, register_workflow, @@ -49,9 +55,14 @@ class NestedParams(BaseModel): group: GroupParams = Field(default_factory=GroupParams, title="Group") +class LimitedParams(BaseModel): + value: int = Field(default=1, ge=0) + + Count = NewType("Count", int) Label = NewType("Label", str) Unused = NewType("Unused", int) +Limited = NewType("Limited", int) def test_pydantic_parameter_widget_creates_model_from_widget_values() -> None: @@ -112,6 +123,38 @@ class Params(BaseModel): assert widget.create_model() == Params(group=GroupParams(value=5)) +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.') + + 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_pydantic_parameter_value_widget_labels_composite_parameters() -> None: + widget = PydanticParameterValueWidget( + GroupParams, + title='Grouped Parameter', + description='Controls for the grouped parameter.', + default=GroupParams(), + ) + + title = widget.children[0].children[0].value + + assert 'Grouped Parameter' in title + assert 'Controls for the grouped parameter.' in title + + +def test_pydantic_parameter_value_widget_uses_title_as_placeholder_fallback() -> None: + widget = PydanticParameterValueWidget(str | None, title='Sample Run', default=None) + + assert widget._widget.widgets['value'].placeholder == 'Sample Run' + + def test_pydantic_model_widget_supports_nested_parameter_groups() -> None: widget = PydanticModelWidget(NestedParams) group = widget.get_parameter_widget('group') @@ -125,25 +168,45 @@ def provider(count: Count, label: Label) -> str: return f"{count}:{label}" +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 +) + + def make_workflow() -> sl.Pipeline: - parameters = ParameterRegistry() - parameters[Count] = ParameterSpec( - model=int, category='General', title='Count', default=1, transform=Count - ) - parameters[Label] = ParameterSpec( - model=str, - category='General', - title='Label', - default='fallback', - transform=Label, - ) - parameters[Unused] = ParameterSpec( - model=int, category='General', title='Unused', default=2, transform=Unused - ) pipeline = sl.Pipeline([provider]) pipeline[Label] = Label('a') - pipeline.typical_outputs = (str,) - pipeline.parameter_registry = parameters + return pipeline + + +def other_provider(unused: Unused) -> int: + return unused + + +def limited_provider(limited: Limited) -> float: + return float(limited) + + +def make_workflow_with_alternative_output() -> sl.Pipeline: + pipeline = sl.Pipeline([provider, other_provider]) + pipeline[Label] = Label('a') + return pipeline + + +def make_workflow_with_limited_parameter() -> sl.Pipeline: + pipeline = sl.Pipeline([provider, limited_provider]) + pipeline[Label] = Label('a') return pipeline @@ -156,7 +219,11 @@ def temporary_workflow_registry( } try: for constructor in constructors: - register_workflow(title="Example")(constructor) + register_workflow( + parameters=test_parameters, + typical_outputs=(str,), + title="Example", + )(constructor) yield finally: for constructor, existed in existence_flags.items(): @@ -166,9 +233,11 @@ def temporary_workflow_registry( def test_register_workflow_with_metadata() -> None: with temporary_workflow_registry(make_workflow): - record = workflow_registry.get(make_workflow) - assert record.factory is make_workflow - assert record.spec.title == "Example" + 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) @@ -178,21 +247,35 @@ def _get_selection_widget(widget): return widget.children[0].children[0] +def _category_sections(widget: WorkflowWidget): + return { + section.get_title(0): section + for section in widget.parameter_box._input_box.children + } + + +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: with temporary_workflow_registry(make_workflow): - record = workflow_registry.get(make_workflow) + spec = workflow_registry.get(make_workflow) selection_widget = _get_selection_widget(workflow_widget()) - assert ("Example", record) in selection_widget.options + assert ("Example", spec) in selection_widget.options def test_workflow_selection_builds_workflow_widget() -> None: with temporary_workflow_registry(make_workflow): - record = workflow_registry.get(make_workflow) + spec = workflow_registry.get(make_workflow) widget = workflow_widget() selection_widget = _get_selection_widget(widget) - selection_widget.value = record + selection_widget.value = spec assert len(widget.children[1].children) == 1 selected_widget = widget.children[1].children[0] @@ -200,11 +283,11 @@ def test_workflow_selection_builds_workflow_widget() -> None: assert selected_widget.output_selection_box.typical_outputs_widget.options == ( ('str', str), ) - assert selected_widget._pipeline.parameter_registry is not None + assert selected_widget.workflow is spec def test_get_parameters_filters_by_selected_output_and_uses_workflow_defaults() -> None: - params = get_parameters(make_workflow(), (str,)) + params = get_parameters(make_workflow(), (str,), test_parameters) assert set(params) == {Count, Label} assert params[Count].default == 1 @@ -213,7 +296,7 @@ def test_get_parameters_filters_by_selected_output_and_uses_workflow_defaults() def test_assign_parameter_values_uses_key_specs() -> None: pipeline = make_workflow() - params = get_parameters(pipeline, (str,)) + params = get_parameters(pipeline, (str,), test_parameters) updated = assign_parameter_values(pipeline, {Count: 5, Label: 'x'}, params) @@ -223,10 +306,12 @@ def test_assign_parameter_values_uses_key_specs() -> None: def test_workflow_widget_runs_with_keyed_parameter_values() -> None: registry = {} with temporary_workflow_registry(make_workflow): - record = workflow_registry.get(make_workflow) - widget = WorkflowWidget(record, result_registry=registry) + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec, result_registry=registry) widget.output_selection_box.typical_outputs_widget.value = (str,) - widget.parameter_box.parameter_refresh_button.click() + + 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() @@ -234,13 +319,124 @@ def test_workflow_widget_runs_with_keyed_parameter_values() -> None: assert registry == {str: '5:x'} +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 == {} + + 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_get_parameter_widget_values_returns_current_keyed_fields() -> None: with temporary_workflow_registry(make_workflow): - record = workflow_registry.get(make_workflow) - widget = WorkflowWidget(record) + spec = workflow_registry.get(make_workflow) + widget = WorkflowWidget(spec) widget.output_selection_box.typical_outputs_widget.value = (str,) - widget.parameter_box.parameter_refresh_button.click() set_parameter_widget_values(widget, {Count: 7}) assert get_parameter_widget_values(widget)[Count] == {'value': 7} + + +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,) + + 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, + } + + 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, + } + + output_widget.value = (str, int) + assert _category_expansion_state(widget) == { + 'General': 0, + 'Text': None, + 'Other': None, + } + + +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), + ) + 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) + + 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/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb b/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb index 3b93b6a54..ec698fb50 100644 --- a/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb +++ b/packages/esssans/docs/user-guide/loki/workflow-widget-loki.ipynb @@ -57,7 +57,6 @@ "ind = keys.index(\"IntensityQ[SampleRun]\")\n", "outputs.value = (values[ind],)\n", "# Adjust Q range\n", - "wfw.parameter_box.parameter_refresh_button.click()\n", "ui.set_parameter_widget_values(wfw, {QBins: {\"start\": 0.01}})\n", "# Run the workflow\n", "rbox = wfw.result_box\n", From 626d44b1fafacb2c4c3377e60d4212ce33902795 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 29 Jun 2026 14:39:01 +0200 Subject: [PATCH 4/4] docs: reflect changes to gui system --- packages/essreduce/docs/developer/gui.ipynb | 262 +++++--------------- 1 file changed, 59 insertions(+), 203 deletions(-) diff --git a/packages/essreduce/docs/developer/gui.ipynb b/packages/essreduce/docs/developer/gui.ipynb index 3201173d8..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,7 +123,7 @@ "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.result_box.run_button.click()" ] @@ -214,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." ] }, { @@ -250,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" ] } ], @@ -336,7 +192,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.11.15" } }, "nbformat": 4,