Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/core/dtype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export class Dtype {
return new Dtype("timedelta", "timedelta", 8);
case "category":
return new Dtype("category", "category", 0);
default: {
const _exhaustive: never = name;
throw new Error(`Unknown dtype: ${_exhaustive}`);
}
}
}

Expand Down
101 changes: 58 additions & 43 deletions src/core/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ function defaultIndex(n: number): Index<Label> {
return new RangeIndex(n) as unknown as Index<Label>;
}

/** True when the value should be treated as missing (null, undefined, or NaN). */
function isMissing(v: Scalar): boolean {
return v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
}

/** Compare two scalar values with null/NaN handling for sorting. */
function compareScalars(
a: Scalar,
b: Scalar,
ascending: boolean,
naPosition: "first" | "last",
): number {
const aNull = isMissing(a);
const bNull = isMissing(b);
if (aNull && bNull) {
return 0;
}
if (aNull) {
return naPosition === "first" ? -1 : 1;
}
if (bNull) {
return naPosition === "first" ? 1 : -1;
}
if (a === b) {
return 0;
}
const cmp = (a as number | string | boolean) < (b as number | string | boolean) ? -1 : 1;
return ascending ? cmp : -cmp;
}

// ─── SeriesOptions ────────────────────────────────────────────────────────────

/** Constructor options accepted by `Series`. */
Expand Down Expand Up @@ -184,7 +214,10 @@ export class Series<T extends Scalar = Scalar> {

// ─── arithmetic ───────────────────────────────────────────────────────────

private _scalarOp(other: T | Series<T>, fn: (a: number, b: number) => number): Series<number> {
private _scalarOp(
other: number | Series<Scalar>,
fn: (a: number, b: number) => number,
): Series<number> {
const isScalar = !(other instanceof Series);
if (isScalar) {
const b = other as number;
Expand All @@ -199,7 +232,7 @@ export class Series<T extends Scalar = Scalar> {
name: this.name,
});
}
const o = other as Series<T>;
const o = other as Series<Scalar>;
if (o.size !== this.size) {
throw new RangeError(
`Cannot operate on Series of different sizes: ${this.size} vs ${o.size}`,
Expand All @@ -219,46 +252,49 @@ export class Series<T extends Scalar = Scalar> {
}

/** Element-wise addition. */
add(other: T | Series<T>): Series<number> {
add(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a + b);
}

/** Element-wise subtraction. */
sub(other: T | Series<T>): Series<number> {
sub(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a - b);
}

/** Element-wise multiplication. */
mul(other: T | Series<T>): Series<number> {
mul(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a * b);
}

/** Element-wise division (true division, returns float). */
div(other: T | Series<T>): Series<number> {
div(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a / b);
}

/** Element-wise floor division. */
floordiv(other: T | Series<T>): Series<number> {
floordiv(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => Math.floor(a / b));
}

/** Element-wise modulo. */
mod(other: T | Series<T>): Series<number> {
mod(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a % b);
}

/** Element-wise exponentiation. */
pow(other: T | Series<T>): Series<number> {
pow(other: number | Series<Scalar>): Series<number> {
return this._scalarOp(other, (a, b) => a ** b);
}

// ─── comparison ───────────────────────────────────────────────────────────

private _cmpOp(other: T | Series<T>, fn: (a: T, b: T) => boolean): Series<boolean> {
private _cmpOp(
other: Scalar | Series<Scalar>,
fn: (a: Scalar, b: Scalar) => boolean,
): Series<boolean> {
const isScalar = !(other instanceof Series);
if (isScalar) {
const b = other as T;
const b = other as Scalar;
const newData = this._values.map((a) => fn(a, b));
return new Series<boolean>({
data: newData,
Expand All @@ -267,11 +303,11 @@ export class Series<T extends Scalar = Scalar> {
name: this.name,
});
}
const o = other as Series<T>;
const o = other as Series<Scalar>;
if (o.size !== this.size) {
throw new RangeError(`Cannot compare Series of different sizes: ${this.size} vs ${o.size}`);
}
const newData = this._values.map((a, i) => fn(a, o._values[i] as T));
const newData = this._values.map((a, i) => fn(a, o._values[i] as Scalar));
return new Series<boolean>({
data: newData,
index: this.index,
Expand All @@ -280,15 +316,15 @@ export class Series<T extends Scalar = Scalar> {
});
}

eq(other: T | Series<T>): Series<boolean> {
eq(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => a === b);
}

ne(other: T | Series<T>): Series<boolean> {
ne(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => a !== b);
}

lt(other: T | Series<T>): Series<boolean> {
lt(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => {
if (a === null || b === null) {
return false;
Expand All @@ -297,7 +333,7 @@ export class Series<T extends Scalar = Scalar> {
});
}

le(other: T | Series<T>): Series<boolean> {
le(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => {
if (a === null || b === null) {
return false;
Expand All @@ -306,7 +342,7 @@ export class Series<T extends Scalar = Scalar> {
});
}

gt(other: T | Series<T>): Series<boolean> {
gt(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => {
if (a === null || b === null) {
return false;
Expand All @@ -315,7 +351,7 @@ export class Series<T extends Scalar = Scalar> {
});
}

ge(other: T | Series<T>): Series<boolean> {
ge(other: Scalar | Series<Scalar>): Series<boolean> {
return this._cmpOp(other, (a, b) => {
if (a === null || b === null) {
return false;
Expand Down Expand Up @@ -546,28 +582,7 @@ export class Series<T extends Scalar = Scalar> {
/** Return a new Series sorted by values. */
sortValues(ascending = true, naPosition: "first" | "last" = "last"): Series<T> {
const pairs = this._values.map((v, i) => ({ v, i }));
pairs.sort((a, b) => {
const aNull =
a.v === null || a.v === undefined || (typeof a.v === "number" && Number.isNaN(a.v));
const bNull =
b.v === null || b.v === undefined || (typeof b.v === "number" && Number.isNaN(b.v));
if (aNull && bNull) {
return 0;
}
if (aNull) {
return naPosition === "first" ? -1 : 1;
}
if (bNull) {
return naPosition === "first" ? 1 : -1;
}
const av = a.v as number | string | boolean;
const bv = b.v as number | string | boolean;
if (av === bv) {
return 0;
}
const cmp = av < bv ? -1 : 1;
return ascending ? cmp : -cmp;
});
pairs.sort((a, b) => compareScalars(a.v, b.v, ascending, naPosition));
return new Series<T>({
data: pairs.map(({ v }) => v),
index: this.index.take(pairs.map(({ i }) => i)),
Expand Down Expand Up @@ -639,8 +654,8 @@ export class Series<T extends Scalar = Scalar> {
// ─── set operations ───────────────────────────────────────────────────────

/** True when `value` exists in this Series. */
isin(values: readonly T[]): Series<boolean> {
const set = new Set<T>(values);
isin(values: readonly Scalar[]): Series<boolean> {
const set = new Set<Scalar>(values);
return new Series<boolean>({
data: this._values.map((v) => set.has(v)),
index: this.index,
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type {
export { Index } from "./core/index.ts";
export type { IndexOptions } from "./core/index.ts";
export { RangeIndex } from "./core/index.ts";
export { Dtype } from "./core/dtype.ts";
export type { DtypeKind, ItemSize } from "./core/dtype.ts";
export { Series } from "./core/series.ts";
export type { SeriesOptions } from "./core/series.ts";
export { Dtype } from "./core/index.ts";
export type { DtypeKind, ItemSize } from "./core/index.ts";
export { Series } from "./core/index.ts";
export type { SeriesOptions } from "./core/index.ts";
2 changes: 1 addition & 1 deletion tests/core/dtype.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for the Dtype system.
*/
import { describe, expect, it } from "bun:test";
import { Dtype } from "../../src/core/index.ts";
import { Dtype } from "../../src/index.ts";

describe("Dtype", () => {
describe("singletons", () => {
Expand Down
38 changes: 19 additions & 19 deletions tests/core/series.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for Series.
*/
import { describe, expect, it } from "bun:test";
import { Dtype, Index, Series } from "../../src/core/index.ts";
import { Dtype, Index, Series } from "../../src/index.ts";
describe("Series", () => {
describe("construction", () => {
it("creates from numeric array with default RangeIndex", () => {
Expand Down Expand Up @@ -99,30 +99,30 @@ describe("Series", () => {
const a = new Series({ data: [1, 2, 3] });
const b = new Series({ data: [4, 5, 6] });

it("add scalar", () => expect(a.add(10 as never).values).toEqual([11, 12, 13]));
it("add series", () => expect(a.add(b as never).values).toEqual([5, 7, 9]));
it("sub series", () => expect(b.sub(a as never).values).toEqual([3, 3, 3]));
it("mul scalar", () => expect(a.mul(2 as never).values).toEqual([2, 4, 6]));
it("div series", () => expect(b.div(a as never).values).toEqual([4, 2.5, 2]));
it("mod", () => expect(b.mod(a as never).values).toEqual([0, 1, 0]));
it("pow", () => expect(a.pow(2 as never).values).toEqual([1, 4, 9]));
it("floordiv", () => expect(b.floordiv(a as never).values).toEqual([4, 2, 2]));
it("add scalar", () => expect(a.add(10).values).toEqual([11, 12, 13]));
it("add series", () => expect(a.add(b).values).toEqual([5, 7, 9]));
it("sub series", () => expect(b.sub(a).values).toEqual([3, 3, 3]));
it("mul scalar", () => expect(a.mul(2).values).toEqual([2, 4, 6]));
it("div series", () => expect(b.div(a).values).toEqual([4, 2.5, 2]));
it("mod", () => expect(b.mod(a).values).toEqual([0, 1, 0]));
it("pow", () => expect(a.pow(2).values).toEqual([1, 4, 9]));
it("floordiv", () => expect(b.floordiv(a).values).toEqual([4, 2, 2]));

it("throws when sizes differ", () => {
const c = new Series({ data: [1, 2] });
expect(() => a.add(c as never)).toThrow(RangeError);
expect(() => a.add(c)).toThrow(RangeError);
});
});

describe("comparison", () => {
const s = new Series({ data: [1, 2, 3] });

it("eq", () => expect(s.eq(2 as never).values).toEqual([false, true, false]));
it("ne", () => expect(s.ne(2 as never).values).toEqual([true, false, true]));
it("lt", () => expect(s.lt(2 as never).values).toEqual([true, false, false]));
it("le", () => expect(s.le(2 as never).values).toEqual([true, true, false]));
it("gt", () => expect(s.gt(2 as never).values).toEqual([false, false, true]));
it("ge", () => expect(s.ge(2 as never).values).toEqual([false, true, true]));
it("eq", () => expect(s.eq(2).values).toEqual([false, true, false]));
it("ne", () => expect(s.ne(2).values).toEqual([true, false, true]));
it("lt", () => expect(s.lt(2).values).toEqual([true, false, false]));
it("le", () => expect(s.le(2).values).toEqual([true, true, false]));
it("gt", () => expect(s.gt(2).values).toEqual([false, false, true]));
it("ge", () => expect(s.ge(2).values).toEqual([false, true, true]));
});

describe("filter", () => {
Expand All @@ -134,7 +134,7 @@ describe("Series", () => {
});

it("filters by boolean Series", () => {
const mask = s.gt(2 as never);
const mask = s.gt(2);
const result = s.filter(mask);
expect(result.values).toEqual([3, 4, 5]);
});
Expand Down Expand Up @@ -173,7 +173,7 @@ describe("Series", () => {
it("nunique", () => expect(s.nunique()).toBe(5));

it("sum with nulls skips them", () => {
const s2 = new Series({ data: [1, null, 2, null, 3] as never[] });
const s2 = new Series({ data: [1, null, 2, null, 3] });
expect(s2.sum()).toBe(6);
});

Expand Down Expand Up @@ -245,7 +245,7 @@ describe("Series", () => {
describe("isin", () => {
it("returns boolean mask", () => {
const s = new Series({ data: [1, 2, 3, 4] });
expect(s.isin([2, 4] as never[]).values).toEqual([false, true, false, true]);
expect(s.isin([2, 4]).values).toEqual([false, true, false, true]);
});
});

Expand Down
Loading