Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/essnmx/src/ess/nmx/_executable_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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


Expand All @@ -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),
)


Expand Down
61 changes: 60 additions & 1 deletion packages/essnmx/src/ess/nmx/configurations.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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):
Expand All @@ -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(
Expand Down
90 changes: 90 additions & 0 deletions packages/essnmx/src/ess/nmx/executables.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
reduction_config_from_args,
)
from .configurations import (
AuxiliaryOutputConfig,
OutputConfig,
ReductionConfig,
TimeBinCoordinate,
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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())
Expand Down
26 changes: 26 additions & 0 deletions packages/essnmx/src/ess/nmx/nexus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
21 changes: 16 additions & 5 deletions packages/essnmx/tests/executable_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -55,6 +60,7 @@ def _default_config() -> ReductionConfig:
inputs=InputConfig(input_file=['']),
workflow=WorkflowConfig(),
output=OutputConfig(),
aux=AuxiliaryOutputConfig(),
)


Expand Down Expand Up @@ -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)

Expand Down
Loading