pymorgan 0.8.260814.dev1__py3-none-any.whl

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 (103) hide show
  1. pymorgan/__about__.py +5 -0
  2. pymorgan/__init__.py +202 -0
  3. pymorgan/cal/__init__.py +56 -0
  4. pymorgan/cal/fit.py +424 -0
  5. pymorgan/cal/load.py +366 -0
  6. pymorgan/cal/models.py +250 -0
  7. pymorgan/cal/ref_spectra/FTIR-DCM_2cm-1.csv +5801 -0
  8. pymorgan/cal/ref_spectra/FTIR-DioxaneRAL_2cm-1.csv +14935 -0
  9. pymorgan/cal/ref_spectra/FTIR-Dioxane_2cm-1.csv +2141 -0
  10. pymorgan/cal/ref_spectra/FTIR-PS_38+76/302/265m_RAL_2cm-1.csv +14936 -0
  11. pymorgan/cal/ref_spectra/FTIR-PS_38/302/265m_RAL_2cm-1.csv +14936 -0
  12. pymorgan/cal/ref_spectra/FTIR-PS_76/302/265m_RAL_2cm-1.csv +14936 -0
  13. pymorgan/cal/ref_spectra/FTIR-PolystyreneFilm_2cm-1.csv +7001 -0
  14. pymorgan/cal/ref_spectra/FTIR-PolystyreneRAL(1.5mil)_2cm-1.csv +14935 -0
  15. pymorgan/cal/ref_spectra/FTIR-PolystyreneRAL(3mil)_2cm-1.csv +14935 -0
  16. pymorgan/cal/ref_spectra/FTIR_PS50/302/265m_2cm-1.CSV +14935 -0
  17. pymorgan/cal/ref_spectra/Hoya-HY1.csv +951 -0
  18. pymorgan/cal/ref_spectra/Hoya-V11.csv +942 -0
  19. pymorgan/cal/ref_spectra/NIR-Holmium.csv +3002 -0
  20. pymorgan/cal/ref_spectra/README.md +23 -0
  21. pymorgan/cal/ref_spectra/UVVis-Holmium.csv +2403 -0
  22. pymorgan/cal/spectrum_fit.py +114 -0
  23. pymorgan/cal/utils.py +127 -0
  24. pymorgan/display.py +263 -0
  25. pymorgan/fonts.py +143 -0
  26. pymorgan/gui/__init__.py +7 -0
  27. pymorgan/gui/__main__.py +5 -0
  28. pymorgan/gui/app.py +208 -0
  29. pymorgan/gui/busy.py +139 -0
  30. pymorgan/gui/cal_canvas.py +300 -0
  31. pymorgan/gui/canvas.py +50 -0
  32. pymorgan/gui/chirp_dialog.py +316 -0
  33. pymorgan/gui/chirp_progress_dialog.py +129 -0
  34. pymorgan/gui/designer.py +26 -0
  35. pymorgan/gui/dialogs.py +338 -0
  36. pymorgan/gui/icons/PyMORGAN.jpg +0 -0
  37. pymorgan/gui/icons/done_md.png +0 -0
  38. pymorgan/gui/icons/gui.png +0 -0
  39. pymorgan/gui/icons/pirate-hat.png +0 -0
  40. pymorgan/gui/icons/pirate-hat_orig.png +0 -0
  41. pymorgan/gui/icons/refresh_md.png +0 -0
  42. pymorgan/gui/icons/save_md.png +0 -0
  43. pymorgan/gui/icons/saveas_md.png +0 -0
  44. pymorgan/gui/icons/savefile_md.png +0 -0
  45. pymorgan/gui/icons/screensearch_md.png +0 -0
  46. pymorgan/gui/icons/today_md.png +0 -0
  47. pymorgan/gui/icons/uparrow_md.png +0 -0
  48. pymorgan/gui/icons/upload_md.png +0 -0
  49. pymorgan/gui/icons/window.png +0 -0
  50. pymorgan/gui/kubo_dialog.py +1721 -0
  51. pymorgan/gui/main_window.py +1237 -0
  52. pymorgan/gui/main_window.ui +3853 -0
  53. pymorgan/gui/main_window_ui.py +2005 -0
  54. pymorgan/gui/movie_dialog.py +404 -0
  55. pymorgan/gui/mw_common.py +133 -0
  56. pymorgan/gui/picker.py +185 -0
  57. pymorgan/gui/plot_controls.py +1041 -0
  58. pymorgan/gui/settings_app.py +59 -0
  59. pymorgan/gui/settings_panel.py +177 -0
  60. pymorgan/gui/shockwave_dialog.py +294 -0
  61. pymorgan/gui/tabs/__init__.py +13 -0
  62. pymorgan/gui/tabs/browser.py +342 -0
  63. pymorgan/gui/tabs/calibration.py +1665 -0
  64. pymorgan/gui/tabs/oneD.py +1451 -0
  65. pymorgan/gui/tabs/twoD.py +3256 -0
  66. pymorgan/gui/theme.py +302 -0
  67. pymorgan/gui/twoD_gaussian_dialog.py +571 -0
  68. pymorgan/gui/twoD_integral_dialog.py +410 -0
  69. pymorgan/gui/twoD_subtraction_dialog.py +291 -0
  70. pymorgan/gui/widgetplot.py +18 -0
  71. pymorgan/gui/widgets.py +157 -0
  72. pymorgan/helpers.py +926 -0
  73. pymorgan/log.py +69 -0
  74. pymorgan/oneD/__init__.py +63 -0
  75. pymorgan/oneD/chirp.py +1752 -0
  76. pymorgan/oneD/dataset.py +1296 -0
  77. pymorgan/oneD/load.py +1677 -0
  78. pymorgan/oneD/plot.py +2548 -0
  79. pymorgan/oneD/process.py +541 -0
  80. pymorgan/oneD/registry.py +10 -0
  81. pymorgan/plot_styles/CMR_plt.mplstyle +82 -0
  82. pymorgan/plot_styles/HLV_plt.mplstyle +135 -0
  83. pymorgan/plot_styles/HLV_plt_in.mplstyle +99 -0
  84. pymorgan/plot_styles/HLV_plt_poster.mplstyle +135 -0
  85. pymorgan/plot_styles/JW_plt.mplstyle +40 -0
  86. pymorgan/settings.default.toml +248 -0
  87. pymorgan/settings.py +1377 -0
  88. pymorgan/steadyState/__init__.py +23 -0
  89. pymorgan/steadyState/dataset.py +259 -0
  90. pymorgan/steadyState/registry.py +135 -0
  91. pymorgan/twoD/__init__.py +59 -0
  92. pymorgan/twoD/analyse.py +1226 -0
  93. pymorgan/twoD/dataset.py +723 -0
  94. pymorgan/twoD/kubo_fit.py +393 -0
  95. pymorgan/twoD/load.py +679 -0
  96. pymorgan/twoD/plot.py +743 -0
  97. pymorgan/twoD/process.py +418 -0
  98. pymorgan/twoD/progress.py +128 -0
  99. pymorgan-0.8.260814.dev1.dist-info/METADATA +1045 -0
  100. pymorgan-0.8.260814.dev1.dist-info/RECORD +103 -0
  101. pymorgan-0.8.260814.dev1.dist-info/WHEEL +4 -0
  102. pymorgan-0.8.260814.dev1.dist-info/entry_points.txt +5 -0
  103. pymorgan-0.8.260814.dev1.dist-info/licenses/LICENSE +660 -0
pymorgan/__about__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.8.260814.dev1"
4
+ __author__ = "Dr. Ricardo J. Fernández-Terán"
5
+ __email__ = "Ricardo.FernandezTeran[at]unige.ch"
pymorgan/__init__.py ADDED
@@ -0,0 +1,202 @@
1
+ """PyMORGAN: plotting and analysis of ultrafast time-resolved spectroscopy data.
2
+
3
+ The 1-D branch is a load/process/plot pipeline built around
4
+ :class:`pymorgan.oneD.Dataset1D`; steady-state absorption/emission/excitation
5
+ spectra live in :mod:`pymorgan.steadyState`; presentation is centralised in
6
+ :class:`pymorgan.settings.Settings`.
7
+
8
+ import pymorgan as pm
9
+
10
+ pm.apply_style()
11
+ data = pm.load_1D("scan.pdat", data_type="PDAT")
12
+ data.background_correct(tmin=-20, tmax=-5)
13
+ data.plot_contour(Zscale=20)
14
+ data.plot_kinetics([2132, 2218], plotStyle="-")
15
+ pm.show_plots()
16
+
17
+ **Scope.** PyMORGAN loads, processes and plots; it does not fit kinetics.
18
+ Trace extraction and fitting -- multi-exponential, global and target analysis
19
+ (rate matrices, EAS/SAS) -- live in the companion project **PyRATE-TA**, which
20
+ shares this loader/dataset layer. The one piece of that seam PyMORGAN keeps is
21
+ :func:`pymorgan.oneD.plot.plot_species_spectra`, which renders fitted spectra
22
+ from plain arrays.
23
+
24
+ **Lazy public API.** The pipelines pull in matplotlib, scipy and the loader
25
+ registries, which costs roughly a second. Importing this package therefore
26
+ binds only the names below; the module that provides a name is imported the
27
+ first time that name is used (PEP 562). ``import pymorgan as pm`` is thus
28
+ nearly free, and the GUI can put its splash screen on screen before paying for
29
+ the heavy imports. Everything else -- ``from pymorgan import load_1D``,
30
+ ``pm.twoD.plot_map``, ``help(pm)`` -- behaves exactly as before.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib
36
+ from typing import TYPE_CHECKING
37
+
38
+ from .__about__ import __author__, __email__, __version__
39
+ from .log import configure_logging, get_logger
40
+
41
+ # Public name -> module that defines it. Kept in sync with ``__all__``; a new
42
+ # export needs one entry here.
43
+ _EXPORTS: dict[str, str] = {
44
+ # 1-D pipeline
45
+ "Dataset1D": ".oneD",
46
+ "available_loaders": ".oneD",
47
+ "load_1D": ".oneD",
48
+ "register_loader": ".oneD",
49
+ # 2-D pipeline
50
+ "Dataset2D": ".twoD",
51
+ "available_map_loaders": ".twoD",
52
+ "is_map_dataset_dir": ".twoD",
53
+ "load_2D": ".twoD",
54
+ "map_dataset_glob": ".twoD",
55
+ "register_map_loader": ".twoD",
56
+ # steady state
57
+ "Spectrum": ".steadyState",
58
+ "SpectrumKind": ".steadyState",
59
+ "SpectrumSeries": ".steadyState",
60
+ "available_spectrum_loaders": ".steadyState",
61
+ "load_spectrum": ".steadyState",
62
+ "register_spectrum_loader": ".steadyState",
63
+ # settings
64
+ "DeltaAUnits": ".settings",
65
+ "FreqLabel": ".settings",
66
+ "LabelStyle": ".settings",
67
+ "Settings": ".settings",
68
+ "StyleProfile": ".settings",
69
+ "TimeAxisLabel": ".settings",
70
+ "TimeAxisScale": ".settings",
71
+ "apply_style": ".settings",
72
+ "ensure_settings_file": ".settings",
73
+ "get_settings": ".settings",
74
+ "load_settings": ".settings",
75
+ "save_settings": ".settings",
76
+ "set_settings": ".settings",
77
+ "settings_path": ".settings",
78
+ "update_settings": ".settings",
79
+ "use_settings": ".settings",
80
+ # display
81
+ "add_subplot_labels": ".display",
82
+ "close_plots": ".display",
83
+ "show": ".display",
84
+ "show_plots": ".display",
85
+ }
86
+
87
+ # Submodules reachable as attributes (``pm.twoD.plot_map``) without an import.
88
+ _SUBMODULES = ("oneD", "twoD", "steadyState", "settings", "display", "helpers", "cal", "log")
89
+
90
+ if TYPE_CHECKING: # keeps type checkers and IDE completion fully informed
91
+ from .display import add_subplot_labels, close_plots, show, show_plots
92
+ from .oneD import (
93
+ Dataset1D,
94
+ available_loaders,
95
+ load_1D,
96
+ register_loader,
97
+ )
98
+ from .settings import (
99
+ DeltaAUnits,
100
+ FreqLabel,
101
+ LabelStyle,
102
+ Settings,
103
+ StyleProfile,
104
+ TimeAxisLabel,
105
+ TimeAxisScale,
106
+ apply_style,
107
+ ensure_settings_file,
108
+ get_settings,
109
+ load_settings,
110
+ save_settings,
111
+ set_settings,
112
+ settings_path,
113
+ update_settings,
114
+ use_settings,
115
+ )
116
+ from .steadyState import (
117
+ Spectrum,
118
+ SpectrumKind,
119
+ SpectrumSeries,
120
+ available_spectrum_loaders,
121
+ load_spectrum,
122
+ register_spectrum_loader,
123
+ )
124
+ from .twoD import (
125
+ Dataset2D,
126
+ available_map_loaders,
127
+ is_map_dataset_dir,
128
+ load_2D,
129
+ map_dataset_glob,
130
+ register_map_loader,
131
+ )
132
+
133
+
134
+ def __getattr__(name: str):
135
+ """Import the module providing ``name`` on first use (PEP 562)."""
136
+ if name in _SUBMODULES:
137
+ value = importlib.import_module(f".{name}", __name__)
138
+ elif name in _EXPORTS:
139
+ value = getattr(importlib.import_module(_EXPORTS[name], __name__), name)
140
+ else:
141
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
142
+ globals()[name] = value # bind it, so this happens once per name
143
+ return value
144
+
145
+
146
+ def __dir__() -> list[str]:
147
+ return sorted(set(__all__) | set(_SUBMODULES))
148
+
149
+
150
+ __all__ = [
151
+ "__version__",
152
+ "__author__",
153
+ "__email__",
154
+ # 1-D pipeline
155
+ "Dataset1D",
156
+ "load_1D",
157
+ "register_loader",
158
+ "available_loaders",
159
+ # 2-D pipeline
160
+ "Dataset2D",
161
+ "load_2D",
162
+ "register_map_loader",
163
+ "available_map_loaders",
164
+ "map_dataset_glob",
165
+ "is_map_dataset_dir",
166
+ # steady-state
167
+ "Spectrum",
168
+ "SpectrumSeries",
169
+ "SpectrumKind",
170
+ "load_spectrum",
171
+ "register_spectrum_loader",
172
+ "available_spectrum_loaders",
173
+ # settings
174
+ "Settings",
175
+ "StyleProfile",
176
+ "LabelStyle",
177
+ "DeltaAUnits",
178
+ "TimeAxisScale",
179
+ "TimeAxisLabel",
180
+ "FreqLabel",
181
+ "get_settings",
182
+ # logging
183
+ "configure_logging",
184
+ "get_logger",
185
+ "set_settings",
186
+ "update_settings",
187
+ "use_settings",
188
+ "load_settings",
189
+ "save_settings",
190
+ "settings_path",
191
+ "ensure_settings_file",
192
+ "apply_style",
193
+ # display
194
+ "add_subplot_labels",
195
+ "show_plots",
196
+ "show",
197
+ "close_plots",
198
+ ]
199
+
200
+
201
+ # Console output for informational messages, unless the host configured logging.
202
+ configure_logging()
@@ -0,0 +1,56 @@
1
+ """Spectrometer & Wavelength Calibration routines for PyMORGAN.
2
+
3
+ This package handles:
4
+ - Optical dispersion mathematical models (Grating, Polynomial Grating, Prism Sellmeier)
5
+ - Non-linear parameter optimisation and axis inversion (scipy.optimize.least_squares)
6
+ - Gaussian probe spectral distribution diagnostics (w0, FWHM)
7
+ - Reference spectra library and experimental spectrum file parsers
8
+ - Multi-detector split & merge calibration tools
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .fit import CalibrationResult, fit_wavelength_axis, get_default_calibration_params
14
+ from .load import (
15
+ ExperimentalData,
16
+ ReferenceSpectrum,
17
+ load_experimental_spectrum,
18
+ load_reference_spectrum,
19
+ )
20
+ from .models import (
21
+ fit_model_eval,
22
+ linear_grating_pixel_mapping,
23
+ n_sellmeier_sf10,
24
+ polynomial_grating_pixel_mapping,
25
+ prism_output_angle,
26
+ prism_pixel_mapping,
27
+ )
28
+ from .spectrum_fit import (
29
+ ProbeFitResult,
30
+ SpectrumFitResult,
31
+ fit_probe_spectrum,
32
+ fit_spectrum_gaussian,
33
+ )
34
+ from .utils import merge_calibration, save_calibration_file, split_calibration
35
+
36
+ __all__ = [
37
+ "CalibrationResult",
38
+ "SpectrumFitResult",
39
+ "ProbeFitResult",
40
+ "ExperimentalData",
41
+ "ReferenceSpectrum",
42
+ "fit_wavelength_axis",
43
+ "fit_spectrum_gaussian",
44
+ "fit_probe_spectrum",
45
+ "fit_model_eval",
46
+ "linear_grating_pixel_mapping",
47
+ "polynomial_grating_pixel_mapping",
48
+ "prism_pixel_mapping",
49
+ "prism_output_angle",
50
+ "n_sellmeier_sf10",
51
+ "load_reference_spectrum",
52
+ "load_experimental_spectrum",
53
+ "split_calibration",
54
+ "merge_calibration",
55
+ "save_calibration_file",
56
+ ]
pymorgan/cal/fit.py ADDED
@@ -0,0 +1,424 @@
1
+ """Spectral axis fitting engine using scipy.optimize.least_squares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NamedTuple
6
+
7
+ import numpy as np
8
+ from scipy.interpolate import interp1d
9
+ from scipy.optimize import least_squares
10
+ from scipy.signal import savgol_filter
11
+ from scipy.spatial import KDTree
12
+
13
+ from ..log import get_logger
14
+ from .models import (
15
+ SF10_REF_WL_NM,
16
+ convolve_spectrum_gaussian,
17
+ fit_model_eval,
18
+ prism_output_angle,
19
+ )
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class CalibrationResult(NamedTuple):
25
+ """Result container for spectrometer wavelength/wavenumber axis calibration."""
26
+
27
+ wavelength_nm: np.ndarray
28
+ wavenumber_cm1: np.ndarray
29
+ fit_params: np.ndarray
30
+ residuals: np.ndarray
31
+ ref_x_cut: np.ndarray
32
+ ref_y_cut: np.ndarray
33
+ model_y_fit: np.ndarray
34
+ convolved_ref_y: np.ndarray | None = None
35
+ sigma_opt: float = 0.0
36
+ exp_corr_y_fit: np.ndarray | None = None
37
+ ref_x_full: np.ndarray | None = None
38
+ ref_y_full: np.ndarray | None = None
39
+ baseline_fit: np.ndarray | None = None # Baseline evaluated over active ref_x_cut range
40
+ ref_y_corr: np.ndarray | None = None # Baseline-corrected reference spectrum (active range)
41
+
42
+
43
+ def prealign_peaks_cross_correlation(
44
+ meas_y: np.ndarray,
45
+ ref_x_full: np.ndarray,
46
+ ref_y_full: np.ndarray,
47
+ cwl: float,
48
+ ppnm_guess: float,
49
+ ) -> tuple[float, float]:
50
+ """Pre-align experimental and reference spectra via template scanning.
51
+
52
+ Scans central_wl0 (wavelength at central pixel c_pix) over a wide range and finds the offset
53
+ that maximises the normalised dot-product between the measured absorbance
54
+ and the reference resampled onto the estimated pixel grid.
55
+ """
56
+ n_pix = len(meas_y)
57
+ if n_pix < 10 or len(ref_y_full) < 10 or ppnm_guess <= 0:
58
+ return cwl, ppnm_guess
59
+
60
+ from scipy.signal import savgol_filter
61
+
62
+ c_pix = (1.0 + float(n_pix)) / 2.0
63
+ pix = np.arange(1, n_pix + 1, dtype=float)
64
+
65
+ # Lightly smooth the experimental spectrum to suppress noise before scoring
66
+ win = max(5, min(21, (n_pix // 20) | 1)) # odd window, ~5% of array length
67
+ abs_smooth = savgol_filter(meas_y, win, polyorder=2)
68
+ abs_norm = abs_smooth - np.mean(abs_smooth)
69
+ std_abs = np.std(abs_norm)
70
+ if std_abs > 0:
71
+ abs_norm = abs_norm / std_abs
72
+
73
+ # Scan central_wl0 over ±half-detector-width around cwl, at 1-pixel resolution
74
+ half_span_nm = n_pix / (2.0 * ppnm_guess) # half detector span in nm
75
+ cwl_lo = cwl - half_span_nm
76
+ cwl_hi = cwl + half_span_nm
77
+ n_steps = max(200, int(2 * n_pix)) # at least 1 step per pixel
78
+ cwl_scan = np.linspace(cwl_lo, cwl_hi, n_steps)
79
+
80
+ scores = np.empty(n_steps)
81
+ for i, wl0 in enumerate(cwl_scan):
82
+ wl_grid = (pix - c_pix) / ppnm_guess + wl0
83
+ ref_on_pix = np.interp(wl_grid, ref_x_full, ref_y_full, left=0.0, right=0.0)
84
+ ref_on_pix -= np.mean(ref_on_pix)
85
+ std_r = np.std(ref_on_pix)
86
+ if std_r > 0:
87
+ ref_on_pix /= std_r
88
+ scores[i] = float(np.dot(abs_norm, ref_on_pix))
89
+
90
+ best_cwl0 = float(cwl_scan[np.argmax(scores)])
91
+ return best_cwl0, float(ppnm_guess)
92
+
93
+
94
+ def get_default_calibration_params(cal_type_code: int) -> dict:
95
+ """Return default hardware parameters and relative nm bounds for setup (1..10)."""
96
+ defaults = {
97
+ 1: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": 1500.0, "max_wl": 2500.0, "rel_min_nm": -1000.0, "rel_max_nm": 1666.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UoS TRIR
98
+ 2: {"cwl": 535.0, "ppnm_guess": 1.1, "min_wl": 350.0, "max_wl": 740.0, "rel_min_nm": -180.0, "rel_max_nm": 210.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UniGE fsTA
99
+ 3: {"cwl": 540.0, "ppnm_guess": 1.4, "min_wl": 350.0, "max_wl": 740.0, "rel_min_nm": -180.0, "rel_max_nm": 210.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UniGE nsTA
100
+ 4: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": 1600.0, "max_wl": 2200.0, "rel_min_nm": -455.0, "rel_max_nm": 1250.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UZH Lab 2
101
+ 5: {"cwl": 1000.0, "ppnm_guess": 1.0, "min_wl": 800.0, "max_wl": 1600.0, "rel_min_nm": -200.0, "rel_max_nm": 600.0, "grating_degree": 1, "use_derivative": "1st", "use_cross_corr": False}, # UniGE NIR-TA (Prism model)
102
+ 6: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": -300.0, "max_wl": 300.0, "rel_min_nm": -653.0, "rel_max_nm": 882.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # RAL Absorbance
103
+ 7: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": -300.0, "max_wl": 300.0, "rel_min_nm": -653.0, "rel_max_nm": 882.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # RAL Intensity
104
+ 8: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": 1500.0, "max_wl": 2500.0, "rel_min_nm": -200.0, "rel_max_nm": 200.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UniGE TRIR Intensity
105
+ 9: {"cwl": 2000.0, "ppnm_guess": 0.30, "min_wl": 1500.0, "max_wl": 2500.0, "rel_min_nm": -200.0, "rel_max_nm": 200.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UniGE TRIR Absorbance
106
+ 10: {"cwl": 530.0, "ppnm_guess": 1.2, "min_wl": 320.0, "max_wl": 780.0, "rel_min_nm": -150.0, "rel_max_nm": 220.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False}, # UniGE TRUVIS-II
107
+ }
108
+ return defaults.get(cal_type_code, {"cwl": 535.0, "ppnm_guess": 1.1, "min_wl": 350.0, "max_wl": 740.0, "rel_min_nm": -180.0, "rel_max_nm": 210.0, "grating_degree": 2, "use_derivative": "1st", "use_cross_corr": False})
109
+
110
+
111
+ def fit_wavelength_axis(
112
+ meas_y: np.ndarray,
113
+ ref_x: np.ndarray,
114
+ ref_y: np.ndarray,
115
+ cal_type_code: int = 2,
116
+ cwl: float | None = None,
117
+ ppnm_guess: float | None = None,
118
+ min_wl: float | None = None,
119
+ max_wl: float | None = None,
120
+ rel_min_nm: float | None = None,
121
+ rel_max_nm: float | None = None,
122
+ do_fit: bool = True,
123
+ grating_degree: int | None = None,
124
+ use_cross_corr: bool = False,
125
+ use_derivative: str = "1st", # "none", "1st", "2nd"
126
+ savgol_window: int = 9,
127
+ convolve_ref: bool = False,
128
+ ) -> CalibrationResult:
129
+ """Optimise dispersion parameters and compute calibrated wavelength/wavenumber axes."""
130
+ meas_pixels = np.arange(1, len(meas_y) + 1, dtype=float)
131
+ n_pix = len(meas_y)
132
+
133
+ spec_defaults = get_default_calibration_params(cal_type_code)
134
+ if cwl is None:
135
+ cwl = spec_defaults["cwl"]
136
+ if ppnm_guess is None:
137
+ ppnm_guess = spec_defaults["ppnm_guess"]
138
+ if rel_min_nm is None:
139
+ rel_min_nm = spec_defaults.get("rel_min_nm", -200.0)
140
+ if rel_max_nm is None:
141
+ rel_max_nm = spec_defaults.get("rel_max_nm", 200.0)
142
+ if grating_degree is None:
143
+ grating_degree = spec_defaults.get("grating_degree", 1)
144
+
145
+ # Preserve full reference spectrum arrays before cutting
146
+ ref_x_full = np.asarray(ref_x, dtype=float).ravel().copy()
147
+ ref_y_full = np.asarray(ref_y, dtype=float).ravel().copy()
148
+
149
+ ref_x = ref_x_full
150
+ ref_y = ref_y_full
151
+
152
+ is_ir = cal_type_code in (1, 4, 6, 7, 8, 9)
153
+ if is_ir:
154
+ cwl_nm = 1e7 / cwl if cwl > 0 else 5000.0
155
+ else:
156
+ cwl_nm = cwl if cwl > 0 else 500.0
157
+
158
+ # Calculate cut bounds in nm relative to central wavelength
159
+ min_cut_nm = cwl_nm + min(rel_min_nm, rel_max_nm)
160
+ max_cut_nm = cwl_nm + max(rel_min_nm, rel_max_nm)
161
+
162
+ if is_ir:
163
+ # Convert nm bounds to wavenumber bounds (cm-1)
164
+ min_cut_cm = 1e7 / max(max_cut_nm, 100.0)
165
+ max_cut_cm = 1e7 / max(min_cut_nm, 50.0)
166
+
167
+ cut_mask = (ref_x >= min_cut_cm) & (ref_x <= max_cut_cm)
168
+ if not np.any(cut_mask):
169
+ cut_mask = np.ones_like(ref_x, dtype=bool)
170
+
171
+ ref_x_cut_native = ref_x[cut_mask]
172
+ ref_y_cut = ref_y[cut_mask]
173
+
174
+ sort_id = np.argsort(ref_x_cut_native)[::-1]
175
+ ref_x_cut_native = ref_x_cut_native[sort_id]
176
+ ref_y_cut = ref_y_cut[sort_id]
177
+ meas_y = np.flip(meas_y)
178
+
179
+ # Convert to nm for internal NLS optimisation
180
+ ref_x_cut = 1e7 / ref_x_cut_native
181
+ else:
182
+ # UV-Vis / NIR setups (ref_x in nm)
183
+ cut_mask = (ref_x >= min_cut_nm) & (ref_x <= max_cut_nm)
184
+ if not np.any(cut_mask):
185
+ cut_mask = np.ones_like(ref_x, dtype=bool)
186
+
187
+ ref_x_cut_native = ref_x[cut_mask]
188
+ ref_y_cut = ref_y[cut_mask]
189
+
190
+ sort_cut = np.argsort(ref_x_cut_native)
191
+ ref_x_cut_native = ref_x_cut_native[sort_cut]
192
+ ref_y_cut = ref_y_cut[sort_cut]
193
+ ref_x_cut = ref_x_cut_native
194
+
195
+ # Scale both active region and full reference spectrum using active region peak height
196
+ ref_max = np.max(ref_y_cut)
197
+ if ref_max > 0:
198
+ ref_y_cut = ref_y_cut / ref_max
199
+ ref_y_full = ref_y_full / ref_max
200
+
201
+ # Normalise experimental measured input array to [0, 1] range
202
+ meas_span = np.max(meas_y) - np.min(meas_y)
203
+ if meas_span > 1e-6:
204
+ meas_y = (meas_y - np.min(meas_y)) / meas_span
205
+
206
+ # Convert central wavelength to nm for dispersion estimation if in cm-1
207
+ if cal_type_code in (1, 4, 6, 7, 8, 9):
208
+ cwl_nm = 1e7 / cwl
209
+ # Convert ppnm_guess from pixels/cm-1 to pixels/nm at central wavenumber
210
+ ppnm_nm_guess = ppnm_guess * ((cwl**2) / 1e7)
211
+ else:
212
+ cwl_nm = cwl
213
+ ppnm_nm_guess = ppnm_guess
214
+
215
+ # Estimate starting reference central wavelength (central_wl0) at middle pixel
216
+ if use_cross_corr:
217
+ central_wl0, ppnm_nm_guess = prealign_peaks_cross_correlation(
218
+ meas_y, ref_x_full, ref_y_full, cwl_nm, ppnm_nm_guess
219
+ )
220
+ else:
221
+ central_wl0 = cwl_nm
222
+
223
+ # Define initial guess (p0) and bounds (LB, UB) based on setup type & grating degree
224
+ if cal_type_code == 5:
225
+ # UniGE NIR-TA Prism
226
+ p0 = np.array([248.0, 175.0, 60.0, 1.0, 0.0])
227
+ lb = np.array([100.0, 100.0, 30.0, 0.01, -0.5])
228
+ ub = np.array([370.0, 240.0, 90.0, 100.0, 0.5])
229
+ elif grating_degree == 3:
230
+ # Cubic Grating Model (e.g. TRUVIS-II) - small perturbations c2, c3 around linear model
231
+ p0 = np.array([central_wl0, ppnm_nm_guess, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
232
+ lb = np.array([central_wl0 - 150.0, 1e-4, 0.01, -5.0, -1.0, -0.5, -2.0, -1.0])
233
+ ub = np.array([central_wl0 + 150.0, 10.0, 100.0, 5.0, 1.0, 0.5, 2.0, 1.0])
234
+ elif grating_degree == 2:
235
+ # Quadratic Grating Model - small perturbation c2 around linear model
236
+ p0 = np.array([central_wl0, ppnm_nm_guess, 1.0, 0.0, 0.0, 0.0, 0.0])
237
+ lb = np.array([central_wl0 - 150.0, 1e-4, 0.01, -5.0, -1.0, -0.5, -2.0])
238
+ ub = np.array([central_wl0 + 150.0, 10.0, 100.0, 5.0, 1.0, 0.5, 2.0])
239
+ else:
240
+ # Standard Linear Grating Model (Degree 1)
241
+ p0 = np.array([central_wl0, ppnm_nm_guess, 1.0, 0.0, 0.0, 0.0])
242
+ lb = np.array([central_wl0 - 150.0, 1e-4, 0.01, -5.0, -1.0, -0.5])
243
+ ub = np.array([central_wl0 + 150.0, 10.0, 100.0, 5.0, 1.0, 0.5])
244
+
245
+ win_len = max(5, savgol_window if savgol_window % 2 == 1 else savgol_window + 1)
246
+ if win_len >= len(ref_x_cut):
247
+ win_len = len(ref_x_cut) - 1 if len(ref_x_cut) % 2 == 0 else len(ref_x_cut) - 2
248
+ win_len = max(3, win_len)
249
+
250
+ def residuals_func(params: np.ndarray) -> np.ndarray:
251
+ model_y = fit_model_eval(
252
+ params, ref_x_cut, meas_pixels, meas_y, cal_type_code, grating_degree
253
+ )
254
+ if use_derivative == "1st" and win_len >= 5:
255
+ d_model = savgol_filter(model_y, win_len, polyorder=2, deriv=1)
256
+ d_ref = savgol_filter(ref_y_cut, win_len, polyorder=2, deriv=1)
257
+ return d_model - d_ref
258
+ elif use_derivative == "2nd" and win_len >= 5:
259
+ d2_model = savgol_filter(model_y, win_len, polyorder=2, deriv=2)
260
+ d2_ref = savgol_filter(ref_y_cut, win_len, polyorder=2, deriv=2)
261
+ return d2_model - d2_ref
262
+ else:
263
+ return model_y - ref_y_cut
264
+
265
+ if do_fit:
266
+ res = least_squares(
267
+ residuals_func,
268
+ p0,
269
+ bounds=(lb, ub),
270
+ ftol=5e-7,
271
+ xtol=5e-7,
272
+ gtol=5e-7,
273
+ max_nfev=5000,
274
+ )
275
+ p_fit = res.x
276
+ else:
277
+ p_fit = p0
278
+
279
+ convolved_ref_y = None
280
+ sigma_opt = 0.0
281
+
282
+ if convolve_ref and do_fit:
283
+ def conv_refine_residuals(params_with_sigma: np.ndarray) -> np.ndarray:
284
+ p_curr = params_with_sigma[:-1]
285
+ sig_curr = params_with_sigma[-1]
286
+ m_y = fit_model_eval(
287
+ p_curr, ref_x_cut, meas_pixels, meas_y, cal_type_code, grating_degree
288
+ )
289
+ r_conv = convolve_spectrum_gaussian(ref_y_cut, sig_curr)
290
+ if use_derivative == "1st" and win_len >= 5:
291
+ d_m = savgol_filter(m_y, win_len, polyorder=2, deriv=1)
292
+ d_r = savgol_filter(r_conv, win_len, polyorder=2, deriv=1)
293
+ return d_m - d_r
294
+ elif use_derivative == "2nd" and win_len >= 5:
295
+ d2_m = savgol_filter(m_y, win_len, polyorder=2, deriv=2)
296
+ d2_r = savgol_filter(r_conv, win_len, polyorder=2, deriv=2)
297
+ return d2_m - d2_r
298
+ else:
299
+ return m_y - r_conv
300
+
301
+ p0_refine = np.append(p_fit, [2.0])
302
+ lb_refine = np.append(lb, [0.01])
303
+ ub_refine = np.append(ub, [30.0])
304
+
305
+ try:
306
+ res_conv = least_squares(
307
+ conv_refine_residuals,
308
+ p0_refine,
309
+ bounds=(lb_refine, ub_refine),
310
+ ftol=5e-7,
311
+ xtol=5e-7,
312
+ gtol=5e-7,
313
+ max_nfev=3000,
314
+ )
315
+ p_fit = res_conv.x[:-1]
316
+ sigma_opt = float(res_conv.x[-1])
317
+ convolved_ref_y = convolve_spectrum_gaussian(ref_y_cut, sigma_opt)
318
+ except Exception:
319
+ sigma_opt = 2.0
320
+ convolved_ref_y = convolve_spectrum_gaussian(ref_y_cut, sigma_opt)
321
+
322
+ # Post-fit baseline parameters (b0, b1, b2) in direct absorbance space if derivative fitting was used,
323
+ # because constant offset b0 and linear slope b1 are degenerate or unconstrained in derivative space.
324
+ if do_fit and use_derivative != "standard" and cal_type_code != 5 and len(p_fit) >= 6:
325
+ dwl = ref_x_cut - p_fit[0]
326
+ ppnm = p_fit[1]
327
+ c2 = p_fit[6] if len(p_fit) > 6 else 0.0
328
+ c3 = p_fit[7] if len(p_fit) > 7 else 0.0
329
+ c_pix = (1.0 + float(n_pix)) / 2.0
330
+ if grating_degree == 2 or len(p_fit) == 7:
331
+ pix_calc = dwl * ppnm + 1e-4 * c2 * (dwl**2) + c_pix
332
+ elif grating_degree == 3 or len(p_fit) >= 8:
333
+ pix_calc = dwl * ppnm + 1e-4 * c2 * (dwl**2) + 1e-7 * c3 * (dwl**3) + c_pix
334
+ else:
335
+ pix_calc = dwl * ppnm + c_pix
336
+
337
+ meas_interp = interp1d(
338
+ meas_pixels, meas_y, kind="linear", bounds_error=False, fill_value="extrapolate"
339
+ )
340
+ scaled_meas = meas_interp(pix_calc) * p_fit[2]
341
+ target_ref = convolved_ref_y if convolved_ref_y is not None else ref_y_cut
342
+ target_b = target_ref - scaled_meas
343
+
344
+ A = np.column_stack([np.ones_like(dwl), 1e-6 * dwl, 1e-6 * (dwl**2)])
345
+ try:
346
+ b_opt, _, _, _ = np.linalg.lstsq(A, target_b, rcond=None)
347
+ p_fit[3] = float(b_opt[0])
348
+ p_fit[4] = float(b_opt[1])
349
+ p_fit[5] = float(b_opt[2])
350
+ except Exception:
351
+ logger.debug(
352
+ "Quadratic refinement of the dispersion coefficients failed; "
353
+ "keeping the initial estimate.", exc_info=True
354
+ )
355
+
356
+ # Evaluate final model fit
357
+ model_y_fit = fit_model_eval(
358
+ p_fit, ref_x_cut, meas_pixels, meas_y, cal_type_code, grating_degree
359
+ )
360
+ residuals = model_y_fit - (convolved_ref_y if convolved_ref_y is not None else ref_y_cut)
361
+
362
+ # Invert mapping to calculate pixel -> wavelength (lambda) axis
363
+ c_pix = (1.0 + float(n_pix)) / 2.0
364
+ if cal_type_code in (1, 4, 6, 7, 8, 9):
365
+ # IR setups
366
+ lam_axis = np.flip((meas_pixels - c_pix) / p_fit[1] + p_fit[0])
367
+ elif cal_type_code == 5:
368
+ # NIR-TA Prism numerical inversion
369
+ wl_grid = 1e7 / np.linspace(5000, 20000, 15000)
370
+ th_wl = prism_output_angle(wl_grid, p_fit[2])
371
+ th_ref = prism_output_angle(SF10_REF_WL_NM, p_fit[2])
372
+ pix_grid = p_fit[0] * (1000.0 / 25.0) * np.sin(th_wl - th_ref) + p_fit[1]
373
+
374
+ tree = KDTree(pix_grid[:, None])
375
+ _, nearest_idx = tree.query(meas_pixels[:, None])
376
+ lam_axis = wl_grid[nearest_idx]
377
+ elif grating_degree >= 2:
378
+ # Higher-order Grating inversion (Degree 2 or 3)
379
+ x_grid = np.linspace(250.0, 1000.0, 20000)
380
+ dwl = x_grid - p_fit[0]
381
+ c2 = p_fit[6] if len(p_fit) > 6 else 0.0
382
+ c3 = p_fit[7] if len(p_fit) > 7 else 0.0
383
+ pix_grid = dwl * p_fit[1] + 1e-4 * c2 * (dwl**2) + 1e-7 * c3 * (dwl**3) + c_pix
384
+ lam_axis = np.interp(meas_pixels, pix_grid, x_grid)
385
+ else:
386
+ # Standard UV-Vis setups (Linear Grating)
387
+ lam_axis = (meas_pixels - c_pix) / p_fit[1] + p_fit[0]
388
+
389
+ cm_axis = 1e7 / lam_axis
390
+
391
+ # --- Baseline in reference-scale units (active fitting range only) ---
392
+ # The model is: model_y = meas_interp(pix) * scale + b0 + b1·Δλ + b2·Δλ²
393
+ # baseline_fit = the additive polynomial offset (b0 + b1·Δλ + b2·Δλ²)
394
+ # exp_corr_y_fit = model_y - baseline = meas_interp(pix) * scale (signal in ref units)
395
+ # ref_y_corr = ref - baseline (reference without the offset)
396
+ dwl_ref = ref_x_cut - p_fit[0]
397
+ b0 = p_fit[3] if len(p_fit) > 3 else 0.0
398
+ b1 = p_fit[4] if len(p_fit) > 4 else 0.0
399
+ b2 = p_fit[5] if len(p_fit) > 5 else 0.0
400
+ baseline_fit = b0 + 1e-6 * b1 * dwl_ref + 1e-6 * b2 * (dwl_ref**2)
401
+
402
+ # Baseline-corrected experimental: signal in reference scale, no offset
403
+ exp_corr_y_fit = model_y_fit - baseline_fit
404
+
405
+ # Baseline-corrected reference (offset subtracted; keep sign for proper display)
406
+ active_ref = convolved_ref_y if convolved_ref_y is not None else ref_y_cut
407
+ ref_y_corr = active_ref - baseline_fit
408
+
409
+ return CalibrationResult(
410
+ wavelength_nm=lam_axis,
411
+ wavenumber_cm1=cm_axis,
412
+ fit_params=p_fit,
413
+ residuals=residuals,
414
+ ref_x_cut=ref_x_cut_native,
415
+ ref_y_cut=ref_y_cut,
416
+ model_y_fit=model_y_fit,
417
+ convolved_ref_y=convolved_ref_y,
418
+ sigma_opt=sigma_opt,
419
+ exp_corr_y_fit=exp_corr_y_fit,
420
+ ref_x_full=ref_x_full,
421
+ ref_y_full=ref_y_full,
422
+ baseline_fit=baseline_fit,
423
+ ref_y_corr=ref_y_corr,
424
+ )