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.
- pymorgan/__about__.py +5 -0
- pymorgan/__init__.py +202 -0
- pymorgan/cal/__init__.py +56 -0
- pymorgan/cal/fit.py +424 -0
- pymorgan/cal/load.py +366 -0
- pymorgan/cal/models.py +250 -0
- pymorgan/cal/ref_spectra/FTIR-DCM_2cm-1.csv +5801 -0
- pymorgan/cal/ref_spectra/FTIR-DioxaneRAL_2cm-1.csv +14935 -0
- pymorgan/cal/ref_spectra/FTIR-Dioxane_2cm-1.csv +2141 -0
- pymorgan/cal/ref_spectra/FTIR-PS_38+76/302/265m_RAL_2cm-1.csv +14936 -0
- pymorgan/cal/ref_spectra/FTIR-PS_38/302/265m_RAL_2cm-1.csv +14936 -0
- pymorgan/cal/ref_spectra/FTIR-PS_76/302/265m_RAL_2cm-1.csv +14936 -0
- pymorgan/cal/ref_spectra/FTIR-PolystyreneFilm_2cm-1.csv +7001 -0
- pymorgan/cal/ref_spectra/FTIR-PolystyreneRAL(1.5mil)_2cm-1.csv +14935 -0
- pymorgan/cal/ref_spectra/FTIR-PolystyreneRAL(3mil)_2cm-1.csv +14935 -0
- pymorgan/cal/ref_spectra/FTIR_PS50/302/265m_2cm-1.CSV +14935 -0
- pymorgan/cal/ref_spectra/Hoya-HY1.csv +951 -0
- pymorgan/cal/ref_spectra/Hoya-V11.csv +942 -0
- pymorgan/cal/ref_spectra/NIR-Holmium.csv +3002 -0
- pymorgan/cal/ref_spectra/README.md +23 -0
- pymorgan/cal/ref_spectra/UVVis-Holmium.csv +2403 -0
- pymorgan/cal/spectrum_fit.py +114 -0
- pymorgan/cal/utils.py +127 -0
- pymorgan/display.py +263 -0
- pymorgan/fonts.py +143 -0
- pymorgan/gui/__init__.py +7 -0
- pymorgan/gui/__main__.py +5 -0
- pymorgan/gui/app.py +208 -0
- pymorgan/gui/busy.py +139 -0
- pymorgan/gui/cal_canvas.py +300 -0
- pymorgan/gui/canvas.py +50 -0
- pymorgan/gui/chirp_dialog.py +316 -0
- pymorgan/gui/chirp_progress_dialog.py +129 -0
- pymorgan/gui/designer.py +26 -0
- pymorgan/gui/dialogs.py +338 -0
- pymorgan/gui/icons/PyMORGAN.jpg +0 -0
- pymorgan/gui/icons/done_md.png +0 -0
- pymorgan/gui/icons/gui.png +0 -0
- pymorgan/gui/icons/pirate-hat.png +0 -0
- pymorgan/gui/icons/pirate-hat_orig.png +0 -0
- pymorgan/gui/icons/refresh_md.png +0 -0
- pymorgan/gui/icons/save_md.png +0 -0
- pymorgan/gui/icons/saveas_md.png +0 -0
- pymorgan/gui/icons/savefile_md.png +0 -0
- pymorgan/gui/icons/screensearch_md.png +0 -0
- pymorgan/gui/icons/today_md.png +0 -0
- pymorgan/gui/icons/uparrow_md.png +0 -0
- pymorgan/gui/icons/upload_md.png +0 -0
- pymorgan/gui/icons/window.png +0 -0
- pymorgan/gui/kubo_dialog.py +1721 -0
- pymorgan/gui/main_window.py +1237 -0
- pymorgan/gui/main_window.ui +3853 -0
- pymorgan/gui/main_window_ui.py +2005 -0
- pymorgan/gui/movie_dialog.py +404 -0
- pymorgan/gui/mw_common.py +133 -0
- pymorgan/gui/picker.py +185 -0
- pymorgan/gui/plot_controls.py +1041 -0
- pymorgan/gui/settings_app.py +59 -0
- pymorgan/gui/settings_panel.py +177 -0
- pymorgan/gui/shockwave_dialog.py +294 -0
- pymorgan/gui/tabs/__init__.py +13 -0
- pymorgan/gui/tabs/browser.py +342 -0
- pymorgan/gui/tabs/calibration.py +1665 -0
- pymorgan/gui/tabs/oneD.py +1451 -0
- pymorgan/gui/tabs/twoD.py +3256 -0
- pymorgan/gui/theme.py +302 -0
- pymorgan/gui/twoD_gaussian_dialog.py +571 -0
- pymorgan/gui/twoD_integral_dialog.py +410 -0
- pymorgan/gui/twoD_subtraction_dialog.py +291 -0
- pymorgan/gui/widgetplot.py +18 -0
- pymorgan/gui/widgets.py +157 -0
- pymorgan/helpers.py +926 -0
- pymorgan/log.py +69 -0
- pymorgan/oneD/__init__.py +63 -0
- pymorgan/oneD/chirp.py +1752 -0
- pymorgan/oneD/dataset.py +1296 -0
- pymorgan/oneD/load.py +1677 -0
- pymorgan/oneD/plot.py +2548 -0
- pymorgan/oneD/process.py +541 -0
- pymorgan/oneD/registry.py +10 -0
- pymorgan/plot_styles/CMR_plt.mplstyle +82 -0
- pymorgan/plot_styles/HLV_plt.mplstyle +135 -0
- pymorgan/plot_styles/HLV_plt_in.mplstyle +99 -0
- pymorgan/plot_styles/HLV_plt_poster.mplstyle +135 -0
- pymorgan/plot_styles/JW_plt.mplstyle +40 -0
- pymorgan/settings.default.toml +248 -0
- pymorgan/settings.py +1377 -0
- pymorgan/steadyState/__init__.py +23 -0
- pymorgan/steadyState/dataset.py +259 -0
- pymorgan/steadyState/registry.py +135 -0
- pymorgan/twoD/__init__.py +59 -0
- pymorgan/twoD/analyse.py +1226 -0
- pymorgan/twoD/dataset.py +723 -0
- pymorgan/twoD/kubo_fit.py +393 -0
- pymorgan/twoD/load.py +679 -0
- pymorgan/twoD/plot.py +743 -0
- pymorgan/twoD/process.py +418 -0
- pymorgan/twoD/progress.py +128 -0
- pymorgan-0.8.260814.dev1.dist-info/METADATA +1045 -0
- pymorgan-0.8.260814.dev1.dist-info/RECORD +103 -0
- pymorgan-0.8.260814.dev1.dist-info/WHEEL +4 -0
- pymorgan-0.8.260814.dev1.dist-info/entry_points.txt +5 -0
- pymorgan-0.8.260814.dev1.dist-info/licenses/LICENSE +660 -0
pymorgan/__about__.py
ADDED
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()
|
pymorgan/cal/__init__.py
ADDED
|
@@ -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
|
+
)
|