diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da0d55d..ca96d05 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CITATION.cff b/CITATION.cff index ae790ee..62477a6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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 diff --git a/README.md b/README.md index 82d193d..3185986 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/python/ndtbl/README.md b/python/ndtbl/README.md index 36e77a3..e5b06f6 100644 --- a/python/ndtbl/README.md +++ b/python/ndtbl/README.md @@ -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. @@ -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 diff --git a/python/ndtbl/pyproject.toml b/python/ndtbl/pyproject.toml index 4f8aa7f..9c80c2c 100644 --- a/python/ndtbl/pyproject.toml +++ b/python/ndtbl/pyproject.toml @@ -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 = [ @@ -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", @@ -74,7 +80,7 @@ exclude = "doc" [tool.ruff] line-length = 79 -target-version = "py313" +target-version = "py311" [tool.ruff.lint] select = [ diff --git a/python/ndtbl/src/ndtbl/_binary.py b/python/ndtbl/src/ndtbl/_binary.py index ce6b7b9..6085d4c 100644 --- a/python/ndtbl/src/ndtbl/_binary.py +++ b/python/ndtbl/src/ndtbl/_binary.py @@ -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, @@ -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), } @@ -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: @@ -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, diff --git a/python/ndtbl/src/ndtbl/cli.py b/python/ndtbl/src/ndtbl/cli.py index 19e3c4a..4a049a2 100644 --- a/python/ndtbl/src/ndtbl/cli.py +++ b/python/ndtbl/src/ndtbl/cli.py @@ -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}") diff --git a/python/ndtbl/src/ndtbl/generate.py b/python/ndtbl/src/ndtbl/generate.py index a68f3e4..e56d5da 100644 --- a/python/ndtbl/src/ndtbl/generate.py +++ b/python/ndtbl/src/ndtbl/generate.py @@ -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) @@ -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: @@ -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) @@ -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. @@ -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. diff --git a/python/ndtbl/src/ndtbl/model.py b/python/ndtbl/src/ndtbl/model.py index efc45ea..3a2cccd 100644 --- a/python/ndtbl/src/ndtbl/model.py +++ b/python/ndtbl/src/ndtbl/model.py @@ -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): @@ -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: @@ -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. @@ -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: @@ -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. @@ -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: @@ -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) diff --git a/python/ndtbl/src/ndtbl/py.typed b/python/ndtbl/src/ndtbl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/ndtbl/tests/test_generate.py b/python/ndtbl/tests/test_generate.py index 086b05e..7698f92 100644 --- a/python/ndtbl/tests/test_generate.py +++ b/python/ndtbl/tests/test_generate.py @@ -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, @@ -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) diff --git a/python/ndtbl/tests/test_model.py b/python/ndtbl/tests/test_model.py index ccc7e88..8636e92 100644 --- a/python/ndtbl/tests/test_model.py +++ b/python/ndtbl/tests/test_model.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from numpy.typing import ArrayLike from ndtbl import ExplicitAxis, FieldGroup, GroupMetadata, UniformAxis @@ -48,7 +49,7 @@ def test_explicit_axis_accepts_strictly_increasing_coordinates() -> None: ], ) def test_explicit_axis_rejects_invalid_coordinates( - coordinates: object, + coordinates: ArrayLike, ) -> None: with pytest.raises(ValueError): ExplicitAxis(coordinates) @@ -56,11 +57,13 @@ def test_explicit_axis_rejects_invalid_coordinates( def test_field_group_normalizes_numeric_inputs_to_float64() -> None: group = FieldGroup( - axes=(UniformAxis(0.0, 1.0, 2),), - field_names=("A",), + axes=[UniformAxis(0.0, 1.0, 2)], + field_names=["A"], values=[[-1], [2]], ) + assert isinstance(group.axes, tuple) + assert group.field_names == ("A",) assert group.dtype == np.dtype(np.float64) np.testing.assert_array_equal(group.values, np.array([[-1.0], [2.0]])) @@ -92,11 +95,13 @@ def test_field_group_rejects_empty_or_blank_field_names() -> None: def test_group_metadata_reports_derived_properties() -> None: metadata = GroupMetadata( - axes=(UniformAxis(0.0, 1.0, 2), ExplicitAxis([5.0, 6.0, 7.0])), - field_names=("A", "B"), - dtype=np.dtype(np.float32), + axes=[UniformAxis(0.0, 1.0, 2), ExplicitAxis([5.0, 6.0, 7.0])], + field_names=["A", "B"], + dtype=np.float32, ) + assert isinstance(metadata.axes, tuple) + assert metadata.field_names == ("A", "B") assert metadata.dimension == 2 assert metadata.field_count == 2 assert metadata.axis_sizes == (2, 3)