Files
nappMATLAB-toolbox/beamtime/nappAddFitToPlot.m
T
bartels-rausch 2377e3b1f2 Refactor Shirley background to fix kinetic energy scaling and solver convergence
This commit replaces the numerically unstable Shirley background calculation in the peak fitting routine with a continuous, analytical Pseudo-Voigt Cumulative Distribution Function (CDF).

Previously, the solver struggled to converge and pushed peak parameters (area, width) into their absolute bounds due to two critical mathematical bugs in the objective function:

1. The 'flip' and Energy Axis Mutation Bug:
To handle kinetic energy scales (where the background steps up as energy decreases), the code mutated the energy axis (E = max(E)+100-E) inside the peak loop and attempted to use array flipping. This approach fundamentally decoupled the background step from the physical peak position. Array flipping mirrors data based on the length of the plot window, physically moving the Shirley step away from the true peak center. Furthermore, mutating E inside the loop corrupted the X-axis for subsequent peaks in multi-peak fits.

2. The Integration Cutoff ('idxregion') Bug:
The prior implementation used a hard Boolean cutoff (idxregion = (E >= pos)) to restrict the background calculation to only one side of the peak. Because Pseudo-Voigt peaks have long tails, this created a severe mathematical discontinuity (a sharp cliff) exactly at the peak center. Non-linear optimization solvers require smooth, continuous gradients to step correctly. The sharp cutoff destroyed the gradient, causing the solver to panic and lock parameters against their constraints.

The Fix:
- Removed all 'flip()' calls and energy array mutations.
- Replaced the hard cutoff with a continuous analytical CDF evaluated over the entire energy range.
- Handled binding vs. kinetic energy directionality cleanly by toggling between 'integral_fraction' and '(1 - integral_fraction)'.

Result:
The non-linear solver now receives perfectly smooth, mathematically continuous gradients, leading to rapid convergence. Peak parameters no longer crash into their bounds, and the plotted red fit line now perfectly aligns with the internally calculated solver residuals.
2026-03-22 22:07:55 +01:00

308 lines
10 KiB
Matlab

function nappAddFitToPlot(DataTable, varargin)
% nappAddFitToPlot - Plot fitted peaks with proper decomposition
% FIXED: Correct pseudo-Voigt normalization and FWHM extraction
% Parse input arguments
p = inputParser;
addOptional(p, 'tableSelection', []);
addOptional(p, 'plotHandle', gca);
addOptional(p, 'peakWidthMode', 'FWHM');
addOptional(p, 'energyScale', 'kinetic');
parse(p, varargin{:});
% Get data from table
if isempty(p.Results.tableSelection)
fitData = DataTable(1, :);
else
fitData = DataTable(p.Results.tableSelection, :);
end
% Get energy range from current figure xlim
ax = p.Results.plotHandle;
axes(ax);
xlims = xlim(ax);
energyMin = xlims(1);
energyMax = xlims(2);
% Create energy range with 0.2 eV step
energyStep = 0.2;
energyRange = (energyMin:energyStep:energyMax)';
% =====================================================================
% COUNT ONLY NON-NaN PEAKS
% =====================================================================
peakArea_vals = fitData.peakArea; % Extract from cell
validPeaks = ~isnan(peakArea_vals); % Find non-NaN values
nPeaks = sum(validPeaks); % Count valid peaks
fprintf('Found %d valid peaks (non-NaN)\n', nPeaks);
if isempty(energyRange) || nPeaks == 0
warning('No valid fit data to plot');
return;
end
% Initialize output
peakCurves = zeros(length(energyRange), nPeaks);
totalFit = zeros(size(energyRange));
peakTypes = cell(nPeaks, 1);
% Extract peak parameters (cell arrays)
peakPos_vals = fitData.peakPosition;
peakFWHM_vals = fitData.peakFWHM;
% Check for peak eta parameter (pseudo-Voigt indicator)
hasPeakEta = ismember('peakEta', fitData.Properties.VariableNames) && ...
~all(isnan(fitData.peakEta));
if hasPeakEta
peakEta_vals = fitData.peakEta;
end
% =====================================================================
% MAIN LOOP: Process each VALID peak
% =====================================================================
validIdx = find(validPeaks); % Get indices of valid peaks
for i = 1:nPeaks
iPeak = validIdx(i); % Actual peak index in array
% Extract peak parameters
pos = peakPos_vals(iPeak);
A_total = peakArea_vals(iPeak);
FWHM = peakFWHM_vals(iPeak);
% Skip if FWHM is NaN or invalid
if isnan(FWHM) || FWHM <= 0
fprintf(' WARNING: Peak %d has invalid FWHM = %g, skipping\n', iPeak, FWHM);
continue;
end
% Check if this is a pseudo-Voigt peak
if hasPeakEta && ~isnan(peakEta_vals(iPeak))
% ============================================================
% PSEUDO-VOIGT: Gaussian + Lorentzian blend
% eta = Gaussian fraction (0 = pure Lorentzian, 1 = pure Gaussian)
% ============================================================
eta = peakEta_vals(iPeak);
% === GAUSSIAN COMPONENT (FWHM-based) ===
% G(E) = A_gauss * sqrt(4*ln(2)/pi) / FWHM * exp(-4*ln(2)*(E-E0)^2/FWHM^2)
% This is normalized so integral = A_gauss
A_gauss = eta * A_total; % Gaussian gets eta fraction of total area
gauss_norm = sqrt(4*log(2)/pi) / FWHM;
peakG = A_gauss .* gauss_norm .* ...
exp(-4*log(2) * ((energyRange - pos)./FWHM).^2);
% === LORENTZIAN COMPONENT (FWHM-based) ===
% L(E) = A_lorentz * (FWHM/2) / pi / ((E-E0)^2 + (FWHM/2)^2)
% This is normalized so integral = A_lorentz
A_lorentz = (1 - eta) * A_total; % Lorentzian gets (1-eta) fraction
gamma = FWHM / 2; % Half-width at half-maximum
peakL = A_lorentz .* (gamma / pi) ./ ...
((energyRange - pos).^2 + gamma.^2);
% === COMBINED PSEUDO-VOIGT ===
peak = peakG + peakL;
peakTypes{i} = sprintf('Pseudo-Voigt (η=%.3f)', eta);
else
% ============================================================
% PURE GAUSSIAN using FWHM
% G(E) = A * sqrt(4*ln(2)/pi) / FWHM * exp(-4*ln(2)*(E-E0)^2/FWHM^2)
% ============================================================
gauss_norm = sqrt(4*log(2)/pi) / FWHM;
peak = A_total .* gauss_norm .* ...
exp(-4*log(2) * ((energyRange - pos)./FWHM).^2);
peakTypes{i} = 'Gaussian';
end
peakCurves(:, i) = peak;
totalFit = totalFit + peak;
end
% =====================================================================
% ADD BACKGROUND
% =====================================================================
% Check for Shirley background parameters
hasShirleyK = ismember('peakShirleyK', fitData.Properties.VariableNames) && ...
~all(isnan(fitData.peakShirleyK));
if hasShirleyK
% SHIRLEY BACKGROUND RECONSTRUCTION
peakShirleyK_vals = fitData.peakShirleyK;
bkgrd = zeros(size(energyRange));
for i = 1:nPeaks
iPeak = validIdx(i);
% Extract parameters
area = peakArea_vals(iPeak);
FWHM = peakFWHM_vals(iPeak);
pos = peakPos_vals(iPeak);
if ismember('peakEta', fitData.Properties.VariableNames) && ~all(isnan(fitData.peakEta))
eta = peakEta_vals(iPeak);
else
eta = 1;
end
K = peakShirleyK_vals(iPeak);
% Calculate width parameters
w = FWHM / sqrt(log(4));
sigma = w / 2;
gamma = FWHM / 2;
% Cumulative Distribution Functions (CDFs) going from 0 to 1
% This represents the integral of the peak from -infinity to E
G_cdf = 0.5 * (1 + erf((energyRange - pos) / (sqrt(2) * sigma)));
L_cdf = 0.5 + (1/pi) * atan((energyRange - pos) / gamma);
% Combined Pseudo-Voigt CDF
integral_fraction = eta * G_cdf + (1 - eta) * L_cdf;
% Shirley Contribution
if matches(p.Results.energyScale, 'kinetic')
% For Kinetic Energy: background is higher at LOWER energies.
% Step goes from 1 (at E << pos) to 0 (at E >> pos)
shirleycontrib = K * area * (1 - integral_fraction);
else
% For Binding Energy: background is higher at HIGHER energies.
% Step goes from 0 (at E << pos) to 1 (at E >> pos)
shirleycontrib = K * area * integral_fraction;
end
bkgrd = bkgrd + shirleycontrib;
end
% Add constant offset C0
if ismember('peakBackground', fitData.Properties.VariableNames)
C0 = fitData.peakBackground(2); % Second element is C0
bkgrd = bkgrd + C0;
end
backgroundType = 'Shirley';
% if hasShirleyK
% % SHIRLEY BACKGROUND RECONSTRUCTION
% peakShirleyK_vals = fitData.peakShirleyK;
%
% % Reconstruct Shirley background using the same algorithm as in fitting
% bkgrd = zeros(size(energyRange));
%
% for i = 1:nPeaks
% iPeak = validIdx(i);
%
% % Extract parameters
% area = peakArea_vals(iPeak);
% FWHM = peakFWHM_vals(iPeak);
% pos = peakPos_vals(iPeak);
% if ismember('peakEta', fitData.Properties.VariableNames) && ~all(isnan(fitData.peakEta))
% eta = peakEta_vals(iPeak);
% else
% eta = 1;
% end
% K = peakShirleyK_vals(iPeak);
%
% % Find energy points after the peak
% idxafter = energyRange >= pos;
%
% if any(idxafter)
% % Calculate width parameters
% w = FWHM / sqrt(log(4)); % Convert FWHM to Gaussian w
% sigma = w / 2;
% gamma = FWHM / 2;
%
% % Gaussian tail fraction
% Gtail = 0.5 * (1 - erf((energyRange(idxafter) - pos) / (sqrt(2) * sigma)));
%
% % Lorentzian tail fraction
% Ltail = 0.5 - (1/pi) * atan((energyRange(idxafter) - pos) / gamma);
%
% % Pseudo-Voigt tail fraction
% tailfrac = eta * Gtail + (1 - eta) * Ltail;
%
% % Shirley contribution
% shirleycontrib = K * area * (0.5 - tailfrac);
% bkgrd(idxafter) = bkgrd(idxafter) + shirleycontrib;
%
% end
%
% end
%
% % Add constant offset C0
% if ismember('peakBackground', fitData.Properties.VariableNames)
% C0 = fitData.peakBackground(2); % Second element is C0
% bkgrd = bkgrd + C0;
% end
%
% if matches(p.Results.energyScale,'kinetic')
% % This is a little work around - needs confirmation if it does what
% % it should - be careful.
% bkgrd = flip(bkgrd);
% end
%
% backgroundType = 'Shirley';
else
% LINEAR BACKGROUND (existing code)
if ismember('peakBackground', fitData.Properties.VariableNames)
bkgrd_vals = fitData.peakBackground;
if length(bkgrd_vals) >= 2
slope = bkgrd_vals(1);
intercept = bkgrd_vals(2);
else
slope = 0;
intercept = bkgrd_vals(1);
end
else
slope = 0;
intercept = 0;
end
bkgrd = slope .* energyRange + intercept;
backgroundType = sprintf('Linear (slope=%.4f, intercept=%.4f)', slope, intercept);
end
totalFit = totalFit + bkgrd;
% =====================================================================
% PLOT RESULTS
% =====================================================================
axes(ax);
hold on;
% Plot total fit (thick red line)
plot(energyRange, totalFit, '-', 'LineWidth', 2.5, 'Color', [0.8 0.2 0.2], 'DisplayName', 'Total Fit');
% Plot background (dotted gray line)
plot(energyRange, bkgrd, ':', 'LineWidth', 1.5, 'Color', [0.5 0.5 0.5], 'DisplayName', 'Background');
% Plot individual peaks (dashed colored lines)
colors = lines(nPeaks);
for i = 1:nPeaks
if any(peakCurves(:, i)) % Only plot if non-zero
plot(energyRange, peakCurves(:, i)+bkgrd, '--', 'LineWidth', 1.2, ...
'Color', colors(i,:), 'DisplayName', sprintf('Peak %d', i));
end
end
legend('show', 'Location', 'best');
hold off;
% % =====================================================================
% % DISPLAY INFORMATION
% % =====================================================================
% fprintf('\n=== FIT PLOTTED SUCCESSFULLY ===\n');
% fprintf('Valid peaks: %d\n', nPeaks);
% fprintf('Peak type(s): ');
% for i = 1:nPeaks
% fprintf('%s', peakTypes{i});
% if i < nPeaks, fprintf('; '); end
% end
% fprintf('\n');
% fprintf('Energy range: %.1f - %.1f eV\n', min(energyRange), max(energyRange));
% fprintf('=====================================\n\n');
% fprintf('Background: %s\n', backgroundType);
end