diff --git a/packages/essnmx/src/ess/nmx/_executable_helper.py b/packages/essnmx/src/ess/nmx/_executable_helper.py index 64bfb4ff7..db64335df 100644 --- a/packages/essnmx/src/ess/nmx/_executable_helper.py +++ b/packages/essnmx/src/ess/nmx/_executable_helper.py @@ -14,7 +14,13 @@ from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined -from .configurations import InputConfig, OutputConfig, ReductionConfig, WorkflowConfig +from .configurations import ( + AuxiliaryOutputConfig, + InputConfig, + OutputConfig, + ReductionConfig, + WorkflowConfig, +) def _validate_annotation(annotation) -> TypeGuard[type]: @@ -189,6 +195,9 @@ def build_reduction_argument_parser() -> argparse.ArgumentParser: parser = add_args_from_pydantic_model(model_cls=InputConfig, parser=parser) parser = add_args_from_pydantic_model(model_cls=WorkflowConfig, parser=parser) parser = add_args_from_pydantic_model(model_cls=OutputConfig, parser=parser) + parser = add_args_from_pydantic_model( + model_cls=AuxiliaryOutputConfig, parser=parser + ) return parser @@ -197,6 +206,7 @@ def reduction_config_from_args(args: argparse.Namespace) -> ReductionConfig: inputs=from_args(InputConfig, args), workflow=from_args(WorkflowConfig, args), output=from_args(OutputConfig, args), + aux=from_args(AuxiliaryOutputConfig, args), ) diff --git a/packages/essnmx/src/ess/nmx/configurations.py b/packages/essnmx/src/ess/nmx/configurations.py index 567845a64..24d81941b 100644 --- a/packages/essnmx/src/ess/nmx/configurations.py +++ b/packages/essnmx/src/ess/nmx/configurations.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) import enum +import pathlib from pydantic import BaseModel, Field, model_validator @@ -181,6 +182,62 @@ def positive_nbins(self): ) +class AuxiliaryOutputConfig(BaseModel): + # Add title of the basemodel + model_config = {"title": "Auxiliary Output Configuration"} + output_dir: str = Field( + title="Path to the Auxiliary Files Directory", + description="Directory to save auxiliary files into. " + "If not given, stem of the output file name will be used.", + default="", + ) + no_png: bool = Field( + title="Skip Saving Plots", + description="Skip saving auxiliary plots in png files.", + default=False, + ) + + @property + def no_axilaries(self) -> bool: + return self.no_png + + @property + def tof_1d_png_filename(self) -> str: + """Hard-coded png file name for tof 1D histgoram plot.""" + return "essnmx-reduce-tof-1d.png" + + def build_target_dir(self, output_file: str = "") -> pathlib.Path: + if self.output_dir: + return pathlib.Path(self.output_dir) + elif output_file: + output_file_path = pathlib.Path(output_file) + return output_file_path.parent / output_file_path.stem + else: + return pathlib.Path("essnmx-reduce-aux") + + def check_output_dir(self, output_file: str = "") -> None: + """Raises if the expected auxiliary output directory path is invalid. + + Raises + ------ + - If the parent directory does not exist. + - If the path already exists but is not a directory. + + """ + target_dir = self.build_target_dir(output_file) + if not target_dir.parent.is_dir(): + raise NotADirectoryError( + "Parent directory doesn't exist " + f"for the output files: {target_dir.parent}. " + "Please make sure the parent directory exists first." + ) + if target_dir.exists() and not target_dir.is_dir(): + raise NotADirectoryError( + f"Target Directory path exists but it is not a directory: {target_dir} " + "Please choose another directory path." + ) + + class OutputConfig(BaseModel): # Add title of the basemodel model_config = {"title": "Output Configuration"} @@ -212,6 +269,7 @@ class OutputConfig(BaseModel): description="Compress option of reduced output file.", default=Compression.BITSHUFFLE_LZ4, ) + no_tof_histogram: bool = Field(title="", description="", default=False) class ReductionConfig(BaseModel): @@ -220,10 +278,11 @@ class ReductionConfig(BaseModel): inputs: InputConfig workflow: WorkflowConfig = Field(default_factory=WorkflowConfig) output: OutputConfig = Field(default_factory=OutputConfig) + aux: AuxiliaryOutputConfig = Field(default_factory=AuxiliaryOutputConfig) @property def _children(self) -> list[BaseModel]: - return [self.inputs, self.workflow, self.output] + return [self.inputs, self.workflow, self.output, self.aux] def to_command_arguments( diff --git a/packages/essnmx/src/ess/nmx/executables.py b/packages/essnmx/src/ess/nmx/executables.py index 48e7e6457..1ee1eac6b 100644 --- a/packages/essnmx/src/ess/nmx/executables.py +++ b/packages/essnmx/src/ess/nmx/executables.py @@ -19,6 +19,7 @@ reduction_config_from_args, ) from .configurations import ( + AuxiliaryOutputConfig, OutputConfig, ReductionConfig, TimeBinCoordinate, @@ -237,6 +238,8 @@ def reduction( # Check the file output configuration before we start heavy computation. if not config.output.skip_file_output: _check_file(config.output.output_file, config.output.overwrite) + elif not (config.output.skip_file_output or config.aux.no_axilaries): + config.aux.check_output_dir() display = _retrieve_display(logger, display) input_file_path = _retrieve_input_file(config.inputs.input_file).resolve() @@ -245,6 +248,9 @@ def reduction( output_file_path = pathlib.Path(config.output.output_file).resolve() display(f"Output file: {output_file_path}") + auxilary_output_dir = config.aux.build_target_dir(output_file_path.as_posix()) + display(f"Auxiliary output dir: {auxilary_output_dir}") + detector_names = select_detector_names(detector_ids=config.inputs.detector_ids) # Initialize workflow @@ -324,6 +330,14 @@ def reduction( if not config.output.skip_file_output: save_results(results=results, output_config=config.output) + if not (config.aux.no_axilaries and config.output.no_tof_histogram): + save_auxiliary_output( + results=results, + output_config=config.output, + aux_config=config.aux, + output_dir=auxilary_output_dir, + display=display, + ) return results @@ -357,6 +371,82 @@ def save_results(*, results: NMXLauetof, output_config: OutputConfig) -> None: raise ValueError(f"Detector counts histogram missing in {detector_name}") +def save_auxiliary_output( + *, + results: NMXLauetof, + output_config: OutputConfig, + aux_config: AuxiliaryOutputConfig, + output_dir: pathlib.Path, + display: Callable | None = None, +) -> None: + output_dir.mkdir(exist_ok=True) + tof_histogram = sc.reduce( + det_res.data.sum(["x_pixel_offset", "y_pixel_offset"]) + for det_res in results.instrument.detectors.values() + if isinstance(det_res.data, sc.DataArray) + ).sum() + if ( + isinstance(tof_histogram, sc.DataArray) + and not output_config.skip_file_output + and not output_config.no_tof_histogram + ): + from .nexus import export_tof_distribution_nxlauetof + + export_tof_distribution_nxlauetof( + tof_histogram=tof_histogram, + output_file=output_config.output_file, + ) + + if isinstance(tof_histogram, sc.DataArray) and not aux_config.no_png: + tof_histogram.plot( + title="Tof Distribution (All Panels Summed)", + grid=True, + ).save(output_dir / aux_config.tof_1d_png_filename) + if ( + isinstance(tof_histogram, sc.DataArray) + and output_config.verbose + and display is not None + ): + da = tof_histogram + t_dim = da.dim + n_row = min(da.sizes[t_dim] // 4, 50) + max_value = da.max().value + row_size = int(max_value // n_row) + bars = [ + list(("*" * int(val // row_size)).rjust(n_row)) for val in da.data.values + ] + bars = [ + [bars[col_i][row_i] for col_i in range(len(bars))] + for row_i in range(len(bars[0])) + ] + bars = "\n".join("".join(c for c in row) for row in bars) + + y_axis = f"max-count: {max_value} [{da.unit}]".ljust(da.sizes[t_dim], '-') + bars = y_axis + "\n" + bars + display( + "\n┌──TOF DISTRIBUTION (ALL PANELS SUMMED)".ljust(da.sizes[t_dim] + 1, '─') + + '─┐' + ) + bars = "\n".join('│' + row + '│' for row in bars.split("\n")) + if t_dim in da.coords: + t_coord = da.coords[t_dim] + t_unit = str(t_coord.unit) + t_min, t_max = int(t_coord.min().value), int(t_coord.max().value) + xaxis = f"{t_min:.3g} [{t_unit}]".ljust(da.sizes[t_dim]) + max_t_label = f"{t_max:.3g} [{t_unit}]" + xaxis = xaxis[: -len(max_t_label) + 2] + max_t_label + bars += f"\n{xaxis}" + num_bins_label = f"({da.sizes[t_dim]} {t_dim} bins)" + bars += ( + "\n└".ljust(da.sizes[t_dim] + 1 - len(num_bins_label), '─') + + num_bins_label + + '─┘' + ) + else: + bars += "\n└".ljust(da.sizes[t_dim] + 1, '─') + '┘' + display(bars) + + def main() -> None: parser = build_reduction_argument_parser() config = reduction_config_from_args(parser.parse_args()) diff --git a/packages/essnmx/src/ess/nmx/nexus.py b/packages/essnmx/src/ess/nmx/nexus.py index 20a71d3eb..d050cfcab 100644 --- a/packages/essnmx/src/ess/nmx/nexus.py +++ b/packages/essnmx/src/ess/nmx/nexus.py @@ -323,3 +323,29 @@ def export_reduced_data_as_nxlauetof( root_entry=nx_detector, var=da.coords[time_coord_name], ) + + +def export_tof_distribution_nxlauetof( + tof_histogram: sc.DataArray, + output_file: str | pathlib.Path | io.BytesIO, + append_mode: bool = True, +) -> None: + """Export Tof Distribution to a NeXus file. + + Parameters + ---------- + tof_histogram: + Tof 1D histogram. + output_file: + Output file path. + + """ + + if not append_mode: + raise NotImplementedError("Only append mode is supported for now.") + + with h5py.File(output_file, "r+") as f: + nx_instrument: h5py.Group = f["entry/instrument"] + _create_dataset_from_var( + root_entry=nx_instrument, name="tof-1d", var=tof_histogram.data, dtype=int + ) diff --git a/packages/essnmx/tests/executable_test.py b/packages/essnmx/tests/executable_test.py index 7997bf886..e3960b2e0 100644 --- a/packages/essnmx/tests/executable_test.py +++ b/packages/essnmx/tests/executable_test.py @@ -15,14 +15,19 @@ from scipp.testing import assert_identical from ess.nmx._executable_helper import ( + build_reduction_argument_parser, + reduction_config_from_args, +) +from ess.nmx.configurations import ( + AuxiliaryOutputConfig, InputConfig, OutputConfig, ReductionConfig, + TimeBinCoordinate, + TimeBinUnit, WorkflowConfig, - build_reduction_argument_parser, - reduction_config_from_args, + to_command_arguments, ) -from ess.nmx.configurations import TimeBinCoordinate, TimeBinUnit, to_command_arguments from ess.nmx.executables import reduction from ess.nmx.types import Compression, NMXLauetof @@ -55,6 +60,7 @@ def _default_config() -> ReductionConfig: inputs=InputConfig(input_file=['']), workflow=WorkflowConfig(), output=OutputConfig(), + aux=AuxiliaryOutputConfig(), ) @@ -109,16 +115,21 @@ def test_reduction_config() -> None: verbose=True, skip_file_output=True, overwrite=True, + no_tof_histogram=True, ) + auxilary_options = AuxiliaryOutputConfig(output_dir='test-aux-output', no_png=True) expected_config = ReductionConfig( - inputs=input_options, workflow=workflow_options, output=output_options + inputs=input_options, + workflow=workflow_options, + output=output_options, + aux=auxilary_options, ) # Check if all values are non-default. _check_non_default_config(expected_config) # Build argument list manually, not using `to_command_arguments` to test it. arg_list = _build_arg_list_from_pydantic_instance( - input_options, workflow_options, output_options + input_options, workflow_options, output_options, auxilary_options ) assert arg_list == to_command_arguments(config=expected_config, one_line=False)