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
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ repos:
hooks:
# Run the linter
- id: ruff-check
types_or: [ python, pyi, jupyter, pyproject ]
args: [ --fix, --show-fixes ]
# Run the formatter
- id: ruff-format
Expand Down
10 changes: 5 additions & 5 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
cff-version: 1.2.0
message: If you use this software, please cite it as below.
message: "If you use this software, please cite it as below."
title: ndtbl
abstract: Multidimensional table lookup library for C++.
abstract: "Multidimensional table lookup library for C++."
authors:
- family-names: Isensee
given-names: Thomas
orcid: "https://orcid.org/0000-0002-7242-4529"
keywords:
- table lookup
- multidimensional interpolation
version: 0.0.1
- "table lookup"
- "multidimensional interpolation"
version: "0.0.1"
date-released: "2026-04-01"
license: MIT
repository-code: https://github.com/thomasisensee/ndtbl.git
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# ndtbl

[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![License](https://img.shields.io/pypi/l/ndtbl?label=License)](https://opensource.org/licenses/MIT)
[![Build](https://github.com/thomasisensee/ndtbl/actions/workflows/ci.yml/badge.svg)](https://github.com/thomasisensee/ndtbl/actions)
[![Documentation Status](https://readthedocs.org/projects/ndtbl/badge/)](https://ndtbl.readthedocs.io/)
[![Documentation](https://readthedocs.org/projects/ndtbl/badge/)](https://ndtbl.readthedocs.io/)
[![codecov](https://codecov.io/gh/thomasisensee/ndtbl/graph/badge.svg?flag=cpp&token=5N4GQ0YP7I)](https://codecov.io/gh/thomasisensee/ndtbl)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/thomasisensee/ndtbl/main.svg)](https://results.pre-commit.ci/latest/github/thomasisensee/ndtbl/main)
[![C++ standard](https://img.shields.io/badge/C%2B%2B-14-blue.svg)](https://en.wikipedia.org/wiki/C%2B%2B#Standardization)
[![pre-commit.ci](https://results.pre-commit.ci/badge/github/thomasisensee/ndtbl/main.svg)](https://results.pre-commit.ci/latest/github/thomasisensee/ndtbl/main)
[![C++](https://img.shields.io/badge/C%2B%2B-14-blue.svg)](https://en.wikipedia.org/wiki/C%2B%2B#Standardization)

`ndtbl` is an n-dimensional table format and toolkit.

Expand Down
10 changes: 5 additions & 5 deletions python/ndtbl/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# ndtbl

[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![License](https://img.shields.io/pypi/l/ndtbl?label=License)](https://opensource.org/licenses/MIT)
[![codecov](https://codecov.io/gh/thomasisensee/ndtbl/graph/badge.svg?flag=python&token=5N4GQ0YP7I)](https://codecov.io/gh/thomasisensee/ndtbl)
[![PyPI](https://img.shields.io/pypi/v/ndtbl)](https://pypi.org/project/ndtbl)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/thomasisensee/ndtbl/main.svg)](https://results.pre-commit.ci/latest/github/thomasisensee/ndtbl/main)
![Python](https://img.shields.io/badge/python-3.11%20|%203.12%20|%203.13%20|%203.14-blue)
[![pre-commit.ci](https://results.pre-commit.ci/badge/github/thomasisensee/ndtbl/main.svg)](https://results.pre-commit.ci/latest/github/thomasisensee/ndtbl/main)
[![PyPI](https://img.shields.io/pypi/v/ndtbl?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/ndtbl)
[![Python](https://img.shields.io/pypi/pyversions/ndtbl?logo=python&logoColor=gold&label=Python)](https://pypi.org/project/ndtbl)

Pure-Python tools for reading, writing, inspecting, and generating `.ndtbl`
files without depending on the C++ binaries.
Expand Down Expand Up @@ -79,7 +79,7 @@ print(loaded.values[1, 0, :])
The `values` array shape is `axis_0 x axis_1 x ... x field`.

### Demo notebook
Use the [demo notebook](https://github.com/thomasisensee/ndtbl/blob/pyproject/python/ndtbl/notebooks/demo.ipynb) to test the Python functionality of `ndtbl` and see how it can be used to read and write binary `.ndtbl` files.
Use the [demo notebook](https://github.com/thomasisensee/ndtbl/blob/main/python/ndtbl/notebooks/demo.ipynb) to test the Python functionality of `ndtbl` and see how it can be used to read and write binary `.ndtbl` files.


## 💻 CLI
Expand Down
12 changes: 9 additions & 3 deletions python/ndtbl/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ readme = "README.md"
authors = [
{ name = "Thomas Isensee", email = "thomas.isensee@iwr.uni-heidelberg.de" },
]
version = "0.2.0"
version = "0.3.0"
requires-python = ">=3.11"
license = "MIT"
keywords = [
Expand All @@ -23,8 +23,14 @@ keywords = [
]
classifiers = [
"Intended Audience :: Science/Research",
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
dependencies = [
"click",
Expand Down Expand Up @@ -74,7 +80,7 @@ exclude = "doc"

[tool.ruff]
line-length = 79
target-version = "py313"
target-version = "py311"

[tool.ruff.lint]
select = [
Expand Down
23 changes: 14 additions & 9 deletions python/ndtbl/src/ndtbl/_binary.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO
from typing import BinaryIO, cast

import numpy as np

from .model import (
ExplicitAxis,
FieldGroup,
FloatArray,
FloatDType,
GroupMetadata,
NdtblFormatError,
UniformAxis,
Expand All @@ -28,11 +30,11 @@
UINT64 = struct.Struct(f"{LITTLE_ENDIAN_PREFIX}Q")
DOUBLE = struct.Struct(f"{LITTLE_ENDIAN_PREFIX}d")

DTYPE_TO_TAG = {
DTYPE_TO_TAG: dict[FloatDType, int] = {
np.dtype(np.float32): SCALAR_FLOAT32,
np.dtype(np.float64): SCALAR_FLOAT64,
}
TAG_TO_DTYPE = {
TAG_TO_DTYPE: dict[int, FloatDType] = {
SCALAR_FLOAT32: np.dtype(np.float32),
SCALAR_FLOAT64: np.dtype(np.float64),
}
Expand All @@ -56,22 +58,22 @@ def _read_exact(stream: BinaryIO, size: int) -> bytes:

def _read_uint8(stream: BinaryIO) -> int:
"""Read one unsigned 8-bit integer from a stream."""
return UINT8.unpack(_read_exact(stream, UINT8.size))[0]
return int(UINT8.unpack(_read_exact(stream, UINT8.size))[0])


def _read_uint16(stream: BinaryIO) -> int:
"""Read one unsigned 16-bit integer from a stream."""
return UINT16.unpack(_read_exact(stream, UINT16.size))[0]
return int(UINT16.unpack(_read_exact(stream, UINT16.size))[0])


def _read_uint64(stream: BinaryIO) -> int:
"""Read one unsigned 64-bit integer from a stream."""
return UINT64.unpack(_read_exact(stream, UINT64.size))[0]
return int(UINT64.unpack(_read_exact(stream, UINT64.size))[0])


def _read_double(stream: BinaryIO) -> float:
"""Read one IEEE-754 double-precision value from a stream."""
return DOUBLE.unpack(_read_exact(stream, DOUBLE.size))[0]
return float(DOUBLE.unpack(_read_exact(stream, DOUBLE.size))[0])


def _write_uint8(stream: BinaryIO, value: int) -> None:
Expand Down Expand Up @@ -243,8 +245,11 @@ def read_group_from_stream(stream: BinaryIO) -> FieldGroup:
values = np.frombuffer(payload, dtype=wire_dtype).astype(
metadata.dtype, copy=False
)
shaped = values.reshape(
(*metadata.axis_sizes, metadata.field_count), order="C"
shaped = cast(
FloatArray,
values.reshape(
(*metadata.axis_sizes, metadata.field_count), order="C"
),
)
return FieldGroup(
axes=metadata.axes,
Expand Down
2 changes: 1 addition & 1 deletion python/ndtbl/src/ndtbl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def _echo_query_values(group: FieldGroup, indices: tuple[int, ...]) -> None:
indices: Zero-based point indices in axis order.
"""

values = group.values[*indices, :]
values = group.values[indices]
for field_name, value in zip(group.field_names, values, strict=True):
click.echo(f"{field_name}: {value:g}")

Expand Down
38 changes: 25 additions & 13 deletions python/ndtbl/src/ndtbl/generate.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
from dataclasses import dataclass
from typing import cast

import numpy as np

from .model import FieldGroup, UniformAxis, normalize_dtype
from ._binary import DOUBLE, MAGIC, UINT8, UINT16, UINT64
from .model import (
FieldGroup,
FloatArray,
FloatDType,
FloatDTypeLike,
UniformAxis,
normalize_dtype,
)

FILE_MAGIC_SIZE = 8
HEADER_PREFIX_SIZE = 1 + 1 + 2 + 8
DIMENSION_HEADER_SIZE = 8 * 3
AXIS_HEADER_SIZE = 8 + 8
UNIFORM_AXIS_PAYLOAD_SIZE = 16
STRING_LENGTH_SIZE = 8
FILE_MAGIC_SIZE = len(MAGIC)
HEADER_PREFIX_SIZE = UINT8.size + UINT8.size + UINT16.size + UINT64.size
DIMENSION_HEADER_SIZE = UINT64.size * 3
AXIS_HEADER_SIZE = UINT8.size + UINT8.size + UINT16.size + UINT64.size
UNIFORM_AXIS_PAYLOAD_SIZE = DOUBLE.size * 2
STRING_LENGTH_SIZE = UINT64.size


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -61,8 +70,8 @@ def __post_init__(self) -> None:
def _linear_field(
axes: tuple[UniformAxis, ...],
spec: LinearFieldSpec,
dtype: np.dtype[np.float32] | np.dtype[np.float64],
) -> np.ndarray:
dtype: FloatDType,
) -> FloatArray:
"""Evaluate one linear field over a uniform grid.

Args:
Expand All @@ -80,10 +89,13 @@ def _linear_field(
)

axis_sizes = tuple(axis.size for axis in axes)
field = np.full(axis_sizes, spec.offset, dtype=dtype)
field = cast(FloatArray, np.full(axis_sizes, spec.offset, dtype=dtype))

for axis_index, coefficient in enumerate(spec.coefficients):
coordinates = axes[axis_index].coordinates().astype(dtype, copy=False)
coordinates = cast(
FloatArray,
axes[axis_index].coordinates().astype(dtype, copy=False),
)
broadcast_shape = [1] * len(axes)
broadcast_shape[axis_index] = axes[axis_index].size
field += coefficient * coordinates.reshape(broadcast_shape)
Expand All @@ -99,7 +111,7 @@ def _linear_field(
def generate_group(
axes: tuple[UniformAxis, ...],
field_specs: tuple[LinearFieldSpec, ...],
dtype: np.dtype[np.float32] | np.dtype[np.float64] | str = np.float64,
dtype: FloatDTypeLike = np.float64,
) -> FieldGroup:
"""Generate an in-memory field group from linear field specifications.

Expand Down Expand Up @@ -132,7 +144,7 @@ def generate_group(
def estimate_generated_group_size(
axes: tuple[UniformAxis, ...],
field_specs: tuple[LinearFieldSpec, ...],
dtype: np.dtype[np.float32] | np.dtype[np.float64] | str = np.float64,
dtype: FloatDTypeLike = np.float64,
) -> GenerationSizeEstimate:
"""Estimate the size of a generated ndtbl file.

Expand Down
49 changes: 32 additions & 17 deletions python/ndtbl/src/ndtbl/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from numpy.typing import ArrayLike, NDArray

FloatArray: TypeAlias = NDArray[np.float32] | NDArray[np.float64]
FloatDType: TypeAlias = np.dtype[np.float32] | np.dtype[np.float64]
FloatDTypeLike: TypeAlias = (
FloatDType | type[np.float32] | type[np.float64] | str
)


class NdtblFormatError(RuntimeError):
Expand Down Expand Up @@ -110,8 +114,8 @@ def coordinates(self) -> NDArray[np.float64]:


def normalize_dtype(
dtype: np.dtype[np.generic] | str,
) -> np.dtype[np.float32] | np.dtype[np.float64]:
dtype: FloatDTypeLike,
) -> FloatDType:
"""Normalize supported payload dtypes to canonical NumPy dtypes.

Args:
Expand Down Expand Up @@ -191,7 +195,7 @@ def _normalize_values(values: ArrayLike) -> FloatArray:
return np.ascontiguousarray(array)


@dataclass(frozen=True, slots=True)
@dataclass(frozen=True, slots=True, init=False)
class GroupMetadata:
"""Metadata describing an ndtbl field group without payload values.

Expand All @@ -204,17 +208,23 @@ class GroupMetadata:

axes: tuple[Axis, ...]
field_names: tuple[str, ...]
dtype: np.dtype[np.float32] | np.dtype[np.float64]
dtype: FloatDType
format_version: int = 1

def __post_init__(self) -> None:
"""Normalize metadata fields after dataclass initialization."""
object.__setattr__(self, "axes", _normalize_axes(self.axes))
def __init__(
self,
axes: tuple[Axis, ...] | list[Axis],
field_names: tuple[str, ...] | list[str],
dtype: FloatDTypeLike,
format_version: int = 1,
) -> None:
"""Normalize metadata fields during initialization."""
object.__setattr__(self, "axes", _normalize_axes(axes))
object.__setattr__(
self, "field_names", _normalize_field_names(self.field_names)
self, "field_names", _normalize_field_names(field_names)
)
object.__setattr__(self, "dtype", normalize_dtype(self.dtype))
object.__setattr__(self, "format_version", int(self.format_version))
object.__setattr__(self, "dtype", normalize_dtype(dtype))
object.__setattr__(self, "format_version", int(format_version))

@property
def dimension(self) -> int:
Expand Down Expand Up @@ -245,7 +255,7 @@ def dtype_name(self) -> str:
return str(self.dtype)


@dataclass(slots=True)
@dataclass(slots=True, init=False)
class FieldGroup:
"""In-memory ndtbl payload and metadata.

Expand All @@ -259,11 +269,16 @@ class FieldGroup:
field_names: tuple[str, ...]
values: FloatArray

def __post_init__(self) -> None:
"""Validate the payload shape and normalize stored values."""
self.axes = _normalize_axes(self.axes)
self.field_names = _normalize_field_names(self.field_names)
self.values = _normalize_values(self.values)
def __init__(
self,
axes: tuple[Axis, ...] | list[Axis],
field_names: tuple[str, ...] | list[str],
values: ArrayLike,
) -> None:
"""Validate and normalize group data during initialization."""
self.axes = _normalize_axes(axes)
self.field_names = _normalize_field_names(field_names)
self.values = _normalize_values(values)

expected_shape = (*self.axis_sizes, self.field_count)
if self.values.ndim != self.dimension + 1:
Expand Down Expand Up @@ -300,7 +315,7 @@ def point_count(self) -> int:
return point_count

@property
def dtype(self) -> np.dtype[np.float32] | np.dtype[np.float64]:
def dtype(self) -> FloatDType:
"""Return the normalized payload dtype."""
return normalize_dtype(self.values.dtype)

Expand Down
Empty file added python/ndtbl/src/ndtbl/py.typed
Empty file.
18 changes: 18 additions & 0 deletions python/ndtbl/tests/test_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pytest

from ndtbl import FieldGroup, UniformAxis
from ndtbl._binary import serialized_group_size
from ndtbl.generate import (
LinearFieldSpec,
estimate_generated_group_size,
Expand Down Expand Up @@ -141,3 +142,20 @@ def test_estimate_generated_group_size_scales_with_dtype() -> None:
float64_estimate.estimated_file_bytes
> float32_estimate.estimated_file_bytes
)


def test_estimate_generated_group_size_matches_serialized_size() -> None:
axes = (
UniformAxis(0.0, 1.0, 3),
UniformAxis(10.0, 20.0, 2),
)
field_specs = (
LinearFieldSpec("A", 1.0, (2.0, 0.0)),
LinearFieldSpec("B", 5.0, (0.0, -1.0)),
)
group = generate_group(axes, field_specs, dtype=np.float32)
estimate = estimate_generated_group_size(
axes, field_specs, dtype=np.float32
)

assert estimate.estimated_file_bytes == serialized_group_size(group)
Loading