From cb2106f217538c5652c5977be2712285044313b4 Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Wed, 22 Jul 2026 15:58:34 +0200 Subject: [PATCH 1/4] feat: add asymmetric split gaussian shape `SplitGaussian` models an asymmetric peak as two gaussian halves sharing the apex, each with its own width (`fwhmLeft` / `fwhmRight`). Includes the analytical derivative, area/height helpers, registry wiring (`getShape1D`), and tests. --- README.md | 1 + src/index.ts | 2 + src/shapes/1d/Shape1D.ts | 8 +- src/shapes/1d/Shape1DClass.ts | 9 +- src/shapes/1d/Shape1DInstance.ts | 2 + src/shapes/1d/__tests__/getShape1D.test.ts | 14 + src/shapes/1d/getShape1D.ts | 3 + src/shapes/1d/splitGaussian/SplitGaussian.ts | 262 ++++++++++++++++++ .../SplitGaussian.derivative.test.ts | 57 ++++ .../__tests__/SplitGaussian.fct.test.ts | 26 ++ .../__tests__/SplitGaussian.shape.test.ts | 66 +++++ 11 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 src/shapes/1d/splitGaussian/SplitGaussian.ts create mode 100644 src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts create mode 100644 src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts create mode 100644 src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts diff --git a/README.md b/README.md index 0592977..9db61e8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The current supported kinds of shapes: | Lorentzian Dispersive | | | Generalized Lorentzian | | | Pseudo Voigt | | +| Split Gaussian (asymmetric) | Two gaussian halves sharing the apex: the left half (`t ≤ x`) uses `fwhmLeft`, the right half (`t > x`) uses `fwhmRight`. | where diff --git a/src/index.ts b/src/index.ts index edaa9f8..584055d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './shapes/1d/lorentzianDispersive/LorentzianDispersive.ts'; export * from './shapes/1d/pseudoVoigt/PseudoVoigt.ts'; export * from './shapes/1d/pseudoVoigtTCH/PseudoVoigtTCH.ts'; export * from './shapes/1d/generalizedLorentzian/GeneralizedLorentzian.ts'; +export * from './shapes/1d/splitGaussian/SplitGaussian.ts'; export * from './shapes/2d/gaussian2D/Gaussian2D.ts'; export * from './shapes/1d/getShape1D.ts'; @@ -17,6 +18,7 @@ export type { PseudoVoigtShape1D, PseudoVoigtTCHShape1D, Shape1D, + SplitGaussianShape1D, } from './shapes/1d/Shape1D.ts'; export type { GaussianShape2D, Shape2D } from './shapes/2d/Shape2D.ts'; export type { diff --git a/src/shapes/1d/Shape1D.ts b/src/shapes/1d/Shape1D.ts index 79135a0..bc20efd 100644 --- a/src/shapes/1d/Shape1D.ts +++ b/src/shapes/1d/Shape1D.ts @@ -3,6 +3,7 @@ import type { GeneralizedLorentzianClassOptions } from './generalizedLorentzian/ import type { LorentzianClassOptions } from './lorentzian/Lorentzian.ts'; import type { PseudoVoigtClassOptions } from './pseudoVoigt/PseudoVoigt.ts'; import type { PseudoVoigtTCHClassOptions } from './pseudoVoigtTCH/PseudoVoigtTCH.ts'; +import type { SplitGaussianClassOptions } from './splitGaussian/SplitGaussian.ts'; /** * kind of shape @@ -31,10 +32,15 @@ export interface PseudoVoigtTCHShape1D extends PseudoVoigtTCHClassOptions { kind: 'pseudoVoigtTCH'; } +export interface SplitGaussianShape1D extends SplitGaussianClassOptions { + kind: 'splitGaussian'; +} + export type Shape1D = | GaussianShape1D | LorentzianShape1D | PseudoVoigtShape1D | LorentzianDispersiveShape1D | GeneralizedLorentzianShape1D - | PseudoVoigtTCHShape1D; + | PseudoVoigtTCHShape1D + | SplitGaussianShape1D; diff --git a/src/shapes/1d/Shape1DClass.ts b/src/shapes/1d/Shape1DClass.ts index 71a74a5..b52e6ea 100644 --- a/src/shapes/1d/Shape1DClass.ts +++ b/src/shapes/1d/Shape1DClass.ts @@ -2,7 +2,14 @@ import type { DoubleArray } from 'cheminfo-types'; import type { GetData1DOptions } from './GetData1DOptions.ts'; -export type Parameter = 'fwhm' | 'mu' | 'gamma' | 'fwhmG' | 'fwhmL'; +export type Parameter = + | 'fwhm' + | 'mu' + | 'gamma' + | 'fwhmG' + | 'fwhmL' + | 'fwhmLeft' + | 'fwhmRight'; /** * The exact tuple of parameters a shape exposes. Constraining `T` to diff --git a/src/shapes/1d/Shape1DInstance.ts b/src/shapes/1d/Shape1DInstance.ts index bddaebb..b1a396d 100644 --- a/src/shapes/1d/Shape1DInstance.ts +++ b/src/shapes/1d/Shape1DInstance.ts @@ -4,6 +4,7 @@ import type { Lorentzian } from './lorentzian/Lorentzian.ts'; import type { LorentzianDispersive } from './lorentzianDispersive/LorentzianDispersive.ts'; import type { PseudoVoigt } from './pseudoVoigt/PseudoVoigt.ts'; import type { PseudoVoigtTCH } from './pseudoVoigtTCH/PseudoVoigtTCH.ts'; +import type { SplitGaussian } from './splitGaussian/SplitGaussian.ts'; export interface Shape1DKindInstanceMap { gaussian: Gaussian; @@ -12,6 +13,7 @@ export interface Shape1DKindInstanceMap { pseudoVoigtTCH: PseudoVoigtTCH; lorentzianDispersive: LorentzianDispersive; generalizedLorentzian: GeneralizedLorentzian; + splitGaussian: SplitGaussian; } export type Shape1DInstance< diff --git a/src/shapes/1d/__tests__/getShape1D.test.ts b/src/shapes/1d/__tests__/getShape1D.test.ts index 11001f6..d534a6f 100644 --- a/src/shapes/1d/__tests__/getShape1D.test.ts +++ b/src/shapes/1d/__tests__/getShape1D.test.ts @@ -4,6 +4,7 @@ import type { Shape1D } from '../Shape1D.ts'; import type { Shape1DInstance } from '../Shape1DInstance.ts'; import { Gaussian } from '../gaussian/Gaussian.ts'; import { getShape1D } from '../getShape1D.ts'; +import { SplitGaussian } from '../splitGaussian/SplitGaussian.ts'; test('returns a Gaussian instance for gaussian input', () => { const shape = getShape1D({ kind: 'gaussian', fwhm: 10 }); @@ -13,6 +14,19 @@ test('returns a Gaussian instance for gaussian input', () => { expect(shape).toBeInstanceOf(Gaussian); }); +test('returns a SplitGaussian instance for splitGaussian input', () => { + const shape = getShape1D({ + kind: 'splitGaussian', + fwhmLeft: 10, + fwhmRight: 30, + }); + + expectTypeOf(shape).toEqualTypeOf(); + + expect(shape).toBeInstanceOf(SplitGaussian); + expect(shape.fwhm).toBe(20); +}); + test('returns the broad union for widened Shape1D input', () => { const useGaussian = true as boolean; const shape: Shape1D = useGaussian diff --git a/src/shapes/1d/getShape1D.ts b/src/shapes/1d/getShape1D.ts index d1bd013..2100348 100644 --- a/src/shapes/1d/getShape1D.ts +++ b/src/shapes/1d/getShape1D.ts @@ -6,6 +6,7 @@ import { Lorentzian } from './lorentzian/Lorentzian.ts'; import { LorentzianDispersive } from './lorentzianDispersive/LorentzianDispersive.ts'; import { PseudoVoigt } from './pseudoVoigt/PseudoVoigt.ts'; import { PseudoVoigtTCH } from './pseudoVoigtTCH/PseudoVoigtTCH.ts'; +import { SplitGaussian } from './splitGaussian/SplitGaussian.ts'; /** * Generate an instance of a specific kind of shape. @@ -30,6 +31,8 @@ export function getShape1D(shape: Shape1D): Shape1DInstance { return new LorentzianDispersive(shape); case 'generalizedLorentzian': return new GeneralizedLorentzian(shape); + case 'splitGaussian': + return new SplitGaussian(shape); default: throw new Error(`Unknown distribution ${kind as string}`); } diff --git a/src/shapes/1d/splitGaussian/SplitGaussian.ts b/src/shapes/1d/splitGaussian/SplitGaussian.ts new file mode 100644 index 0000000..71660c4 --- /dev/null +++ b/src/shapes/1d/splitGaussian/SplitGaussian.ts @@ -0,0 +1,262 @@ +import { ROOT_PI_OVER_LN2 } from '../../../util/constants.ts'; +import type { GetData1DOptions } from '../GetData1DOptions.ts'; +import type { + ParameterTuple, + Shape1DClass, + Shape1DDerivative, +} from '../Shape1DClass.ts'; +import { + gaussianDerivative, + gaussianFct, + gaussianFwhmToWidth, + gaussianWidthToFWHM, + getGaussianFactor, +} from '../gaussian/Gaussian.ts'; + +export interface SplitGaussianClassOptions { + /** + * Full width at half maximum of the left half (x <= 0). + * @default 500 + */ + fwhmLeft?: number; + /** + * Full width at half maximum of the right half (x > 0). + * @default 500 + */ + fwhmRight?: number; +} + +interface CalculateSplitGaussianHeightOptions { + /** + * Full width at half maximum of the left half. + * @default 500 + */ + fwhmLeft?: number; + /** + * Full width at half maximum of the right half. + * @default 500 + */ + fwhmRight?: number; + /** + * @default 1 + */ + area?: number; +} + +interface GetSplitGaussianAreaOptions { + /** + * The maximum intensity value of the shape. + * @default 1 + */ + height?: number; + /** + * Full width at half maximum of the left half. + * @default 500 + */ + fwhmLeft?: number; + /** + * Full width at half maximum of the right half. + * @default 500 + */ + fwhmRight?: number; +} + +export class SplitGaussian implements Shape1DClass { + /** + * Full width at half maximum of the left half (x <= 0). + * @default 500 + */ + public fwhmLeft: number; + /** + * Full width at half maximum of the right half (x > 0). + * @default 500 + */ + public fwhmRight: number; + + public constructor(options: SplitGaussianClassOptions = {}) { + const { fwhmLeft = 500, fwhmRight = 500 } = options; + + this.fwhmLeft = fwhmLeft; + this.fwhmRight = fwhmRight; + } + + /** + * Full width at half maximum of the whole peak, the mean of both halves. + * A symmetric peak has no split; use a `Gaussian` for that case. + * @returns the mean full width at half maximum. + */ + public get fwhm() { + return (this.fwhmLeft + this.fwhmRight) / 2; + } + + /** + * Convert a full width at half maximum to the width between the inflection + * points, using the plain gaussian relation on the mean fwhm by default. + * + * Because that relation is linear, the value returned for the mean fwhm equals + * the true total span between the split shape's two (asymmetric) inflection + * points, `σleft + σright`. It is therefore an **aggregate**: it does not + * capture the asymmetry — `fwhmLeft: 200, fwhmRight: 600` and + * `fwhmLeft: fwhmRight: 400` yield the same width. Use `fwhmLeft` / `fwhmRight` + * directly when each half-width matters. + * @param fwhm - full width at half maximum. Defaults to the mean of both halves. + * @returns the aggregate width between the inflection points. + */ + public fwhmToWidth(fwhm = this.fwhm) { + return gaussianFwhmToWidth(fwhm); + } + + /** + * Convert a width between the inflection points back to a full width at half + * maximum, using the plain gaussian relation. + * + * This is the inverse of `fwhmToWidth` only for the **aggregate** (mean) fwhm; + * it cannot recover the individual `fwhmLeft` / `fwhmRight`, since a single + * width does not encode the asymmetry. + * @param width - width between the inflection points. + * @returns the corresponding (aggregate) full width at half maximum. + */ + public widthToFWHM(width: number) { + return gaussianWidthToFWHM(width); + } + + public fct(x: number) { + return splitGaussianFct(x, this.fwhmLeft, this.fwhmRight); + } + + public getArea( + height = calculateSplitGaussianHeight({ + fwhmLeft: this.fwhmLeft, + fwhmRight: this.fwhmRight, + }), + ) { + return getSplitGaussianArea({ + fwhmLeft: this.fwhmLeft, + fwhmRight: this.fwhmRight, + height, + }); + } + + public getFactor(area?: number) { + return getGaussianFactor(area); + } + + public getData(options: GetData1DOptions = {}) { + return getSplitGaussianData(this, options); + } + + public calculateHeight(area = 1) { + return calculateSplitGaussianHeight({ + fwhmLeft: this.fwhmLeft, + fwhmRight: this.fwhmRight, + area, + }); + } + + public getParameters(): ParameterTuple<['fwhmLeft', 'fwhmRight']> { + return ['fwhmLeft', 'fwhmRight']; + } + + public derivative(x: number): Shape1DDerivative { + const { fct, dx, dFwhmLeft, dFwhmRight } = splitGaussianDerivative( + x, + this.fwhmLeft, + this.fwhmRight, + ); + return { fct, dx, parameters: [dFwhmLeft, dFwhmRight] }; + } +} + +/** + * Calculate the peak height for a given area and both half-widths. + * @param options - fwhmLeft, fwhmRight and area. + * @returns the peak height. + */ +export function calculateSplitGaussianHeight( + options: CalculateSplitGaussianHeightOptions, +) { + const { fwhmLeft = 500, fwhmRight = 500, area = 1 } = options; + return (4 * area) / ROOT_PI_OVER_LN2 / (fwhmLeft + fwhmRight); +} + +/** + * Evaluate the split (asymmetric) gaussian function centered at x=0. + * The left half (x <= 0) uses `fwhmLeft`, the right half (x > 0) uses `fwhmRight`. + * @param x - position at which to evaluate. + * @param fwhmLeft - full width at half maximum of the left half. + * @param fwhmRight - full width at half maximum of the right half. + * @returns the intensity at x. + */ +export function splitGaussianFct( + x: number, + fwhmLeft: number, + fwhmRight: number, +) { + return x <= 0 ? gaussianFct(x, fwhmLeft) : gaussianFct(x, fwhmRight); +} + +/** + * Analytical value and partial derivatives of the split gaussian function centered at x=0. + * Each half's fwhm only affects its own side, so the off-side derivative is 0. + * @param x - position at which to evaluate. + * @param fwhmLeft - full width at half maximum of the left half. + * @param fwhmRight - full width at half maximum of the right half. + * @returns the value `fct` and its partial derivatives with respect to `x` (`dx`), `fwhmLeft` (`dFwhmLeft`) and `fwhmRight` (`dFwhmRight`). + */ +export function splitGaussianDerivative( + x: number, + fwhmLeft: number, + fwhmRight: number, +) { + if (x <= 0) { + const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmLeft); + return { fct, dx, dFwhmLeft: dFwhm, dFwhmRight: 0 }; + } + const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmRight); + return { fct, dx, dFwhmLeft: 0, dFwhmRight: dFwhm }; +} + +/** + * Calculate the area under a split gaussian peak. + * @param options - fwhmLeft, fwhmRight and height. + * @returns the area. + */ +export function getSplitGaussianArea(options: GetSplitGaussianAreaOptions) { + const { fwhmLeft = 500, fwhmRight = 500, height = 1 } = options; + return (height * ROOT_PI_OVER_LN2 * (fwhmLeft + fwhmRight)) / 4; +} + +/** + * Generate an intensity array for a split gaussian shape. + * @param shape - split gaussian shape parameters (fwhm, fwhmLeft, fwhmRight). + * @param options - sampling options (length, factor, height). + * @returns Float64Array of intensity values. + */ +export function getSplitGaussianData( + shape: SplitGaussianClassOptions = {}, + options: GetData1DOptions = {}, +) { + const { fwhmLeft = 500, fwhmRight = 500 } = shape; + + const { + factor = getGaussianFactor(), + height = calculateSplitGaussianHeight({ fwhmLeft, fwhmRight }), + } = options; + let { length } = options; + + if (!length) { + length = Math.min( + Math.ceil(Math.max(fwhmLeft, fwhmRight) * factor), + 2 ** 25 - 1, + ); + if (length % 2 === 0) length++; + } + + const center = (length - 1) / 2; + const data = new Float64Array(length); + for (let i = 0; i < length; i++) { + data[i] = splitGaussianFct(i - center, fwhmLeft, fwhmRight) * height; + } + + return data; +} diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts new file mode 100644 index 0000000..75b3fce --- /dev/null +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'vitest'; + +import { + SplitGaussian, + splitGaussianDerivative, + splitGaussianFct, +} from '../SplitGaussian.ts'; + +const fwhmLeft = 0.2; +const fwhmRight = 0.4; +const h = 1e-6; + +test('splitGaussianDerivative matches numerical derivatives on each side', () => { + for (const x of [-0.4, -0.1, 0.05, 0.25]) { + const { fct, dx, dFwhmLeft, dFwhmRight } = splitGaussianDerivative( + x, + fwhmLeft, + fwhmRight, + ); + + expect(fct).toBeCloseTo(splitGaussianFct(x, fwhmLeft, fwhmRight), 12); + + const numericalDx = + (splitGaussianFct(x + h, fwhmLeft, fwhmRight) - + splitGaussianFct(x - h, fwhmLeft, fwhmRight)) / + (2 * h); + + expect(dx).toBeCloseTo(numericalDx, 6); + + const numericalDFwhmLeft = + (splitGaussianFct(x, fwhmLeft + h, fwhmRight) - + splitGaussianFct(x, fwhmLeft - h, fwhmRight)) / + (2 * h); + + expect(dFwhmLeft).toBeCloseTo(numericalDFwhmLeft, 6); + + const numericalDFwhmRight = + (splitGaussianFct(x, fwhmLeft, fwhmRight + h) - + splitGaussianFct(x, fwhmLeft, fwhmRight - h)) / + (2 * h); + + expect(dFwhmRight).toBeCloseTo(numericalDFwhmRight, 6); + } +}); + +test('SplitGaussian.derivative returns parameters in getParameters() order', () => { + const shape = new SplitGaussian({ fwhmLeft, fwhmRight }); + const { fct, dx, parameters } = shape.derivative(0.05); + const expected = splitGaussianDerivative(0.05, fwhmLeft, fwhmRight); + + expect(shape.getParameters()).toStrictEqual(['fwhmLeft', 'fwhmRight']); + expect(fct).toBeCloseTo(expected.fct, 12); + expect(dx).toBeCloseTo(expected.dx, 12); + expect(parameters).toHaveLength(2); + expect(parameters[0]).toBeCloseTo(expected.dFwhmLeft, 12); + expect(parameters[1]).toBeCloseTo(expected.dFwhmRight, 12); +}); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts new file mode 100644 index 0000000..6076b4a --- /dev/null +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest'; + +import { SplitGaussian, splitGaussianFct } from '../SplitGaussian.ts'; + +test('SplitGaussian.fct is asymmetric around the apex', () => { + const shape = new SplitGaussian({ fwhmLeft: 0.2, fwhmRight: 0.4 }); + + expect(shape.fct(0)).toBeCloseTo(1); + // left half reaches half-max at -fwhmLeft/2 + expect(shape.fct(-0.1)).toBeCloseTo(0.5); + // right half reaches half-max at +fwhmRight/2 + expect(shape.fct(0.2)).toBeCloseTo(0.5); + // same distance, different intensity -> asymmetry + expect(shape.fct(-0.1)).toBeLessThan(shape.fct(0.1)); +}); + +test('splitGaussianFct with equal halves matches a symmetric gaussian', () => { + expect(splitGaussianFct(-0.1, 0.2, 0.2)).toBeCloseTo(0.5); + expect(splitGaussianFct(0.1, 0.2, 0.2)).toBeCloseTo(0.5); +}); + +test('fwhm getter returns the mean of both halves', () => { + const shape = new SplitGaussian({ fwhmLeft: 200, fwhmRight: 600 }); + + expect(shape.fwhm).toBe(400); +}); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts new file mode 100644 index 0000000..5f9daf1 --- /dev/null +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'vitest'; + +import { ROOT_PI_OVER_LN2 } from '../../../../util/constants.ts'; +import { + SplitGaussian, + calculateSplitGaussianHeight, + getSplitGaussianArea, +} from '../SplitGaussian.ts'; + +test('height 1, apex at the center', () => { + const shape = new SplitGaussian({ fwhmLeft: 10, fwhmRight: 30 }); + const data = shape.getData({ height: 1 }); + + const center = (data.length - 1) / 2; + + expect(data[center]).toBe(1); + // right half is wider, so it decays slower than the left half + expect(data[center + 5]).toBeGreaterThan(data[center - 5]); + + const area = data.reduce((a, b) => a + b, 0); + + expect(area).toBeCloseTo((ROOT_PI_OVER_LN2 * (10 + 30)) / 4, 2); + expect(shape.getParameters()).toStrictEqual(['fwhmLeft', 'fwhmRight']); +}); + +test('normalized area is close to 1', () => { + const shape = new SplitGaussian({ fwhmLeft: 40, fwhmRight: 60 }); + const data = shape.getData(); + + const area = data.reduce((a, b) => a + b, 0); + + expect(area).toBeCloseTo(0.9999, 2); + expect(shape.getArea()).toBeCloseTo(1, 2); +}); + +test('equal halves reduce to a symmetric gaussian area', () => { + const shape = new SplitGaussian({ fwhmLeft: 50, fwhmRight: 50 }); + + expect(shape.fwhm).toBe(50); + expect(getSplitGaussianArea({ fwhmLeft: 50, fwhmRight: 50 })).toBeCloseTo( + (ROOT_PI_OVER_LN2 * 50) / 2, + 6, + ); +}); + +test('height calculation is consistent with the area', () => { + const shape = new SplitGaussian({ fwhmLeft: 100, fwhmRight: 300 }); + const height = shape.calculateHeight(); + const expected = calculateSplitGaussianHeight({ + fwhmLeft: 100, + fwhmRight: 300, + area: 1, + }); + + expect(height).toBeCloseTo(expected, 6); + expect( + getSplitGaussianArea({ fwhmLeft: 100, fwhmRight: 300, height }), + ).toBeCloseTo(1, 6); +}); + +test('change height should change area proportionally', () => { + const shape = new SplitGaussian({ fwhmLeft: 100, fwhmRight: 200 }); + const area = shape.getArea(1); + + expect(shape.getArea(2)).toBeCloseTo(2 * area, 4); +}); From 0f7955421244cbc09ccb214d8155fcc57271d56b Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Wed, 22 Jul 2026 16:11:07 +0200 Subject: [PATCH 2/4] chore: fix prettier by removing / reinstalling local node_modules and npm run prettier-write --- src/shapes/1d/Shape1DClass.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/shapes/1d/Shape1DClass.ts b/src/shapes/1d/Shape1DClass.ts index b52e6ea..33d72d3 100644 --- a/src/shapes/1d/Shape1DClass.ts +++ b/src/shapes/1d/Shape1DClass.ts @@ -3,13 +3,7 @@ import type { DoubleArray } from 'cheminfo-types'; import type { GetData1DOptions } from './GetData1DOptions.ts'; export type Parameter = - | 'fwhm' - | 'mu' - | 'gamma' - | 'fwhmG' - | 'fwhmL' - | 'fwhmLeft' - | 'fwhmRight'; + 'fwhm' | 'mu' | 'gamma' | 'fwhmG' | 'fwhmL' | 'fwhmLeft' | 'fwhmRight'; /** * The exact tuple of parameters a shape exposes. Constraining `T` to From a643495fe8ec92477435a2df8974da87f9775541 Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Wed, 22 Jul 2026 16:43:19 +0200 Subject: [PATCH 3/4] chore: rename parameters to fwhmLow and fhwmHigh would be too confusing in NMR --- README.md | 2 +- src/shapes/1d/Shape1DClass.ts | 2 +- src/shapes/1d/__tests__/getShape1D.test.ts | 4 +- src/shapes/1d/splitGaussian/SplitGaussian.ts | 128 +++++++++--------- .../SplitGaussian.derivative.test.ts | 42 +++--- .../__tests__/SplitGaussian.fct.test.ts | 8 +- .../__tests__/SplitGaussian.shape.test.ts | 22 +-- 7 files changed, 102 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 9db61e8..b4fc094 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The current supported kinds of shapes: | Lorentzian Dispersive | | | Generalized Lorentzian | | | Pseudo Voigt | | -| Split Gaussian (asymmetric) | Two gaussian halves sharing the apex: the left half (`t ≤ x`) uses `fwhmLeft`, the right half (`t > x`) uses `fwhmRight`. | +| Split Gaussian (asymmetric) | Two gaussian halves sharing the apex: the lower-x half (`t ≤ x`) uses `fwhmLow`, the higher-x half (`t > x`) uses `fwhmHigh`. | where diff --git a/src/shapes/1d/Shape1DClass.ts b/src/shapes/1d/Shape1DClass.ts index 33d72d3..bf410de 100644 --- a/src/shapes/1d/Shape1DClass.ts +++ b/src/shapes/1d/Shape1DClass.ts @@ -3,7 +3,7 @@ import type { DoubleArray } from 'cheminfo-types'; import type { GetData1DOptions } from './GetData1DOptions.ts'; export type Parameter = - 'fwhm' | 'mu' | 'gamma' | 'fwhmG' | 'fwhmL' | 'fwhmLeft' | 'fwhmRight'; + 'fwhm' | 'mu' | 'gamma' | 'fwhmG' | 'fwhmL' | 'fwhmLow' | 'fwhmHigh'; /** * The exact tuple of parameters a shape exposes. Constraining `T` to diff --git a/src/shapes/1d/__tests__/getShape1D.test.ts b/src/shapes/1d/__tests__/getShape1D.test.ts index d534a6f..fd10bbb 100644 --- a/src/shapes/1d/__tests__/getShape1D.test.ts +++ b/src/shapes/1d/__tests__/getShape1D.test.ts @@ -17,8 +17,8 @@ test('returns a Gaussian instance for gaussian input', () => { test('returns a SplitGaussian instance for splitGaussian input', () => { const shape = getShape1D({ kind: 'splitGaussian', - fwhmLeft: 10, - fwhmRight: 30, + fwhmLow: 10, + fwhmHigh: 30, }); expectTypeOf(shape).toEqualTypeOf(); diff --git a/src/shapes/1d/splitGaussian/SplitGaussian.ts b/src/shapes/1d/splitGaussian/SplitGaussian.ts index 71660c4..8102bb5 100644 --- a/src/shapes/1d/splitGaussian/SplitGaussian.ts +++ b/src/shapes/1d/splitGaussian/SplitGaussian.ts @@ -15,28 +15,28 @@ import { export interface SplitGaussianClassOptions { /** - * Full width at half maximum of the left half (x <= 0). + * Full width at half maximum of the lower-x half (x <= 0). * @default 500 */ - fwhmLeft?: number; + fwhmLow?: number; /** - * Full width at half maximum of the right half (x > 0). + * Full width at half maximum of the higher-x half (x > 0). * @default 500 */ - fwhmRight?: number; + fwhmHigh?: number; } interface CalculateSplitGaussianHeightOptions { /** - * Full width at half maximum of the left half. + * Full width at half maximum of the lower-x half. * @default 500 */ - fwhmLeft?: number; + fwhmLow?: number; /** - * Full width at half maximum of the right half. + * Full width at half maximum of the higher-x half. * @default 500 */ - fwhmRight?: number; + fwhmHigh?: number; /** * @default 1 */ @@ -50,34 +50,34 @@ interface GetSplitGaussianAreaOptions { */ height?: number; /** - * Full width at half maximum of the left half. + * Full width at half maximum of the lower-x half. * @default 500 */ - fwhmLeft?: number; + fwhmLow?: number; /** - * Full width at half maximum of the right half. + * Full width at half maximum of the higher-x half. * @default 500 */ - fwhmRight?: number; + fwhmHigh?: number; } export class SplitGaussian implements Shape1DClass { /** - * Full width at half maximum of the left half (x <= 0). + * Full width at half maximum of the lower-x half (x <= 0). * @default 500 */ - public fwhmLeft: number; + public fwhmLow: number; /** - * Full width at half maximum of the right half (x > 0). + * Full width at half maximum of the higher-x half (x > 0). * @default 500 */ - public fwhmRight: number; + public fwhmHigh: number; public constructor(options: SplitGaussianClassOptions = {}) { - const { fwhmLeft = 500, fwhmRight = 500 } = options; + const { fwhmLow = 500, fwhmHigh = 500 } = options; - this.fwhmLeft = fwhmLeft; - this.fwhmRight = fwhmRight; + this.fwhmLow = fwhmLow; + this.fwhmHigh = fwhmHigh; } /** @@ -86,7 +86,7 @@ export class SplitGaussian implements Shape1DClass { * @returns the mean full width at half maximum. */ public get fwhm() { - return (this.fwhmLeft + this.fwhmRight) / 2; + return (this.fwhmLow + this.fwhmHigh) / 2; } /** @@ -95,9 +95,9 @@ export class SplitGaussian implements Shape1DClass { * * Because that relation is linear, the value returned for the mean fwhm equals * the true total span between the split shape's two (asymmetric) inflection - * points, `σleft + σright`. It is therefore an **aggregate**: it does not - * capture the asymmetry — `fwhmLeft: 200, fwhmRight: 600` and - * `fwhmLeft: fwhmRight: 400` yield the same width. Use `fwhmLeft` / `fwhmRight` + * points, `σlow + σhigh`. It is therefore an **aggregate**: it does not + * capture the asymmetry — `fwhmLow: 200, fwhmHigh: 600` and + * `fwhmLow: fwhmHigh: 400` yield the same width. Use `fwhmLow` / `fwhmHigh` * directly when each half-width matters. * @param fwhm - full width at half maximum. Defaults to the mean of both halves. * @returns the aggregate width between the inflection points. @@ -111,7 +111,7 @@ export class SplitGaussian implements Shape1DClass { * maximum, using the plain gaussian relation. * * This is the inverse of `fwhmToWidth` only for the **aggregate** (mean) fwhm; - * it cannot recover the individual `fwhmLeft` / `fwhmRight`, since a single + * it cannot recover the individual `fwhmLow` / `fwhmHigh`, since a single * width does not encode the asymmetry. * @param width - width between the inflection points. * @returns the corresponding (aggregate) full width at half maximum. @@ -121,18 +121,18 @@ export class SplitGaussian implements Shape1DClass { } public fct(x: number) { - return splitGaussianFct(x, this.fwhmLeft, this.fwhmRight); + return splitGaussianFct(x, this.fwhmLow, this.fwhmHigh); } public getArea( height = calculateSplitGaussianHeight({ - fwhmLeft: this.fwhmLeft, - fwhmRight: this.fwhmRight, + fwhmLow: this.fwhmLow, + fwhmHigh: this.fwhmHigh, }), ) { return getSplitGaussianArea({ - fwhmLeft: this.fwhmLeft, - fwhmRight: this.fwhmRight, + fwhmLow: this.fwhmLow, + fwhmHigh: this.fwhmHigh, height, }); } @@ -147,88 +147,84 @@ export class SplitGaussian implements Shape1DClass { public calculateHeight(area = 1) { return calculateSplitGaussianHeight({ - fwhmLeft: this.fwhmLeft, - fwhmRight: this.fwhmRight, + fwhmLow: this.fwhmLow, + fwhmHigh: this.fwhmHigh, area, }); } - public getParameters(): ParameterTuple<['fwhmLeft', 'fwhmRight']> { - return ['fwhmLeft', 'fwhmRight']; + public getParameters(): ParameterTuple<['fwhmLow', 'fwhmHigh']> { + return ['fwhmLow', 'fwhmHigh']; } public derivative(x: number): Shape1DDerivative { - const { fct, dx, dFwhmLeft, dFwhmRight } = splitGaussianDerivative( + const { fct, dx, dFwhmLow, dFwhmHigh } = splitGaussianDerivative( x, - this.fwhmLeft, - this.fwhmRight, + this.fwhmLow, + this.fwhmHigh, ); - return { fct, dx, parameters: [dFwhmLeft, dFwhmRight] }; + return { fct, dx, parameters: [dFwhmLow, dFwhmHigh] }; } } /** * Calculate the peak height for a given area and both half-widths. - * @param options - fwhmLeft, fwhmRight and area. + * @param options - fwhmLow, fwhmHigh and area. * @returns the peak height. */ export function calculateSplitGaussianHeight( options: CalculateSplitGaussianHeightOptions, ) { - const { fwhmLeft = 500, fwhmRight = 500, area = 1 } = options; - return (4 * area) / ROOT_PI_OVER_LN2 / (fwhmLeft + fwhmRight); + const { fwhmLow = 500, fwhmHigh = 500, area = 1 } = options; + return (4 * area) / ROOT_PI_OVER_LN2 / (fwhmLow + fwhmHigh); } /** * Evaluate the split (asymmetric) gaussian function centered at x=0. - * The left half (x <= 0) uses `fwhmLeft`, the right half (x > 0) uses `fwhmRight`. + * The lower-x half (x <= 0) uses `fwhmLow`, the higher-x half (x > 0) uses `fwhmHigh`. * @param x - position at which to evaluate. - * @param fwhmLeft - full width at half maximum of the left half. - * @param fwhmRight - full width at half maximum of the right half. + * @param fwhmLow - full width at half maximum of the lower-x half. + * @param fwhmHigh - full width at half maximum of the higher-x half. * @returns the intensity at x. */ -export function splitGaussianFct( - x: number, - fwhmLeft: number, - fwhmRight: number, -) { - return x <= 0 ? gaussianFct(x, fwhmLeft) : gaussianFct(x, fwhmRight); +export function splitGaussianFct(x: number, fwhmLow: number, fwhmHigh: number) { + return x <= 0 ? gaussianFct(x, fwhmLow) : gaussianFct(x, fwhmHigh); } /** * Analytical value and partial derivatives of the split gaussian function centered at x=0. * Each half's fwhm only affects its own side, so the off-side derivative is 0. * @param x - position at which to evaluate. - * @param fwhmLeft - full width at half maximum of the left half. - * @param fwhmRight - full width at half maximum of the right half. - * @returns the value `fct` and its partial derivatives with respect to `x` (`dx`), `fwhmLeft` (`dFwhmLeft`) and `fwhmRight` (`dFwhmRight`). + * @param fwhmLow - full width at half maximum of the lower-x half. + * @param fwhmHigh - full width at half maximum of the higher-x half. + * @returns the value `fct` and its partial derivatives with respect to `x` (`dx`), `fwhmLow` (`dFwhmLow`) and `fwhmHigh` (`dFwhmHigh`). */ export function splitGaussianDerivative( x: number, - fwhmLeft: number, - fwhmRight: number, + fwhmLow: number, + fwhmHigh: number, ) { if (x <= 0) { - const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmLeft); - return { fct, dx, dFwhmLeft: dFwhm, dFwhmRight: 0 }; + const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmLow); + return { fct, dx, dFwhmLow: dFwhm, dFwhmHigh: 0 }; } - const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmRight); - return { fct, dx, dFwhmLeft: 0, dFwhmRight: dFwhm }; + const { fct, dx, dFwhm } = gaussianDerivative(x, fwhmHigh); + return { fct, dx, dFwhmLow: 0, dFwhmHigh: dFwhm }; } /** * Calculate the area under a split gaussian peak. - * @param options - fwhmLeft, fwhmRight and height. + * @param options - fwhmLow, fwhmHigh and height. * @returns the area. */ export function getSplitGaussianArea(options: GetSplitGaussianAreaOptions) { - const { fwhmLeft = 500, fwhmRight = 500, height = 1 } = options; - return (height * ROOT_PI_OVER_LN2 * (fwhmLeft + fwhmRight)) / 4; + const { fwhmLow = 500, fwhmHigh = 500, height = 1 } = options; + return (height * ROOT_PI_OVER_LN2 * (fwhmLow + fwhmHigh)) / 4; } /** * Generate an intensity array for a split gaussian shape. - * @param shape - split gaussian shape parameters (fwhm, fwhmLeft, fwhmRight). + * @param shape - split gaussian shape parameters (fwhm, fwhmLow, fwhmHigh). * @param options - sampling options (length, factor, height). * @returns Float64Array of intensity values. */ @@ -236,17 +232,17 @@ export function getSplitGaussianData( shape: SplitGaussianClassOptions = {}, options: GetData1DOptions = {}, ) { - const { fwhmLeft = 500, fwhmRight = 500 } = shape; + const { fwhmLow = 500, fwhmHigh = 500 } = shape; const { factor = getGaussianFactor(), - height = calculateSplitGaussianHeight({ fwhmLeft, fwhmRight }), + height = calculateSplitGaussianHeight({ fwhmLow, fwhmHigh }), } = options; let { length } = options; if (!length) { length = Math.min( - Math.ceil(Math.max(fwhmLeft, fwhmRight) * factor), + Math.ceil(Math.max(fwhmLow, fwhmHigh) * factor), 2 ** 25 - 1, ); if (length % 2 === 0) length++; @@ -255,7 +251,7 @@ export function getSplitGaussianData( const center = (length - 1) / 2; const data = new Float64Array(length); for (let i = 0; i < length; i++) { - data[i] = splitGaussianFct(i - center, fwhmLeft, fwhmRight) * height; + data[i] = splitGaussianFct(i - center, fwhmLow, fwhmHigh) * height; } return data; diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts index 75b3fce..155e0e9 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts @@ -6,52 +6,52 @@ import { splitGaussianFct, } from '../SplitGaussian.ts'; -const fwhmLeft = 0.2; -const fwhmRight = 0.4; +const fwhmLow = 0.2; +const fwhmHigh = 0.4; const h = 1e-6; test('splitGaussianDerivative matches numerical derivatives on each side', () => { for (const x of [-0.4, -0.1, 0.05, 0.25]) { - const { fct, dx, dFwhmLeft, dFwhmRight } = splitGaussianDerivative( + const { fct, dx, dFwhmLow, dFwhmHigh } = splitGaussianDerivative( x, - fwhmLeft, - fwhmRight, + fwhmLow, + fwhmHigh, ); - expect(fct).toBeCloseTo(splitGaussianFct(x, fwhmLeft, fwhmRight), 12); + expect(fct).toBeCloseTo(splitGaussianFct(x, fwhmLow, fwhmHigh), 12); const numericalDx = - (splitGaussianFct(x + h, fwhmLeft, fwhmRight) - - splitGaussianFct(x - h, fwhmLeft, fwhmRight)) / + (splitGaussianFct(x + h, fwhmLow, fwhmHigh) - + splitGaussianFct(x - h, fwhmLow, fwhmHigh)) / (2 * h); expect(dx).toBeCloseTo(numericalDx, 6); - const numericalDFwhmLeft = - (splitGaussianFct(x, fwhmLeft + h, fwhmRight) - - splitGaussianFct(x, fwhmLeft - h, fwhmRight)) / + const numericalDFwhmLow = + (splitGaussianFct(x, fwhmLow + h, fwhmHigh) - + splitGaussianFct(x, fwhmLow - h, fwhmHigh)) / (2 * h); - expect(dFwhmLeft).toBeCloseTo(numericalDFwhmLeft, 6); + expect(dFwhmLow).toBeCloseTo(numericalDFwhmLow, 6); - const numericalDFwhmRight = - (splitGaussianFct(x, fwhmLeft, fwhmRight + h) - - splitGaussianFct(x, fwhmLeft, fwhmRight - h)) / + const numericalDFwhmHigh = + (splitGaussianFct(x, fwhmLow, fwhmHigh + h) - + splitGaussianFct(x, fwhmLow, fwhmHigh - h)) / (2 * h); - expect(dFwhmRight).toBeCloseTo(numericalDFwhmRight, 6); + expect(dFwhmHigh).toBeCloseTo(numericalDFwhmHigh, 6); } }); test('SplitGaussian.derivative returns parameters in getParameters() order', () => { - const shape = new SplitGaussian({ fwhmLeft, fwhmRight }); + const shape = new SplitGaussian({ fwhmLow, fwhmHigh }); const { fct, dx, parameters } = shape.derivative(0.05); - const expected = splitGaussianDerivative(0.05, fwhmLeft, fwhmRight); + const expected = splitGaussianDerivative(0.05, fwhmLow, fwhmHigh); - expect(shape.getParameters()).toStrictEqual(['fwhmLeft', 'fwhmRight']); + expect(shape.getParameters()).toStrictEqual(['fwhmLow', 'fwhmHigh']); expect(fct).toBeCloseTo(expected.fct, 12); expect(dx).toBeCloseTo(expected.dx, 12); expect(parameters).toHaveLength(2); - expect(parameters[0]).toBeCloseTo(expected.dFwhmLeft, 12); - expect(parameters[1]).toBeCloseTo(expected.dFwhmRight, 12); + expect(parameters[0]).toBeCloseTo(expected.dFwhmLow, 12); + expect(parameters[1]).toBeCloseTo(expected.dFwhmHigh, 12); }); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts index 6076b4a..d598991 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts @@ -3,12 +3,12 @@ import { expect, test } from 'vitest'; import { SplitGaussian, splitGaussianFct } from '../SplitGaussian.ts'; test('SplitGaussian.fct is asymmetric around the apex', () => { - const shape = new SplitGaussian({ fwhmLeft: 0.2, fwhmRight: 0.4 }); + const shape = new SplitGaussian({ fwhmLow: 0.2, fwhmHigh: 0.4 }); expect(shape.fct(0)).toBeCloseTo(1); - // left half reaches half-max at -fwhmLeft/2 + // lower-x half reaches half-max at -fwhmLow/2 expect(shape.fct(-0.1)).toBeCloseTo(0.5); - // right half reaches half-max at +fwhmRight/2 + // higher-x half reaches half-max at +fwhmHigh/2 expect(shape.fct(0.2)).toBeCloseTo(0.5); // same distance, different intensity -> asymmetry expect(shape.fct(-0.1)).toBeLessThan(shape.fct(0.1)); @@ -20,7 +20,7 @@ test('splitGaussianFct with equal halves matches a symmetric gaussian', () => { }); test('fwhm getter returns the mean of both halves', () => { - const shape = new SplitGaussian({ fwhmLeft: 200, fwhmRight: 600 }); + const shape = new SplitGaussian({ fwhmLow: 200, fwhmHigh: 600 }); expect(shape.fwhm).toBe(400); }); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts index 5f9daf1..67dcf6e 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts @@ -8,23 +8,23 @@ import { } from '../SplitGaussian.ts'; test('height 1, apex at the center', () => { - const shape = new SplitGaussian({ fwhmLeft: 10, fwhmRight: 30 }); + const shape = new SplitGaussian({ fwhmLow: 10, fwhmHigh: 30 }); const data = shape.getData({ height: 1 }); const center = (data.length - 1) / 2; expect(data[center]).toBe(1); - // right half is wider, so it decays slower than the left half + // higher-x half is wider, so it decays slower than the lower-x half expect(data[center + 5]).toBeGreaterThan(data[center - 5]); const area = data.reduce((a, b) => a + b, 0); expect(area).toBeCloseTo((ROOT_PI_OVER_LN2 * (10 + 30)) / 4, 2); - expect(shape.getParameters()).toStrictEqual(['fwhmLeft', 'fwhmRight']); + expect(shape.getParameters()).toStrictEqual(['fwhmLow', 'fwhmHigh']); }); test('normalized area is close to 1', () => { - const shape = new SplitGaussian({ fwhmLeft: 40, fwhmRight: 60 }); + const shape = new SplitGaussian({ fwhmLow: 40, fwhmHigh: 60 }); const data = shape.getData(); const area = data.reduce((a, b) => a + b, 0); @@ -34,32 +34,32 @@ test('normalized area is close to 1', () => { }); test('equal halves reduce to a symmetric gaussian area', () => { - const shape = new SplitGaussian({ fwhmLeft: 50, fwhmRight: 50 }); + const shape = new SplitGaussian({ fwhmLow: 50, fwhmHigh: 50 }); expect(shape.fwhm).toBe(50); - expect(getSplitGaussianArea({ fwhmLeft: 50, fwhmRight: 50 })).toBeCloseTo( + expect(getSplitGaussianArea({ fwhmLow: 50, fwhmHigh: 50 })).toBeCloseTo( (ROOT_PI_OVER_LN2 * 50) / 2, 6, ); }); test('height calculation is consistent with the area', () => { - const shape = new SplitGaussian({ fwhmLeft: 100, fwhmRight: 300 }); + const shape = new SplitGaussian({ fwhmLow: 100, fwhmHigh: 300 }); const height = shape.calculateHeight(); const expected = calculateSplitGaussianHeight({ - fwhmLeft: 100, - fwhmRight: 300, + fwhmLow: 100, + fwhmHigh: 300, area: 1, }); expect(height).toBeCloseTo(expected, 6); expect( - getSplitGaussianArea({ fwhmLeft: 100, fwhmRight: 300, height }), + getSplitGaussianArea({ fwhmLow: 100, fwhmHigh: 300, height }), ).toBeCloseTo(1, 6); }); test('change height should change area proportionally', () => { - const shape = new SplitGaussian({ fwhmLeft: 100, fwhmRight: 200 }); + const shape = new SplitGaussian({ fwhmLow: 100, fwhmHigh: 200 }); const area = shape.getArea(1); expect(shape.getArea(2)).toBeCloseTo(2 * area, 4); From bb7c036fbe3435ae7dca220c44b0ce8f3e5d49b5 Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Tue, 28 Jul 2026 10:07:29 +0200 Subject: [PATCH 4/4] docs: state the split gaussian fwhm relations exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(fwhmLow + fwhmHigh) / 2` is not an aggregate of the two halves, it is the peak's full width at half maximum: the half-maximum crossings sit at `-fwhmLow / 2` and `fwhmHigh / 2`. `fwhmToWidth` is exact in the same way, the width it returns for that fwhm being `σlow + σhigh`; the only caveat is that a single width cannot be mapped back to the two halves. `getSplitGaussianData` also listed an `fwhm` shape parameter, which does not exist. The tests now pin the crossings the fwhm is defined from, the halves each derivative belongs to, the apex, the default widths, the `length` option, and that equal halves reproduce `getGaussianData` exactly. Assisted-By: Claude Opus 5 --- node_modules | 1 + src/shapes/1d/splitGaussian/SplitGaussian.ts | 31 +++++++------------ .../SplitGaussian.derivative.test.ts | 24 ++++++++++++++ .../__tests__/SplitGaussian.fct.test.ts | 12 ++++++- .../__tests__/SplitGaussian.shape.test.ts | 22 +++++++++++-- 5 files changed, 67 insertions(+), 23 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..42d64e5 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/lpatiny/git/mljs/peak-shape-generator/node_modules \ No newline at end of file diff --git a/src/shapes/1d/splitGaussian/SplitGaussian.ts b/src/shapes/1d/splitGaussian/SplitGaussian.ts index 8102bb5..dd5221f 100644 --- a/src/shapes/1d/splitGaussian/SplitGaussian.ts +++ b/src/shapes/1d/splitGaussian/SplitGaussian.ts @@ -81,9 +81,10 @@ export class SplitGaussian implements Shape1DClass { } /** - * Full width at half maximum of the whole peak, the mean of both halves. - * A symmetric peak has no split; use a `Gaussian` for that case. - * @returns the mean full width at half maximum. + * Full width at half maximum of the peak. The half-maximum crossings are at + * `-fwhmLow / 2` and `fwhmHigh / 2`, so the width between them is the mean of + * both halves. + * @returns the full width at half maximum. */ public get fwhm() { return (this.fwhmLow + this.fwhmHigh) / 2; @@ -91,16 +92,9 @@ export class SplitGaussian implements Shape1DClass { /** * Convert a full width at half maximum to the width between the inflection - * points, using the plain gaussian relation on the mean fwhm by default. - * - * Because that relation is linear, the value returned for the mean fwhm equals - * the true total span between the split shape's two (asymmetric) inflection - * points, `σlow + σhigh`. It is therefore an **aggregate**: it does not - * capture the asymmetry — `fwhmLow: 200, fwhmHigh: 600` and - * `fwhmLow: fwhmHigh: 400` yield the same width. Use `fwhmLow` / `fwhmHigh` - * directly when each half-width matters. - * @param fwhm - full width at half maximum. Defaults to the mean of both halves. - * @returns the aggregate width between the inflection points. + * points. For this peak's own fwhm the result is exactly `σlow + σhigh`. + * @param fwhm - full width at half maximum. Defaults to the peak's fwhm. + * @returns the width between the inflection points. */ public fwhmToWidth(fwhm = this.fwhm) { return gaussianFwhmToWidth(fwhm); @@ -108,13 +102,10 @@ export class SplitGaussian implements Shape1DClass { /** * Convert a width between the inflection points back to a full width at half - * maximum, using the plain gaussian relation. - * - * This is the inverse of `fwhmToWidth` only for the **aggregate** (mean) fwhm; - * it cannot recover the individual `fwhmLow` / `fwhmHigh`, since a single - * width does not encode the asymmetry. + * maximum. A single width does not encode the asymmetry, so it cannot recover + * `fwhmLow` and `fwhmHigh` individually. * @param width - width between the inflection points. - * @returns the corresponding (aggregate) full width at half maximum. + * @returns the corresponding full width at half maximum. */ public widthToFWHM(width: number) { return gaussianWidthToFWHM(width); @@ -224,7 +215,7 @@ export function getSplitGaussianArea(options: GetSplitGaussianAreaOptions) { /** * Generate an intensity array for a split gaussian shape. - * @param shape - split gaussian shape parameters (fwhm, fwhmLow, fwhmHigh). + * @param shape - split gaussian shape parameters (fwhmLow, fwhmHigh). * @param options - sampling options (length, factor, height). * @returns Float64Array of intensity values. */ diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts index 155e0e9..29982f4 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.derivative.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import { gaussianDerivative } from '../../gaussian/Gaussian.ts'; import { SplitGaussian, splitGaussianDerivative, @@ -43,6 +44,29 @@ test('splitGaussianDerivative matches numerical derivatives on each side', () => } }); +test('each side is the gaussian of its own half', () => { + const lower = splitGaussianDerivative(-0.1, fwhmLow, fwhmHigh); + const higher = splitGaussianDerivative(0.1, fwhmLow, fwhmHigh); + + expect(lower.dFwhmLow).toBe(gaussianDerivative(-0.1, fwhmLow).dFwhm); + expect(lower.dFwhmHigh).toBe(0); + expect(higher.dFwhmHigh).toBe(gaussianDerivative(0.1, fwhmHigh).dFwhm); + expect(higher.dFwhmLow).toBe(0); +}); + +test('the apex is a smooth maximum', () => { + const { fct, dx, dFwhmLow, dFwhmHigh } = splitGaussianDerivative( + 0, + fwhmLow, + fwhmHigh, + ); + + expect(fct).toBe(1); + expect(dx).toBeCloseTo(0, 12); + expect(dFwhmLow).toBeCloseTo(0, 12); + expect(dFwhmHigh).toBeCloseTo(0, 12); +}); + test('SplitGaussian.derivative returns parameters in getParameters() order', () => { const shape = new SplitGaussian({ fwhmLow, fwhmHigh }); const { fct, dx, parameters } = shape.derivative(0.05); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts index d598991..d947db8 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.fct.test.ts @@ -19,8 +19,18 @@ test('splitGaussianFct with equal halves matches a symmetric gaussian', () => { expect(splitGaussianFct(0.1, 0.2, 0.2)).toBeCloseTo(0.5); }); -test('fwhm getter returns the mean of both halves', () => { +test('fwhm is the distance between both half-maximum crossings', () => { const shape = new SplitGaussian({ fwhmLow: 200, fwhmHigh: 600 }); + expect(shape.fct(-100)).toBeCloseTo(0.5, 12); + expect(shape.fct(300)).toBeCloseTo(0.5, 12); expect(shape.fwhm).toBe(400); }); + +test('both halves default to 500', () => { + const shape = new SplitGaussian(); + + expect(shape.fwhmLow).toBe(500); + expect(shape.fwhmHigh).toBe(500); + expect(shape.fwhm).toBe(500); +}); diff --git a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts index 67dcf6e..c5b1d3d 100644 --- a/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts +++ b/src/shapes/1d/splitGaussian/__tests__/SplitGaussian.shape.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { ROOT_PI_OVER_LN2 } from '../../../../util/constants.ts'; +import { getGaussianData } from '../../gaussian/Gaussian.ts'; import { SplitGaussian, calculateSplitGaussianHeight, @@ -29,8 +30,9 @@ test('normalized area is close to 1', () => { const area = data.reduce((a, b) => a + b, 0); - expect(area).toBeCloseTo(0.9999, 2); - expect(shape.getArea()).toBeCloseTo(1, 2); + // the window is sized on the wider half, so it covers more than the 0.9999 factor + expect(area).toBeCloseTo(1, 5); + expect(shape.getArea()).toBeCloseTo(1, 12); }); test('equal halves reduce to a symmetric gaussian area', () => { @@ -43,6 +45,22 @@ test('equal halves reduce to a symmetric gaussian area', () => { ); }); +test('equal halves produce the same data as a gaussian', () => { + const data = new SplitGaussian({ fwhmLow: 50, fwhmHigh: 50 }).getData(); + + expect(data).toStrictEqual(getGaussianData({ fwhm: 50 })); +}); + +test('length option is honored, apex stays at the center', () => { + const shape = new SplitGaussian({ fwhmLow: 10, fwhmHigh: 30 }); + const data = shape.getData({ length: 101, height: 1 }); + + expect(data).toHaveLength(101); + expect(data[50]).toBe(1); + expect(data[40]).toBe(shape.fct(-10)); + expect(data[60]).toBe(shape.fct(10)); +}); + test('height calculation is consistent with the area', () => { const shape = new SplitGaussian({ fwhmLow: 100, fwhmHigh: 300 }); const height = shape.calculateHeight();