ewoksxes 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. ewoksxes-0.0.1/LICENSE.md +20 -0
  2. ewoksxes-0.0.1/PKG-INFO +116 -0
  3. ewoksxes-0.0.1/README.md +61 -0
  4. ewoksxes-0.0.1/pyproject.toml +93 -0
  5. ewoksxes-0.0.1/setup.cfg +4 -0
  6. ewoksxes-0.0.1/src/ewoksxes/__init__.py +0 -0
  7. ewoksxes-0.0.1/src/ewoksxes/tasks/__init__.py +19 -0
  8. ewoksxes-0.0.1/src/ewoksxes/tasks/calibrate_energy.py +69 -0
  9. ewoksxes-0.0.1/src/ewoksxes/tasks/combine_spectra.py +117 -0
  10. ewoksxes-0.0.1/src/ewoksxes/tasks/compute_flat_and_mask.py +176 -0
  11. ewoksxes-0.0.1/src/ewoksxes/tasks/compute_roi.py +50 -0
  12. ewoksxes-0.0.1/src/ewoksxes/tasks/fit_polynomial_2d.py +68 -0
  13. ewoksxes-0.0.1/src/ewoksxes/tasks/flat_field_correction.py +102 -0
  14. ewoksxes-0.0.1/src/ewoksxes/tasks/load_raw_data.py +66 -0
  15. ewoksxes-0.0.1/src/ewoksxes/tasks/save_flat_and_mask.py +71 -0
  16. ewoksxes-0.0.1/src/ewoksxes/tasks/save_spectrum.py +113 -0
  17. ewoksxes-0.0.1/src/ewoksxes/tasks/utils.py +33 -0
  18. ewoksxes-0.0.1/src/ewoksxes/tests/__init__.py +0 -0
  19. ewoksxes-0.0.1/src/ewoksxes/tests/conftest.py +52 -0
  20. ewoksxes-0.0.1/src/ewoksxes/tests/data/flat.edf +0 -0
  21. ewoksxes-0.0.1/src/ewoksxes/tests/data/mask.npy +0 -0
  22. ewoksxes-0.0.1/src/ewoksxes/tests/data/von_hamos_0000.h5 +0 -0
  23. ewoksxes-0.0.1/src/ewoksxes/tests/test_calibrate_energy.py +137 -0
  24. ewoksxes-0.0.1/src/ewoksxes/tests/test_combine_spectra.py +134 -0
  25. ewoksxes-0.0.1/src/ewoksxes/tests/test_compute_flat_and_mask.py +55 -0
  26. ewoksxes-0.0.1/src/ewoksxes/tests/test_compute_roi.py +90 -0
  27. ewoksxes-0.0.1/src/ewoksxes/tests/test_fit_polynomial_2d.py +31 -0
  28. ewoksxes-0.0.1/src/ewoksxes/tests/test_flat_field_correction.py +84 -0
  29. ewoksxes-0.0.1/src/ewoksxes/tests/test_flat_field_workflow.py +107 -0
  30. ewoksxes-0.0.1/src/ewoksxes/tests/test_load_raw_data.py +37 -0
  31. ewoksxes-0.0.1/src/ewoksxes/tests/test_save_flat_and_mask.py +55 -0
  32. ewoksxes-0.0.1/src/ewoksxes/tests/test_save_spectrum.py +141 -0
  33. ewoksxes-0.0.1/src/ewoksxes/tests/test_utils.py +43 -0
  34. ewoksxes-0.0.1/src/ewoksxes/tests/test_xes_calibration_workflow.py +115 -0
  35. ewoksxes-0.0.1/src/ewoksxes/tests/test_xes_processing_workflow.py +99 -0
  36. ewoksxes-0.0.1/src/ewoksxes/workflows/__init__.py +0 -0
  37. ewoksxes-0.0.1/src/ewoksxes/workflows/xes_calibration.json +55 -0
  38. ewoksxes-0.0.1/src/ewoksxes/workflows/xes_flat_field.json +63 -0
  39. ewoksxes-0.0.1/src/ewoksxes/workflows/xes_processing.json +75 -0
  40. ewoksxes-0.0.1/src/ewoksxes.egg-info/PKG-INFO +116 -0
  41. ewoksxes-0.0.1/src/ewoksxes.egg-info/SOURCES.txt +43 -0
  42. ewoksxes-0.0.1/src/ewoksxes.egg-info/dependency_links.txt +1 -0
  43. ewoksxes-0.0.1/src/ewoksxes.egg-info/entry_points.txt +2 -0
  44. ewoksxes-0.0.1/src/ewoksxes.egg-info/requires.txt +18 -0
  45. ewoksxes-0.0.1/src/ewoksxes.egg-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ # MIT License
2
+
3
+ **Copyright (c) 2025 European Synchrotron Radiation Facility**
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: ewoksxes
3
+ Version: 0.0.1
4
+ Summary: Data processing workflows for XES
5
+ Author-email: ESRF <dau-pydev@esrf.fr>
6
+ License: # MIT License
7
+
8
+ **Copyright (c) 2025 European Synchrotron Radiation Facility**
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
11
+ this software and associated documentation files (the "Software"), to deal in
12
+ the Software without restriction, including without limitation the rights to
13
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
+ the Software, and to permit persons to whom the Software is furnished to do so,
15
+ subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/
28
+ Project-URL: Documentation, https://ewoksxes.readthedocs.io/
29
+ Project-URL: Repository, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/
30
+ Project-URL: Issues, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/issues
31
+ Project-URL: Changelog, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/-/blob/main/CHANGELOG.md
32
+ Keywords: xes,ewoks
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Requires-Python: >=3.10
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE.md
39
+ Requires-Dist: ewoks
40
+ Requires-Dist: scipy
41
+ Requires-Dist: h5py
42
+ Requires-Dist: fabio
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest>=7; extra == "test"
45
+ Requires-Dist: pyqt6; extra == "test"
46
+ Provides-Extra: dev
47
+ Requires-Dist: ewoksxes[test]; extra == "dev"
48
+ Requires-Dist: ruff; extra == "dev"
49
+ Provides-Extra: doc
50
+ Requires-Dist: ewoksxes[test]; extra == "doc"
51
+ Requires-Dist: sphinx>=4.5; extra == "doc"
52
+ Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
53
+ Requires-Dist: pydata-sphinx-theme; extra == "doc"
54
+ Dynamic: license-file
55
+
56
+ # ewoksxes
57
+
58
+ The **ewoksxes** project is a Python library designed to provide workflow tasks for X-ray Emission Spectroscopy Data Processing using Ewoks (Extensible Workflow System).
59
+
60
+ ## Installation
61
+
62
+ By default, at the ESRF, `ewoksxes` should be installed on Ewoks workers using an Ansible script by the DAU team.
63
+ If you wish to install `ewoksxes` manually, ensure you have Python 3.10+ and `pip` installed. You can install the library directly from PyPI:
64
+
65
+ ```sh
66
+ pip install ewoksxes
67
+ ```
68
+
69
+ Alternatively, to install from source, clone this repository and run:
70
+
71
+ ```sh
72
+ git clone https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes.git
73
+ cd ewoksxes
74
+ pip install -e .
75
+ ```
76
+
77
+ ## Quickstart Guide
78
+
79
+ ### Running an `ewoksxes` Workflow
80
+
81
+ Most of the time, the workflow will be automatically ran from the Bliss control system.
82
+ However if you wish to execute the workflow by hand, you can use the following:
83
+
84
+ `ewoks execute workflow.json`
85
+
86
+ Some examples of workflow are found in `ewoksxes/workflows`
87
+
88
+ ## How-To Guides
89
+
90
+ For detailed instructions on various tasks, please refer to the How-To Guides in the documentation, which cover topics such as:
91
+
92
+ - Configuration of XES workflows
93
+ - Running workflows locally for testing
94
+ - Using the API to run specific tasks (e.g., calibration, combining spectra)
95
+
96
+ ## Documentation
97
+
98
+ Comprehensive documentation, including an API reference, tutorials, and conceptual explanations, can be found in the [doc directory](./doc) or online at the [ReadTheDocs page](https://ewoksxes.readthedocs.io).
99
+
100
+ ## Contributing
101
+
102
+ Contributions are welcome! To contribute, please:
103
+
104
+ 1. Clone the repository and create a new branch for your feature or fix.
105
+ 2. Write tests and ensure that the code is well-documented.
106
+ 3. Submit a merge request for review.
107
+
108
+ See the [`CONTRIBUTING.md`](./CONTRIBUTING.md) file for more details.
109
+
110
+ ## License
111
+
112
+ This project is licensed under the MIT License. See the [`LICENSE.md`](./LICENSE.md) file for details.
113
+
114
+ ## Support
115
+
116
+ If you have any questions or issues, please open an issue on the GitLab repository or contact the support team via a [data processing request ticket](https://requests.esrf.fr/plugins/servlet/desk/portal/41).
@@ -0,0 +1,61 @@
1
+ # ewoksxes
2
+
3
+ The **ewoksxes** project is a Python library designed to provide workflow tasks for X-ray Emission Spectroscopy Data Processing using Ewoks (Extensible Workflow System).
4
+
5
+ ## Installation
6
+
7
+ By default, at the ESRF, `ewoksxes` should be installed on Ewoks workers using an Ansible script by the DAU team.
8
+ If you wish to install `ewoksxes` manually, ensure you have Python 3.10+ and `pip` installed. You can install the library directly from PyPI:
9
+
10
+ ```sh
11
+ pip install ewoksxes
12
+ ```
13
+
14
+ Alternatively, to install from source, clone this repository and run:
15
+
16
+ ```sh
17
+ git clone https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes.git
18
+ cd ewoksxes
19
+ pip install -e .
20
+ ```
21
+
22
+ ## Quickstart Guide
23
+
24
+ ### Running an `ewoksxes` Workflow
25
+
26
+ Most of the time, the workflow will be automatically ran from the Bliss control system.
27
+ However if you wish to execute the workflow by hand, you can use the following:
28
+
29
+ `ewoks execute workflow.json`
30
+
31
+ Some examples of workflow are found in `ewoksxes/workflows`
32
+
33
+ ## How-To Guides
34
+
35
+ For detailed instructions on various tasks, please refer to the How-To Guides in the documentation, which cover topics such as:
36
+
37
+ - Configuration of XES workflows
38
+ - Running workflows locally for testing
39
+ - Using the API to run specific tasks (e.g., calibration, combining spectra)
40
+
41
+ ## Documentation
42
+
43
+ Comprehensive documentation, including an API reference, tutorials, and conceptual explanations, can be found in the [doc directory](./doc) or online at the [ReadTheDocs page](https://ewoksxes.readthedocs.io).
44
+
45
+ ## Contributing
46
+
47
+ Contributions are welcome! To contribute, please:
48
+
49
+ 1. Clone the repository and create a new branch for your feature or fix.
50
+ 2. Write tests and ensure that the code is well-documented.
51
+ 3. Submit a merge request for review.
52
+
53
+ See the [`CONTRIBUTING.md`](./CONTRIBUTING.md) file for more details.
54
+
55
+ ## License
56
+
57
+ This project is licensed under the MIT License. See the [`LICENSE.md`](./LICENSE.md) file for details.
58
+
59
+ ## Support
60
+
61
+ If you have any questions or issues, please open an issue on the GitLab repository or contact the support team via a [data processing request ticket](https://requests.esrf.fr/plugins/servlet/desk/portal/41).
@@ -0,0 +1,93 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=61",
4
+ ]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "ewoksxes"
9
+ version = "0.0.1"
10
+ keywords = [
11
+ "xes",
12
+ "ewoks"
13
+ ]
14
+
15
+ authors = [{name = "ESRF", email = "dau-pydev@esrf.fr"}]
16
+ description = "Data processing workflows for XES"
17
+ readme = {file = "README.md", content-type = "text/markdown"}
18
+ license = {file = "LICENSE.md"}
19
+ classifiers = [
20
+ "Intended Audience :: Science/Research",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ ]
24
+ requires-python = ">=3.10"
25
+ dependencies = [
26
+ "ewoks",
27
+ "scipy",
28
+ "h5py",
29
+ "fabio",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/"
34
+ Documentation = "https://ewoksxes.readthedocs.io/"
35
+ Repository = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/"
36
+ Issues = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/issues"
37
+ Changelog = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksxes/-/blob/main/CHANGELOG.md"
38
+
39
+ [project.optional-dependencies]
40
+ test = [
41
+ "pytest >=7",
42
+ "pyqt6",
43
+ ]
44
+ dev = [
45
+ "ewoksxes[test]",
46
+ "ruff",
47
+ ]
48
+ doc = [
49
+ "ewoksxes[test]",
50
+ "sphinx >=4.5",
51
+ "sphinx-autodoc-typehints >=1.16",
52
+ "pydata-sphinx-theme",
53
+ ]
54
+
55
+ [tool.setuptools]
56
+ package-dir = {""= "src"}
57
+
58
+ [tool.setuptools.packages.find]
59
+ where = ["src"]
60
+
61
+ [tool.setuptools.package-data]
62
+ "ewoksxes.tests.data" = ["*.h5", "*.edf", "*.npy"]
63
+ "ewoksxes.workflows" = ["*.json"]
64
+ "*" = ["*.ows", "*.png", "*.svg"]
65
+
66
+ [project.entry-points."ewoks.tasks.class"]
67
+ "ewoksxes.tasks.*" = "ewoksxes"
68
+
69
+ [tool.coverage.run]
70
+ omit = [
71
+ "*/tests/*"
72
+ ]
73
+
74
+ [tool.bandit.assert_used]
75
+ skips = ["*/test_*.py"]
76
+
77
+ [tool.ruff]
78
+ line-length = 88
79
+
80
+ [tool.ruff.lint]
81
+ select = [
82
+ "E", # pycodestyle errors
83
+ "F", # pyflakes
84
+ "I", # isort
85
+ "S", # flake8-bandit
86
+ "W", # pycodestyle warnings
87
+ ]
88
+
89
+ [tool.ruff.lint.per-file-ignores]
90
+ "**/test_*.py" = ["S101"] # Allow asserts
91
+
92
+ [tool.ruff.lint.isort]
93
+ known-first-party = ["ewoksxes"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,19 @@
1
+ from .calibrate_energy import CalibrateEnergy
2
+ from .combine_spectra import CombineSpectra
3
+ from .compute_flat_and_mask import ComputeFlatAndMask
4
+ from .compute_roi import ComputeROI
5
+ from .fit_polynomial_2d import FitPolynomial2D
6
+ from .flat_field_correction import FlatFieldCorrection
7
+ from .load_raw_data import LoadRawDataAverage
8
+ from .save_flat_and_mask import SaveFlatAndMask
9
+
10
+ __all__ = [
11
+ "CalibrateEnergy",
12
+ "CombineSpectra",
13
+ "ComputeFlatAndMask",
14
+ "ComputeROI",
15
+ "FitPolynomial2D",
16
+ "FlatFieldCorrection",
17
+ "LoadRawDataAverage",
18
+ "SaveFlatAndMask",
19
+ ]
@@ -0,0 +1,69 @@
1
+ import logging
2
+
3
+ import numpy as np
4
+ from ewokscore import Task
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class CalibrateEnergy(
10
+ Task,
11
+ input_names=["spectra", "kb_px", "vtc_px", "e_kb", "e_vtc"],
12
+ output_names=["energies", "spectra", "slope", "intercept"],
13
+ ):
14
+ """
15
+ Two-point linear calibration from pixel -> energy for each ROI.
16
+
17
+ Inputs
18
+ ------
19
+ spectra : list[np.ndarray]
20
+ One 1D spectrum per ROI.
21
+ kb_px : list[float] | np.ndarray
22
+ Pixel index of the Kβ reference for each ROI.
23
+ vtc_px : list[float] | np.ndarray
24
+ Pixel index of the VTC reference for each ROI.
25
+ e_kb : float
26
+ Known energy of the Kβ line (eV).
27
+ e_vtc : float
28
+ Known energy of the VTC line (eV).
29
+
30
+ Outputs
31
+ -------
32
+ energies : list[np.ndarray]
33
+ Energy axis per ROI (same length as its spectrum).
34
+ spectra : list[np.ndarray]
35
+ Pass-through of the input spectra (pipeline convenience).
36
+ slope : np.ndarray
37
+ ΔE / Δpixel per ROI.
38
+ intercept : np.ndarray
39
+ Intercept per ROI so that E = slope * x + intercept.
40
+ """
41
+
42
+ def run(self):
43
+ spectra = list(self.inputs.spectra)
44
+ kb = np.asarray(self.inputs.kb_px, dtype=float).ravel()
45
+ vtc = np.asarray(self.inputs.vtc_px, dtype=float).ravel()
46
+ e_kb = float(self.inputs.e_kb)
47
+ e_vtc = float(self.inputs.e_vtc)
48
+
49
+ if len(spectra) != kb.size or kb.size != vtc.size:
50
+ raise ValueError(
51
+ "Lengths must match: len(spectra) == len(kb_px) == len(vtc_px)"
52
+ )
53
+
54
+ dv = vtc - kb
55
+ if np.any(dv == 0.0):
56
+ raise ValueError("kb_px and vtc_px must differ for every ROI")
57
+
58
+ slope = (e_vtc - e_kb) / dv # shape (n_rois,)
59
+ intercept = e_kb - slope * kb # shape (n_rois,)
60
+
61
+ energies = []
62
+ for s, m, b in zip(spectra, slope, intercept):
63
+ x = np.arange(s.shape[0], dtype=float)
64
+ energies.append(m * x + b)
65
+
66
+ self.outputs.energies = energies
67
+ self.outputs.spectra = spectra
68
+ self.outputs.slope = slope
69
+ self.outputs.intercept = intercept
@@ -0,0 +1,117 @@
1
+ import logging
2
+
3
+ import numpy as np
4
+ from ewokscore import Task
5
+ from scipy.interpolate import interp1d
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class CombineSpectra(
11
+ Task,
12
+ input_names=["energies", "spectra", "energy_range", "n_points"],
13
+ optional_input_names=["normalize", "norm_range"],
14
+ output_names=["energy", "summed_spectrum"],
15
+ ):
16
+ """
17
+ Optionally normalize spectra, then interpolate each onto a common energy axis
18
+ and sum them.
19
+
20
+ Inputs
21
+ ------
22
+ - energies: list of 1D energy arrays (or lists), one per spectrum
23
+ - spectra: list of 1D spectra (same length as energies)
24
+ - energy_range: (emin, emax) -> the range of the FINAL output energy axis
25
+ - n_points: int >= 2 -> number of points on the FINAL output energy axis
26
+
27
+ Optional
28
+ --------
29
+ - normalize: bool (default False). When True, each spectrum is normalized by
30
+ its integral over 'norm_range' before interpolation/summing.
31
+ - norm_range: (emin, emax) normalization window (required when normalize=True)
32
+
33
+ Outputs
34
+ -------
35
+ - energy: np.ndarray of length n_points, spanning [emin, emax]
36
+ - summed_spectrum: np.ndarray of same length, sum of all interpolated spectra
37
+ """
38
+
39
+ def run(self):
40
+ raw_energies = self.inputs.energies
41
+ raw_spectra = self.inputs.spectra
42
+ energy_range = self.inputs.energy_range
43
+ n_points = self.inputs.n_points
44
+
45
+ # Validate and USE energy_range as the FINAL axis range
46
+ try:
47
+ emin, emax = energy_range
48
+ except (TypeError, ValueError):
49
+ raise ValueError(f"energy_range must be (emin, emax); got {energy_range}")
50
+ if emin >= emax:
51
+ raise ValueError(f"Invalid energy_range: {energy_range}")
52
+ if not isinstance(n_points, int) or n_points < 2:
53
+ raise ValueError(f"n_points must be integer >= 2, got {n_points}")
54
+
55
+ # Convert to arrays
56
+ energies_list = [np.asarray(e, dtype=float) for e in raw_energies]
57
+ spectra = [np.asarray(s, dtype=float) for s in raw_spectra]
58
+
59
+ if len(energies_list) != len(spectra):
60
+ raise ValueError("energies_list and spectra must have the same length")
61
+
62
+ # Optional normalization
63
+ if bool(self.get_input_value("normalize", False)):
64
+ norm_range = self.get_input_value("norm_range", None)
65
+ if norm_range is None or len(norm_range) != 2:
66
+ raise ValueError(
67
+ "When normalize=True, norm_range=(emin, emax) must be "
68
+ f"provided; got {norm_range}"
69
+ )
70
+ nmin, nmax = float(norm_range[0]), float(norm_range[1])
71
+
72
+ for idx, (energies, spectrum) in enumerate(zip(energies_list, spectra)):
73
+ mask = (energies >= nmin) & (energies <= nmax)
74
+ if not np.any(mask):
75
+ logger.warning(
76
+ f"Spectrum {idx}: no points in norm_range {nmin}-{nmax}; "
77
+ "leaving spectrum unchanged"
78
+ )
79
+ continue
80
+ norm_factor = np.trapezoid(spectrum[mask], energies[mask])
81
+ if norm_factor == 0 or not np.isfinite(norm_factor):
82
+ logger.warning(
83
+ f"Spectrum {idx}: invalid normalization factor "
84
+ f"{norm_factor}; leaving spectrum unchanged"
85
+ )
86
+ continue
87
+ spectra[idx] = spectrum / norm_factor
88
+ logger.info("CombineSpectra: normalization completed.")
89
+
90
+ # Build FINAL energy axis USING energy_range
91
+ energy = np.linspace(emin, emax, n_points, dtype=float)
92
+ summed = np.zeros_like(energy)
93
+
94
+ # Interpolate & sum
95
+ for idx, (energies, spectrum) in enumerate(zip(energies_list, spectra)):
96
+ if energies.shape[0] != spectrum.shape[0]:
97
+ common = min(energies.shape[0], spectrum.shape[0])
98
+ logger.warning(
99
+ f"Spectrum {idx} length {spectrum.shape[0]} and energies "
100
+ f"{energies.shape[0]} mismatch; trimming to first {common} "
101
+ "points for interpolation."
102
+ )
103
+ energies_to_use = energies[:common]
104
+ spectrum_to_use = spectrum[:common]
105
+ else:
106
+ energies_to_use = energies
107
+ spectrum_to_use = spectrum
108
+
109
+ interp = interp1d(
110
+ energies_to_use, spectrum_to_use, bounds_error=False, fill_value=0.0
111
+ )
112
+ summed += interp(energy)
113
+ logger.info(f"CombineSpectra: added spectrum {idx} to sum.")
114
+
115
+ self.outputs.energy = energy
116
+ self.outputs.summed_spectrum = summed
117
+ logger.info("CombineSpectra completed.")
@@ -0,0 +1,176 @@
1
+ import logging
2
+
3
+ import numpy as np
4
+ from ewokscore import Task
5
+
6
+ from .utils import poly2d_eval
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def _expand_columns(mask: np.ndarray, expand: int) -> np.ndarray:
12
+ """Binary dilate a column mask by ±expand columns (no extra deps)."""
13
+ if expand <= 0:
14
+ return mask
15
+ out = mask.copy()
16
+ for k in range(1, expand + 1):
17
+ out[:, k:] |= mask[:, :-k]
18
+ out[:, :-k] |= mask[:, k:]
19
+ return out
20
+
21
+
22
+ def _expand_rows(mask: np.ndarray, expand: int) -> np.ndarray:
23
+ """Binary dilate a row mask by ±expand rows (no extra deps)."""
24
+ if expand <= 0:
25
+ return mask
26
+ out = mask.copy()
27
+ for k in range(1, expand + 1):
28
+ out[k:, :] |= mask[:-k, :]
29
+ out[:-k, :] |= mask[k:, :]
30
+ return out
31
+
32
+
33
+ class ComputeFlatAndMask(
34
+ Task,
35
+ input_names=["image", "coeffs"],
36
+ optional_input_names=[
37
+ # base validity thresholds
38
+ "min_intensity", # discard pixels with raw <= this (default: 0.0)
39
+ "min_fitted", # discard where fitted surface <= this (default: 1e-6)
40
+ # gap detection via residual ratio
41
+ "ratio_threshold", # ratio=image/fitted below this => bad (default: 0.2)
42
+ # fraction of bad pixels in a column to flag gap (default: 0.8)
43
+ "column_fraction_threshold",
44
+ "row_fraction_threshold", # same for rows (default: 0.95)
45
+ "detect_columns", # default True
46
+ "detect_rows", # default False (usually gaps are vertical)
47
+ "gap_expand", # dilate gap bands by this many pixels (default: 1)
48
+ # flat output
49
+ "invert", # output flat = fitted/image if True (default True)
50
+ "clip_flat", # optional clip for flat magnitudes, e.g. (0.25, 4.0)
51
+ ],
52
+ output_names=["flat", "mask"],
53
+ ):
54
+ """
55
+ Compute flat-field and detect gap bands *automatically* from the 2D fit residuals.
56
+
57
+ Pipeline:
58
+ 1) Evaluate fitted surface S = poly2d(x,y; coeffs).
59
+ 2) Build a base invalid mask:
60
+ - image <= min_intensity OR ~finite OR S <= min_fitted
61
+ 3) Residual ratio R = image / S on valid base pixels; elsewhere treat as 0.
62
+ 4) Candidate bad pixels = base_invalid OR (R <= ratio_threshold).
63
+ 5) Gap detection (bands):
64
+ - per-column bad fraction; mark columns >= column_fraction_threshold as gaps.
65
+ - optional per-row bad fraction for horizontal bands.
66
+ - optional dilation by 'gap_expand' px.
67
+ 6) Final valid mask = NOT(gap_bands) AND NOT(base_invalid)
68
+ 7) Flat:
69
+ - if invert: flat = S / image on valid pixels (else flat = image / S)
70
+ - fill invalid pixels with 1.0 (so correction leaves them unchanged;
71
+ mask will zero them later).
72
+ - optional clipping.
73
+
74
+ Outputs:
75
+ - flat (float32, finite)
76
+ - mask (float32, 1 valid / 0 invalid). Gaps are 0 in the mask.
77
+ """
78
+
79
+ def run(self):
80
+ img = np.asarray(self.inputs.image, dtype=np.float64)
81
+ coeffs = np.asarray(self.inputs.coeffs, dtype=np.float64)
82
+
83
+ # --- Parameters & defaults
84
+ min_intensity = float(self.get_input_value("min_intensity", 0.0))
85
+ min_fitted = float(self.get_input_value("min_fitted", 1e-6))
86
+
87
+ ratio_thr = float(self.get_input_value("ratio_threshold", 0.2))
88
+ col_fr_thr = float(self.get_input_value("column_fraction_threshold", 0.8))
89
+ row_fr_thr = float(self.get_input_value("row_fraction_threshold", 0.95))
90
+ detect_columns = bool(self.get_input_value("detect_columns", True))
91
+ detect_rows = bool(self.get_input_value("detect_rows", False))
92
+ gap_expand = int(self.get_input_value("gap_expand", 1))
93
+
94
+ invert = bool(self.get_input_value("invert", True))
95
+ clip_flat = self.get_input_value("clip_flat", None) # e.g., (0.25, 4.0) or None
96
+
97
+ H, W = img.shape
98
+ y = np.arange(H, dtype=np.float64)
99
+ x = np.arange(W, dtype=np.float64)
100
+ X, Y = np.meshgrid(x, y)
101
+
102
+ # --- Evaluate fitted surface
103
+ fitted = poly2d_eval((X, Y), coeffs)
104
+
105
+ # --- Base invalid mask
106
+ base_invalid = (
107
+ (img <= min_intensity)
108
+ | ~np.isfinite(img)
109
+ | (fitted <= min_fitted)
110
+ | ~np.isfinite(fitted)
111
+ )
112
+
113
+ # --- Residual ratio
114
+ ratio = np.zeros_like(img, dtype=np.float64)
115
+ good = ~base_invalid
116
+ ratio[good] = img[good] / np.maximum(fitted[good], min_fitted)
117
+ candidate_bad = base_invalid | (ratio <= ratio_thr)
118
+
119
+ # --- Gap detection (bands)
120
+ gap_bands = np.zeros_like(candidate_bad, dtype=bool)
121
+
122
+ if detect_columns:
123
+ col_bad_frac = candidate_bad.mean(axis=0) # per-column fraction bad
124
+ gap_cols = col_bad_frac >= col_fr_thr
125
+ if gap_cols.any():
126
+ gap_bands[:, gap_cols] = True
127
+ logger.info(
128
+ "Detected %d gap columns (thr=%.2f)",
129
+ int(gap_cols.sum()),
130
+ col_fr_thr,
131
+ )
132
+
133
+ if detect_rows:
134
+ row_bad_frac = candidate_bad.mean(axis=1) # per-row fraction bad
135
+ gap_rows = row_bad_frac >= row_fr_thr
136
+ if gap_rows.any():
137
+ gap_bands[gap_rows, :] = True
138
+ logger.info(
139
+ "Detected %d gap rows (thr=%.2f)",
140
+ int(gap_rows.sum()),
141
+ row_fr_thr,
142
+ )
143
+
144
+ # --- Expand/dilate gap bands to cover edges
145
+ if detect_columns and gap_expand > 0:
146
+ gap_bands = _expand_columns(gap_bands, gap_expand)
147
+ if detect_rows and gap_expand > 0:
148
+ gap_bands = _expand_rows(gap_bands, gap_expand)
149
+
150
+ # --- Final valid mask
151
+ valid = (~gap_bands) & (~base_invalid)
152
+
153
+ # --- Flat computation
154
+ # invert=True => flat = fitted / image (typical: multiply raw by flat)
155
+ # invert=False => flat = image / fitted
156
+ eps = 1e-12
157
+ flat = np.ones_like(img, dtype=np.float64)
158
+ if invert:
159
+ flat[valid] = fitted[valid] / np.maximum(img[valid], eps)
160
+ else:
161
+ flat[valid] = img[valid] / np.maximum(fitted[valid], eps)
162
+
163
+ if clip_flat is not None:
164
+ lo, hi = clip_flat
165
+ flat = np.clip(flat, lo, hi)
166
+
167
+ # Fill invalids with 1.0 (so downstream (image*flat*mask) → masked zeros)
168
+ flat[~valid] = 1.0
169
+
170
+ self.outputs.flat = flat.astype(np.float32)
171
+ self.outputs.mask = valid.astype(np.float32)
172
+ logger.info(
173
+ "ComputeFlatAndMask: valid=%.2f%%, gaps=%.2f%%",
174
+ 100.0 * valid.mean(),
175
+ 100.0 * gap_bands.mean(),
176
+ )