Skip to content

Commit 33e68a1

Browse files
committed
feat: add xyMedianY and xyMedianYAtXs methods
closes: #352
1 parent e639589 commit 33e68a1

File tree

7 files changed

+254
-3
lines changed

7 files changed

+254
-3
lines changed

src/__tests__/__snapshots__/index.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ exports[`existence of exported functions 1`] = `
108108
"xyMaxY",
109109
"xyMaxYPoint",
110110
"xyMedian",
111+
"xyMedianY",
112+
"xyMedianYAtXs",
111113
"xyMergeByCentroids",
112114
"xyMinClosestYPoint",
113115
"xyMinimaY",

src/xy/__tests__/xyMedianY.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect, test } from 'vitest';
2+
3+
import { xyMedianY } from '../xyMedianY.ts';
4+
5+
test('default window size of 5', () => {
6+
const data = {
7+
x: [1, 2, 3, 4, 5, 6, 7],
8+
y: [1, 3, 2, 100, 4, 5, 6],
9+
};
10+
11+
const result = xyMedianY(data);
12+
13+
expect(result.x).toBe(data.x);
14+
expect(result.y).toStrictEqual(new Float64Array([2, 2, 3, 4, 5, 5, 5]));
15+
});
16+
17+
test('window size of 3', () => {
18+
const data = {
19+
x: [1, 2, 3, 4, 5],
20+
y: [10, 1, 5, 3, 8],
21+
};
22+
23+
const result = xyMedianY(data, { windowSize: 3 });
24+
25+
expect(result.y).toStrictEqual(new Float64Array([1, 5, 3, 5, 3]));
26+
});
27+
28+
test('window size of 1 returns original values', () => {
29+
const data = {
30+
x: [1, 2, 3],
31+
y: [10, 20, 30],
32+
};
33+
34+
const result = xyMedianY(data, { windowSize: 1 });
35+
36+
expect(result.y).toStrictEqual(new Float64Array([10, 20, 30]));
37+
});
38+
39+
test('single point', () => {
40+
const data = {
41+
x: [5],
42+
y: [42],
43+
};
44+
45+
const result = xyMedianY(data);
46+
47+
expect(result.y).toStrictEqual(new Float64Array([42]));
48+
});
49+
50+
test('even number of elements in window returns average of middle two', () => {
51+
const data = {
52+
x: [1, 2, 3, 4],
53+
y: [1, 4, 2, 3],
54+
};
55+
56+
// window size 3, at edges the window has 2 elements (even)
57+
const result = xyMedianY(data, { windowSize: 3 });
58+
59+
// i=0: [1,4] -> median 1 (lower middle, exact: false)
60+
// i=1: [1,4,2] -> median 2
61+
// i=2: [4,2,3] -> median 3
62+
// i=3: [2,3] -> median 2 (lower middle, exact: false)
63+
expect(result.y).toStrictEqual(new Float64Array([1, 2, 3, 2]));
64+
});
65+
66+
test('empty data', () => {
67+
const data = {
68+
x: [],
69+
y: [],
70+
};
71+
72+
const result = xyMedianY(data);
73+
74+
expect(result.y).toStrictEqual(new Float64Array([]));
75+
});
76+
77+
test('does not mutate original Float64Array y data', () => {
78+
const y = new Float64Array([10, 1, 5, 3, 8]);
79+
const yOriginal = new Float64Array(y);
80+
const data = { x: [1, 2, 3, 4, 5], y };
81+
82+
xyMedianY(data, { windowSize: 3 });
83+
84+
expect(y).toStrictEqual(yOriginal);
85+
});
86+
87+
test('does not mutate original plain array y data', () => {
88+
const y = [10, 1, 5, 3, 8];
89+
const yOriginal = [...y];
90+
const data = { x: [1, 2, 3, 4, 5], y };
91+
92+
xyMedianY(data, { windowSize: 3 });
93+
94+
expect(y).toStrictEqual(yOriginal);
95+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from 'vitest';
2+
3+
import { xyMedianYAtXs } from '../xyMedianYAtXs.ts';
4+
5+
test('median at specific x values with window size 5', () => {
6+
const data = {
7+
x: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
8+
y: [2, 8, 3, 100, 5, 6, 1, 9, 4, 7],
9+
};
10+
11+
const result = xyMedianYAtXs(data, [3, 7]);
12+
13+
expect(result.x).toStrictEqual([3, 7]);
14+
expect(result.y).toStrictEqual(new Float64Array([5, 5]));
15+
});
16+
17+
test('median at x values not exactly in data uses closest index', () => {
18+
const data = {
19+
x: [1, 2, 3, 4, 5],
20+
y: [10, 1, 5, 3, 8],
21+
};
22+
23+
// 2.8 is closest to x=3 (index 2)
24+
const result = xyMedianYAtXs(data, [2.8], { windowSize: 3 });
25+
26+
expect(result.y).toStrictEqual(new Float64Array([3]));
27+
});
28+
29+
test('window size of 1 returns the y value at the closest x', () => {
30+
const data = {
31+
x: [1, 2, 3, 4, 5],
32+
y: [10, 20, 30, 40, 50],
33+
};
34+
35+
const result = xyMedianYAtXs(data, [2, 4], { windowSize: 1 });
36+
37+
expect(result.y).toStrictEqual(new Float64Array([20, 40]));
38+
});
39+
40+
test('window at edges is truncated', () => {
41+
const data = {
42+
x: [1, 2, 3, 4, 5],
43+
y: [10, 1, 5, 3, 8],
44+
};
45+
46+
const result = xyMedianYAtXs(data, [1, 5], { windowSize: 5 });
47+
48+
// index 0: window [10,1,5] -> median 5
49+
// index 4: window [5,3,8] -> median 5
50+
expect(result.y).toStrictEqual(new Float64Array([5, 5]));
51+
});
52+
53+
test('does not mutate original Float64Array y data', () => {
54+
const y = new Float64Array([10, 1, 5, 3, 8]);
55+
const yOriginal = new Float64Array(y);
56+
const data = { x: [1, 2, 3, 4, 5], y };
57+
58+
xyMedianYAtXs(data, [2, 4], { windowSize: 3 });
59+
60+
expect(y).toStrictEqual(yOriginal);
61+
});
62+
63+
test('empty xValues returns empty result', () => {
64+
const data = {
65+
x: [1, 2, 3],
66+
y: [10, 20, 30],
67+
};
68+
69+
const result = xyMedianYAtXs(data, []);
70+
71+
expect(result.y).toStrictEqual(new Float64Array([]));
72+
});

src/xy/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export * from './xyMaxMerge.ts';
2525
export * from './xyMaxY.ts';
2626
export * from './xyMaxYPoint.ts';
2727
export * from './xyMedian.ts';
28+
export * from './xyMedianY.ts';
29+
export * from './xyMedianYAtXs.ts';
2830
export * from './xyMergeByCentroids.ts';
2931
export * from './xyMinClosestYPoint.ts';
3032
export * from './xyMinimaY.ts';

src/xy/xyMedian.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { DataXY } from 'cheminfo-types';
22

33
/**
4-
* Finds the median x value for an object with properties x and y (arrays of the same length)
5-
* @param data - x should be sorted in increasing order
6-
* @returns - the median of x values
4+
* Computes the weighted median of the x values, using the y values as weights.
5+
* This is the x value that splits the total weight (sum of y) into two equal halves.
6+
* If the cumulative weight lands exactly at 50%, the result is the average of the two surrounding x values.
7+
* @param data - x should be sorted in increasing order, y values are used as weights and should be non-negative.
8+
* @returns The weighted median x value, or NaN if the data is empty.
79
*/
810
export function xyMedian(data: DataXY): number {
911
const { x, y } = data;

src/xy/xyMedianY.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { DataXY } from 'cheminfo-types';
2+
3+
import { xMedian } from '../x/xMedian.ts';
4+
5+
export interface XYMedianYOptions {
6+
/** Number of points in the sliding window. Must be odd. Defaults to `5`. */
7+
windowSize?: number;
8+
}
9+
10+
/**
11+
* Computes the median of Y values in a sliding window around each point.
12+
* @param data - Object with x and y arrays of the same length.
13+
* @param options - Options for the median computation.
14+
* @returns A new DataXY with the same x values and smoothed y values.
15+
*/
16+
export function xyMedianY(
17+
data: DataXY,
18+
options: XYMedianYOptions = {},
19+
): DataXY {
20+
const { windowSize = 5 } = options;
21+
const { x, y } = data;
22+
23+
const halfWindow = Math.floor(windowSize / 2);
24+
const result = new Float64Array(y.length);
25+
26+
for (let i = 0; i < y.length; i++) {
27+
const fromIndex = Math.max(0, i - halfWindow);
28+
const toIndex = Math.min(y.length, i + halfWindow + 1);
29+
const window = ArrayBuffer.isView(y)
30+
? y.subarray(fromIndex, toIndex)
31+
: y.slice(fromIndex, toIndex);
32+
result[i] = xMedian(window, { exact: false });
33+
}
34+
35+
return { x, y: result };
36+
}

src/xy/xyMedianYAtXs.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { DataXY, NumberArray } from 'cheminfo-types';
2+
3+
import { xFindClosestIndex } from '../x/xFindClosestIndex.ts';
4+
import { xMedian } from '../x/xMedian.ts';
5+
6+
export interface XYMedianYAtXsOptions {
7+
/** Number of points in the sliding window. Must be odd. Defaults to `5`. */
8+
windowSize?: number;
9+
}
10+
11+
/**
12+
* Computes the median of Y values in a sliding window around each target x position.
13+
* For each value in xValues, the closest index in data.x is found and the median
14+
* of the surrounding y values (within the window) is returned.
15+
* @param data - Object with x (sorted in increasing order) and y arrays of the same length.
16+
* @param xValues - Array of x positions at which to compute the median.
17+
* @param options - Options for the median computation.
18+
* @returns A new DataXY with x = xValues and y = computed medians.
19+
*/
20+
export function xyMedianYAtXs(
21+
data: DataXY,
22+
xValues: NumberArray,
23+
options: XYMedianYAtXsOptions = {},
24+
): DataXY {
25+
const { windowSize = 5 } = options;
26+
const { x, y } = data;
27+
28+
const halfWindow = Math.floor(windowSize / 2);
29+
const result = new Float64Array(xValues.length);
30+
31+
for (let i = 0; i < xValues.length; i++) {
32+
const centerIndex = xFindClosestIndex(x, xValues[i]);
33+
const fromIndex = Math.max(0, centerIndex - halfWindow);
34+
const toIndex = Math.min(y.length, centerIndex + halfWindow + 1);
35+
const window = ArrayBuffer.isView(y)
36+
? y.subarray(fromIndex, toIndex)
37+
: y.slice(fromIndex, toIndex);
38+
result[i] = xMedian(window, { exact: false });
39+
}
40+
41+
return { x: xValues, y: result };
42+
}

0 commit comments

Comments
 (0)