Skip to content

Commit fe93b24

Browse files
committed
feat: add options in xBinning
1 parent ff37e13 commit fe93b24

2 files changed

Lines changed: 220 additions & 20 deletions

File tree

src/x/__tests__/xBinning.test.ts

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,129 @@ import { xBinning } from '../xBinning.ts';
55
test('binSize 1 returns a copy', () => {
66
const array = [1, 2, 3, 4];
77

8-
expect(xBinning(array, 1)).toStrictEqual(Float64Array.from([1, 2, 3, 4]));
8+
expect(xBinning(array, { binSize: 1 })).toStrictEqual(
9+
Float64Array.from([1, 2, 3, 4]),
10+
);
911
});
1012

11-
test('even division', () => {
13+
test('default keepFirstAndLast with even division', () => {
1214
const array = [1, 2, 3, 4, 5, 6];
1315

14-
expect(xBinning(array, 2)).toStrictEqual(Float64Array.from([1.5, 3.5, 5.5]));
15-
expect(xBinning(array, 3)).toStrictEqual(Float64Array.from([2, 5]));
16+
expect(xBinning(array, { binSize: 2 })).toStrictEqual(
17+
Float64Array.from([1, 2.5, 4.5, 6]),
18+
);
19+
expect(xBinning(array, { binSize: 3 })).toStrictEqual(
20+
Float64Array.from([1, 3, 5, 6]),
21+
);
1622
});
1723

18-
test('uneven division averages remaining points', () => {
24+
test('default keepFirstAndLast with uneven division', () => {
1925
const array = [1, 2, 3, 4, 5, 6, 7];
2026

21-
expect(xBinning(array, 3)).toStrictEqual(Float64Array.from([2, 5, 7]));
27+
expect(xBinning(array, { binSize: 3 })).toStrictEqual(
28+
Float64Array.from([1, 3, 5.5, 7]),
29+
);
2230
});
2331

2432
test('accepts Float64Array input', () => {
2533
const array = Float64Array.from([2, 4, 6, 8]);
2634

27-
expect(xBinning(array, 2)).toStrictEqual(Float64Array.from([3, 7]));
35+
expect(xBinning(array, { binSize: 2 })).toStrictEqual(
36+
Float64Array.from([2, 5, 8]),
37+
);
38+
});
39+
40+
test('default binSize is 10', () => {
41+
const array = Array.from({ length: 25 }, (_, i) => i + 1);
42+
43+
expect(xBinning(array)).toStrictEqual(
44+
Float64Array.from([1, 6.5, 16.5, 23, 25]),
45+
);
46+
});
47+
48+
test('keepFirstAndLast=false restores pure binning', () => {
49+
const array = [1, 2, 3, 4, 5, 6];
50+
51+
expect(
52+
xBinning(array, { binSize: 2, keepFirstAndLast: false }),
53+
).toStrictEqual(Float64Array.from([1.5, 3.5, 5.5]));
54+
expect(
55+
xBinning(array, { binSize: 3, keepFirstAndLast: false }),
56+
).toStrictEqual(Float64Array.from([2, 5]));
57+
});
58+
59+
test('keepFirstAndLast=false with uneven division', () => {
60+
const array = [1, 2, 3, 4, 5, 6, 7];
61+
62+
expect(
63+
xBinning(array, { binSize: 3, keepFirstAndLast: false }),
64+
).toStrictEqual(Float64Array.from([2, 5, 7]));
2865
});
2966

3067
test('throws on invalid binSize', () => {
31-
expect(() => xBinning([1, 2, 3], 0)).toThrow(
68+
expect(() => xBinning([1, 2, 3], { binSize: 0 })).toThrow(
3269
/binSize must be a positive integer/,
3370
);
34-
expect(() => xBinning([1, 2, 3], 1.5)).toThrow(
71+
expect(() => xBinning([1, 2, 3], { binSize: 1.5 })).toThrow(
3572
/binSize must be a positive integer/,
3673
);
37-
expect(() => xBinning([1, 2, 3], -2)).toThrow(
74+
expect(() => xBinning([1, 2, 3], { binSize: -2 })).toThrow(
3875
/binSize must be a positive integer/,
3976
);
4077
});
4178

4279
test('throws on empty input', () => {
43-
expect(() => xBinning([], 2)).toThrow(/input must not be empty/);
80+
expect(() => xBinning([], { binSize: 2 })).toThrow(/input must not be empty/);
81+
});
82+
83+
test('length <= 2 returns a copy', () => {
84+
expect(xBinning([1, 2], { binSize: 2 })).toStrictEqual(
85+
Float64Array.from([1, 2]),
86+
);
87+
expect(xBinning([5], { binSize: 2 })).toStrictEqual(Float64Array.from([5]));
88+
});
89+
90+
test('numberOfPoints splits into N bins', () => {
91+
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
92+
93+
expect(
94+
xBinning(array, { numberOfPoints: 5, keepFirstAndLast: false }),
95+
).toStrictEqual(Float64Array.from([1.5, 3.5, 5.5, 7.5, 9.5]));
96+
});
97+
98+
test('numberOfPoints with uneven split', () => {
99+
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
100+
101+
expect(
102+
xBinning(array, { numberOfPoints: 3, keepFirstAndLast: false }),
103+
).toStrictEqual(Float64Array.from([2, 5, 8.5]));
104+
});
105+
106+
test('numberOfPoints with keepFirstAndLast', () => {
107+
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
108+
109+
expect(xBinning(array, { numberOfPoints: 5 })).toStrictEqual(
110+
Float64Array.from([1, 2.5, 5, 8, 10]),
111+
);
112+
});
113+
114+
test('numberOfPoints throws when > length', () => {
115+
expect(() => xBinning([1, 2, 3], { numberOfPoints: 4 })).toThrow(
116+
/numberOfPoints must be <= array.length/,
117+
);
118+
});
119+
120+
test('numberOfPoints throws when not a positive integer', () => {
121+
expect(() => xBinning([1, 2, 3], { numberOfPoints: 0 })).toThrow(
122+
/numberOfPoints must be a positive integer/,
123+
);
124+
expect(() => xBinning([1, 2, 3], { numberOfPoints: 2.5 })).toThrow(
125+
/numberOfPoints must be a positive integer/,
126+
);
127+
});
128+
129+
test('binSize and numberOfPoints are mutually exclusive', () => {
130+
expect(() =>
131+
xBinning([1, 2, 3, 4], { binSize: 2, numberOfPoints: 2 }),
132+
).toThrow(/mutually exclusive/);
44133
});

src/x/xBinning.ts

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,91 @@ import type { NumberArray } from 'cheminfo-types';
22

33
import { xCheck } from './xCheck.ts';
44

5+
export interface XBinningOptions {
6+
/**
7+
* Number of consecutive points to average per bin. Must be a positive integer.
8+
* Ignored when `numberOfPoints` is provided.
9+
* @default 10
10+
*/
11+
binSize?: number;
12+
/**
13+
* Target number of points in the output. When provided, the input is split
14+
* into `numberOfPoints` contiguous bins of as-equal-as-possible size, and
15+
* takes precedence over `binSize`. Must be a positive integer and
16+
* `<= array.length`.
17+
*/
18+
numberOfPoints?: number;
19+
/**
20+
* If `true`, the first and last points of the input are preserved unchanged
21+
* in the output (useful to keep the exact domain endpoints of a spectrum).
22+
* The intermediate points (indices `1` to `length - 2`) are binned.
23+
* @default true
24+
*/
25+
keepFirstAndLast?: boolean;
26+
}
27+
528
/**
629
* Downsample an array by averaging consecutive non-overlapping points (binning).
30+
* Either specify a fixed `binSize` or a target `numberOfPoints`.
731
* @param array - Input array.
8-
* @param binSize - Number of consecutive points to average per bin. Must be a positive integer.
9-
* @returns Downsampled array of length `ceil(array.length / binSize)`.
32+
* @param options - Options.
33+
* @returns Downsampled array.
1034
*/
1135
export function xBinning(
1236
array: NumberArray,
13-
binSize: number,
37+
options: XBinningOptions = {},
1438
): Float64Array<ArrayBuffer> {
1539
xCheck(array);
16-
if (!Number.isInteger(binSize) || binSize <= 0) {
40+
const { binSize, numberOfPoints, keepFirstAndLast = true } = options;
41+
const { length } = array;
42+
43+
if (numberOfPoints !== undefined) {
44+
if (binSize !== undefined) {
45+
throw new RangeError('binSize and numberOfPoints are mutually exclusive');
46+
}
47+
if (!Number.isInteger(numberOfPoints) || numberOfPoints <= 0) {
48+
throw new RangeError('numberOfPoints must be a positive integer');
49+
}
50+
if (numberOfPoints > length) {
51+
throw new RangeError('numberOfPoints must be <= array.length');
52+
}
53+
return binByNumberOfPoints(array, numberOfPoints, keepFirstAndLast);
54+
}
55+
56+
const effectiveBinSize = binSize ?? 10;
57+
if (!Number.isInteger(effectiveBinSize) || effectiveBinSize <= 0) {
1758
throw new RangeError('binSize must be a positive integer');
1859
}
19-
if (binSize === 1) {
60+
if (effectiveBinSize === 1) {
2061
return Float64Array.from(array);
2162
}
2263

23-
const { length } = array;
24-
const outputLength = Math.ceil(length / binSize);
64+
if (keepFirstAndLast) {
65+
if (length <= 2) {
66+
return Float64Array.from(array);
67+
}
68+
const innerLength = length - 2;
69+
const innerBinCount = Math.ceil(innerLength / effectiveBinSize);
70+
const output = new Float64Array(innerBinCount + 2);
71+
output[0] = array[0];
72+
output[output.length - 1] = array[length - 1];
73+
for (let i = 0, j = 1; i < innerLength; i += effectiveBinSize, j++) {
74+
const start = i + 1;
75+
const end = Math.min(start + effectiveBinSize, length - 1);
76+
let sum = 0;
77+
for (let k = start; k < end; k++) {
78+
sum += array[k];
79+
}
80+
output[j] = sum / (end - start);
81+
}
82+
return output;
83+
}
84+
85+
const outputLength = Math.ceil(length / effectiveBinSize);
2586
const output = new Float64Array(outputLength);
2687

27-
for (let i = 0, j = 0; i < length; i += binSize, j++) {
28-
const end = Math.min(i + binSize, length);
88+
for (let i = 0, j = 0; i < length; i += effectiveBinSize, j++) {
89+
const end = Math.min(i + effectiveBinSize, length);
2990
let sum = 0;
3091
for (let k = i; k < end; k++) {
3192
sum += array[k];
@@ -35,3 +96,53 @@ export function xBinning(
3596

3697
return output;
3798
}
99+
100+
function binByNumberOfPoints(
101+
array: NumberArray,
102+
numberOfPoints: number,
103+
keepFirstAndLast: boolean,
104+
): Float64Array<ArrayBuffer> {
105+
const { length } = array;
106+
const output = new Float64Array(numberOfPoints);
107+
108+
if (keepFirstAndLast) {
109+
if (numberOfPoints < 2) {
110+
throw new RangeError(
111+
'numberOfPoints must be >= 2 when keepFirstAndLast is true',
112+
);
113+
}
114+
output[0] = array[0];
115+
output[numberOfPoints - 1] = array[length - 1];
116+
if (numberOfPoints === 2) {
117+
return output;
118+
}
119+
const innerLength = length - 2;
120+
const innerBins = numberOfPoints - 2;
121+
if (innerBins > innerLength) {
122+
throw new RangeError(
123+
'numberOfPoints is too large for the given array length with keepFirstAndLast',
124+
);
125+
}
126+
for (let j = 0; j < innerBins; j++) {
127+
const start = 1 + Math.floor((j * innerLength) / innerBins);
128+
const end = 1 + Math.floor(((j + 1) * innerLength) / innerBins);
129+
let sum = 0;
130+
for (let k = start; k < end; k++) {
131+
sum += array[k];
132+
}
133+
output[j + 1] = sum / (end - start);
134+
}
135+
return output;
136+
}
137+
138+
for (let j = 0; j < numberOfPoints; j++) {
139+
const start = Math.floor((j * length) / numberOfPoints);
140+
const end = Math.floor(((j + 1) * length) / numberOfPoints);
141+
let sum = 0;
142+
for (let k = start; k < end; k++) {
143+
sum += array[k];
144+
}
145+
output[j] = sum / (end - start);
146+
}
147+
return output;
148+
}

0 commit comments

Comments
 (0)