From 1a55d68cd73aca112befa61addb8b7b756850e42 Mon Sep 17 00:00:00 2001 From: Jan Pipek Date: Tue, 7 Jul 2026 15:34:59 +0200 Subject: [PATCH 1/4] Typing improvements --- Justfile | 2 +- src/physt/binnings.py | 148 ++++++++++++++++--------------- src/physt/histogram_base.py | 24 ++--- src/physt/plotting/matplotlib.py | 5 +- 4 files changed, 94 insertions(+), 85 deletions(-) diff --git a/Justfile b/Justfile index 64e9844..1554c47 100644 --- a/Justfile +++ b/Justfile @@ -26,7 +26,7 @@ mypy: # Optionally test with pyright (we don't aim yet) [group('qa')] pyright: - uv run --python 3.12 --extra all --with pyright pyright + uv run --extra all --with pyright pyright # Run all the pre-commit checks on the whole code-base pre-commit: diff --git a/src/physt/binnings.py b/src/physt/binnings.py index 6b81204..a1e940e 100644 --- a/src/physt/binnings.py +++ b/src/physt/binnings.py @@ -3,11 +3,13 @@ from __future__ import annotations import warnings +from abc import abstractmethod, ABC +from collections.abc import Iterable from contextlib import suppress -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, final, TypeAlias import numpy as np -from typing_extensions import NoReturn, Self +from typing_extensions import Self from physt._bin_utils import ( find_pretty_width, @@ -40,8 +42,12 @@ BinningType = TypeVar("BinningType", bound="BinningBase") -binning_methods = {} -"""Dictionary of available binnnings.""" +BinMap: TypeAlias = Iterable[tuple[int, int]] +"""Description of the bin remapping - from left to right.""" + + +binning_methods: dict[str, Callable] = {} +"""Dictionary of available binnings.""" def register_binning(name: Optional[str] = None) -> Callable[[Callable], Callable]: @@ -58,7 +64,7 @@ def decorator(f: Callable) -> Callable: # TODO: Locking and edit operations (like numpy read-only) -class BinningBase: +class BinningBase(ABC): """Abstract base class for binning schemas. Inheriting @@ -109,7 +115,7 @@ def __init__( ) self._adaptive = adaptive - def __getitem__(self, index: Union[slice, int]): + def __getitem__(self, index: slice | int) -> StaticBinning | np.ndarray: if isinstance(index, slice): new_binning = self.as_static() new_binning._bins = new_binning.bins[index] @@ -117,27 +123,26 @@ def __getitem__(self, index: Union[slice, int]): return self.bins[index] @staticmethod - def from_dict(a_dict: Dict[str, Any]) -> BinningBase: + def from_dict(a_dict: dict[str, Any]) -> BinningBase: binning_type = a_dict.pop("binning_type", "StaticBinning") klass = find_subclass(BinningBase, binning_type) return klass(**a_dict) - def to_dict(self) -> Dict[str, Any]: + @final + def to_dict(self) -> dict[str, Any]: """Dictionary representation of the binning schema. This serves as template method, please implement _update_dict """ - result: Dict[str, Any] = { + result: dict[str, Any] = { "adaptive": self._adaptive, "binning_type": type(self).__name__, } self._update_dict(result) return result - def _update_dict(self, a_dict): - raise NotImplementedError( - f"Dictionary representation of {type(self).__name__} is not implemented." - ) + @abstractmethod + def _update_dict(self, a_dict: dict[str, Any]) -> None: ... @property def includes_right_edge(self) -> bool: @@ -155,7 +160,7 @@ def is_regular(self, *, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: np.diff(self.bins[1] - self.bins[0]), 0.0, rtol=rtol, atol=atol ) - def is_consecutive(self, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: + def is_consecutive(self, *, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: """Whether all bins are in a growing order. Parameters @@ -174,7 +179,8 @@ def is_adaptive(self) -> bool: """Whether the binning can be adapted to include values not currently spanned.""" return self._adaptive - def force_bin_existence(self, values): + @final + def force_bin_existence(self, values: ArrayLike) -> int | BinMap | None: """Change schema so that there is a bin for value. It is necessary to implement the _force_bin_existence template method. @@ -186,30 +192,37 @@ def force_bin_existence(self, values): Returns ------- - bin_map: Iterable[tuple] or None or int + bin_map: _BinMap or None or int None => There was no change in bins int => The bins are only shifted (allows mass assignment) Otherwise => the iterable contains tuples (old bin index, new bin index) new bin index can occur multiple times, which corresponds to bin merging """ - # TODO: Rename to something less evil if not self.is_adaptive(): - raise RuntimeError("Histogram is not adaptive") + raise RuntimeError("Histogram is not adaptive.") else: return self._force_bin_existence(values) - def _force_bin_existence(self, values): - # TODO: in-place + def _force_bin_existence(self, values) -> int | BinMap | None: raise NotImplementedError() - def adapt(self, other: "BinningBase"): + @final + def adapt(self, other: "BinningBase") -> tuple[BinMap | None, BinMap | None]: """Adapt this binning so that it contains all bins of another binning. Parameters ---------- other: BinningBase + + Returns + ------- + map1: A remapping from old bins to new bins. If not changed, None. + map2: A remapping of `other` bins to new bins. If not changed, None. + + Note + ---- + Implement the `_adapt` template method. """ - # TODO: in-place arg if np.array_equal(self.bins, other.bins): return None, None elif not self.is_adaptive(): @@ -217,6 +230,10 @@ def adapt(self, other: "BinningBase"): else: return self._adapt(other) + def _adapt(self, other) -> tuple[BinMap | None, BinMap | None]: + # By default, this is not possible + raise RuntimeError(f"Cannot adapt {self.__class__.__name__}.") + def set_adaptive(self, value: bool = True) -> None: """Set/unset the adaptive property of the binning. @@ -226,9 +243,6 @@ def set_adaptive(self, value: bool = True) -> None: raise RuntimeError("Cannot change binning to adaptive.") self._adaptive = value - def _adapt(self, other) -> NoReturn: - raise RuntimeError("Cannot adapt binning.") - @property def bins(self) -> np.ndarray: """Bins in the wider format (as edge pairs) @@ -325,7 +339,7 @@ def as_static(self, copy: bool = True) -> "StaticBinning": # pylint: disable=un ) def as_fixed_width(self, copy: bool = True) -> "FixedWidthBinning": # pylint: disable=unused-argument - """Convert binning to recipe with fixed width (if possible.) + """Convert binning to recipe with fixed width (if possible). Parameters ---------- @@ -344,12 +358,12 @@ def as_fixed_width(self, copy: bool = True) -> "FixedWidthBinning": # pylint: d "Cannot create fixed-width binning from differing bin widths." ) + @abstractmethod def copy(self) -> Self: - """An identical, independent copy.""" - raise NotImplementedError() + """Create an identical, independent copy.""" - def apply_bin_map(self, bin_map) -> "BinningBase": - """ + def apply_bin_map(self, bin_map: BinMap) -> "BinningBase": + """... Parameters ---------- @@ -407,8 +421,8 @@ def as_static(self, copy: bool = True) -> "StaticBinning": return self.copy() return self - def copy(self) -> "StaticBinning": - return StaticBinning( + def copy(self) -> StaticBinning: + return type(self)( bins=self.bins.copy(), includes_right_edge=self.includes_right_edge ) @@ -418,15 +432,16 @@ def __getitem__(self, item): # TODO: check for the right_edge?? return copy - def _update_dict(self, a_dict): + def _update_dict(self, a_dict: dict[str, Any]) -> None: a_dict["bins"] = self.bins.tolist() - def _adapt(self, other): + def _adapt(self, other: BinningBase) -> tuple[None, BinMap]: if is_bin_subset(other.bins, self.bins): indices = np.searchsorted(other.bins[:, 0], self.bins[:, 0]) return None, list(enumerate(indices)) + raise ValueError("Cannot adapt binning with different bins.") - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.bins!r})" @@ -441,10 +456,10 @@ def __init__(self, numpy_bins: ArrayLike, includes_right_edge=True, **kwargs): ) @property - def numpy_bins(self): + def numpy_bins(self) -> np.ndarray: return self._numpy_bins - def copy(self) -> "NumpyBinning": + def copy(self) -> NumpyBinning: return NumpyBinning( numpy_bins=self.numpy_bins, includes_right_edge=self.includes_right_edge ) @@ -461,14 +476,15 @@ class FixedWidthBinning(BinningBase): def __init__( self, *, - bin_width, - bin_count=0, - bin_times_min=None, - min=None, - includes_right_edge=False, - adaptive=False, - bin_shift=None, - align=True, + bin_width: float, + bin_count: int = 0, + bin_times_min: int | None = None, + # TODO: move this to the helper method? + min: float | None = None, + includes_right_edge : bool = False, + adaptive: bool = False, + bin_shift: float | None =None, + align:bool = True, **kwargs, ): super().__init__(adaptive=adaptive, includes_right_edge=includes_right_edge) @@ -505,7 +521,7 @@ def __repr__(self): def is_regular(self, **kwargs) -> bool: return True - def _force_bin_existence_single(self, value, includes_right_edge=None): + def _force_bin_existence_single(self, value: float, *, includes_right_edge: bool | None = None) -> int | None: if includes_right_edge is None: includes_right_edge = self.includes_right_edge @@ -537,7 +553,7 @@ def _force_bin_existence_single(self, value, includes_right_edge=None): else: return None - def _force_bin_existence(self, values, *, includes_right_edge=None): + def _force_bin_existence(self, values, *, includes_right_edge=None) -> int | BinMap | None: if np.isscalar(values): return self._force_bin_existence_single( values, includes_right_edge=includes_right_edge @@ -555,14 +571,14 @@ def _force_bin_existence(self, values, *, includes_right_edge=None): @property def first_edge(self) -> float: - return self._times_min * self._bin_width + self._shift + return self._bin_width * self._times_min + self._shift @property def last_edge(self) -> float: return (self._times_min + self._bin_count) * self._bin_width + self._shift @property - def numpy_bins(self): + def numpy_bins(self) -> np.ndarray: if self._numpy_bins is None: self._bins = None if self._bin_count == 0: @@ -573,10 +589,10 @@ def numpy_bins(self): return self._numpy_bins @property - def bin_count(self): + def bin_count(self) -> int: return self._bin_count - def copy(self): + def copy(self) -> FixedWidthBinning: return FixedWidthBinning( bin_width=self._bin_width, bin_count=self._bin_count, @@ -588,10 +604,10 @@ def copy(self): ) @property - def bin_width(self): + def bin_width(self) -> float: return self._bin_width - def _force_new_min_max(self, new_min, new_max): + def _force_new_min_max(self, new_min, new_max) -> BinMap | None: bin_map = None add_right = add_left = 0 if new_min < self._times_min: @@ -605,20 +621,13 @@ def _force_new_min_max(self, new_min, new_max): ) return bin_map - def _set_min_and_count(self, times_min, bin_count): + def _set_min_and_count(self, times_min, bin_count) -> None: self._bin_count = bin_count self._times_min = times_min self._bins = None self._numpy_bins = None - def _adapt(self, other: BinningBase): - """ - - Returns - ------- - bin_map1: Iterable[tuple] or None - bin_map2: Iterable[tuple] or None - """ + def _adapt(self, other: BinningBase) -> tuple[BinMap | None, BinMap | None]: other = other.as_fixed_width() if self.bin_width != other.bin_width: raise ValueError( @@ -629,7 +638,7 @@ def _adapt(self, other: BinningBase): f"Cannot adapt shifted fixed-width histograms: {self._shift} vs {other._shift}" ) # Following operations modify schemas - other = cast(FixedWidthBinning, other.copy()) + other = other.copy() if other.bin_count == 0: return None, () if self.bin_count == 0: @@ -684,7 +693,7 @@ def is_regular(self, **kwargs) -> bool: return False @property - def numpy_bins(self): + def numpy_bins(self) -> np.ndarray: if self._bin_count == 0: return np.ndarray((0,), dtype=float) if self._numpy_bins is None: @@ -697,7 +706,7 @@ def copy(self) -> "ExponentialBinning": self._log_min, self._log_width, self._bin_count, self.includes_right_edge ) - def _update_dict(self, a_dict): + def _update_dict(self, a_dict) -> None: a_dict["log_min"] = self._log_min a_dict["log_width"] = self._log_width a_dict["bin_count"] = self._bin_count @@ -855,7 +864,7 @@ def quantile_binning( @register_binning() def static_binning( - data: Optional[np.ndarray] = None, *, bins: ArrayLike, **kwargs + data: np.ndarray | None = None, *, bins: ArrayLike, **kwargs ) -> StaticBinning: """Construct static binning with whatever bins.""" # TODO: Fail with no bins! @@ -863,7 +872,7 @@ def static_binning( @register_binning() -def integer_binning(data: Optional[np.ndarray] = None, **kwargs) -> FixedWidthBinning: +def integer_binning(data: np.ndarray | None = None, **kwargs) -> FixedWidthBinning: """Construct fixed-width binning schema with bins centered around integers. Parameters @@ -886,10 +895,10 @@ def integer_binning(data: Optional[np.ndarray] = None, **kwargs) -> FixedWidthBi @register_binning() def fixed_width_binning( - data: Optional[np.ndarray] = None, + data: np.ndarray | None = None, bin_width: Union[float, int] = 1, *, - range: Optional[RangeTuple] = None, + range: RangeTuple | None = None, includes_right_edge: bool = False, **kwargs, ) -> FixedWidthBinning: @@ -912,7 +921,6 @@ def fixed_width_binning( if not kwargs.get("adaptive"): return result # Otherwise we want to adapt to data if data is not None and data.shape[0]: - # print("Jo, tady") result._force_bin_existence( [np.min(data), np.max(data)], includes_right_edge=includes_right_edge ) diff --git a/src/physt/histogram_base.py b/src/physt/histogram_base.py index 0238c20..66318c7 100644 --- a/src/physt/histogram_base.py +++ b/src/physt/histogram_base.py @@ -8,7 +8,7 @@ import numpy as np -from physt.binnings import BinningBase, as_binning +from physt.binnings import BinningBase, as_binning, BinMap from physt.config import config from physt.statistics import INVALID_STATISTICS @@ -488,10 +488,10 @@ def adaptive(self, value: bool): def _change_binning( self, new_binning: BinningBase, - bin_map: Iterable[Tuple[int, int]], + bin_map: BinMap | None, axis: Axis = 0, ): - """Set new binnning and update the bin contents according to a map. + """Set new binning and update the bin contents according to a map. Fills frequencies and errors with 0. It's the caller's responsibility to provide correct binning and map. @@ -567,7 +567,7 @@ def merge_bins( self._change_binning(new_binning, bin_map, axis=axis) return self - def _reshape_data(self, new_size: int, bin_map, axis: int = 0): + def _reshape_data(self, new_size: int, bin_map: BinMap, axis: int = 0): """Reshape data to match new binning schema. Fills frequencies and errors with 0. @@ -605,7 +605,7 @@ def _apply_bin_map( new_frequencies: np.ndarray, old_errors2: np.ndarray, new_errors2: np.ndarray, - bin_map: Union[Iterable[Tuple[int, int]], int], + bin_map: BinMap, axis: int, ): """Fill new data arrays using a map. @@ -657,8 +657,8 @@ def has_same_bins(self, other: "HistogramBase") -> bool: return True def copy( - self: "HistogramType", *, include_frequencies: bool = True - ) -> "HistogramType": + self, *, include_frequencies: bool = True + ) -> Self: """Copy the histogram. Parameters @@ -673,7 +673,7 @@ def copy( frequencies = np.zeros_like(self._frequencies) errors2 = np.zeros_like(self._errors2) missed = np.zeros_like(self._missed) - a_copy = self.__class__.__new__(self.__class__) + a_copy = type(self).__new__(type(self)) a_copy._binnings = [binning.copy() for binning in self._binnings] a_copy._dtype = self.dtype a_copy._frequencies = frequencies @@ -1009,7 +1009,7 @@ def __itruediv__(self, other): raise TypeError("Histograms may be divided only by a constant.") return self - def __lshift__(self, value): + def __lshift__(self, value) -> None: """Convenience alias for fill. Because of the limit to argument count, weight is not supported. @@ -1033,8 +1033,8 @@ def _merge_meta_data(cls, first: "HistogramBase", second: "HistogramBase") -> di for key in keys } - def __array__(self) -> np.ndarray: - """Convert to numpy array. + def __array__(self, dtype=None, copy: bool | None = None) -> np.ndarray: + """Convert to a numpy array. Returns ------- @@ -1044,4 +1044,4 @@ def __array__(self) -> np.ndarray: -------- frequencies """ - return self.frequencies + return np.asarray(self.frequencies, dtype=dtype, copy=copy) diff --git a/src/physt/plotting/matplotlib.py b/src/physt/plotting/matplotlib.py index 49bed55..8720625 100644 --- a/src/physt/plotting/matplotlib.py +++ b/src/physt/plotting/matplotlib.py @@ -32,6 +32,7 @@ from __future__ import annotations +from collections.abc import Iterable from contextlib import suppress from functools import wraps from typing import TYPE_CHECKING, cast @@ -69,8 +70,8 @@ # To be filled by register function -types = [] -dims = {} +types: list[str] = [] +dims: dict[str, Iterable[int]] = {} default_dpi = 72 From b43d6086ff95689b84088fec7a7543212312527d Mon Sep 17 00:00:00 2001 From: Jan Pipek Date: Tue, 7 Jul 2026 15:35:43 +0200 Subject: [PATCH 2/4] pre-commit --- src/physt/binnings.py | 18 +++++++++++------- src/physt/cli.py | 2 +- src/physt/histogram_base.py | 6 ++---- tests/test_statistics.py | 4 +++- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/physt/binnings.py b/src/physt/binnings.py index a1e940e..09cb37d 100644 --- a/src/physt/binnings.py +++ b/src/physt/binnings.py @@ -3,10 +3,10 @@ from __future__ import annotations import warnings -from abc import abstractmethod, ABC +from abc import ABC, abstractmethod from collections.abc import Iterable from contextlib import suppress -from typing import TYPE_CHECKING, cast, final, TypeAlias +from typing import TYPE_CHECKING, TypeAlias, final import numpy as np from typing_extensions import Self @@ -481,10 +481,10 @@ def __init__( bin_times_min: int | None = None, # TODO: move this to the helper method? min: float | None = None, - includes_right_edge : bool = False, + includes_right_edge: bool = False, adaptive: bool = False, - bin_shift: float | None =None, - align:bool = True, + bin_shift: float | None = None, + align: bool = True, **kwargs, ): super().__init__(adaptive=adaptive, includes_right_edge=includes_right_edge) @@ -521,7 +521,9 @@ def __repr__(self): def is_regular(self, **kwargs) -> bool: return True - def _force_bin_existence_single(self, value: float, *, includes_right_edge: bool | None = None) -> int | None: + def _force_bin_existence_single( + self, value: float, *, includes_right_edge: bool | None = None + ) -> int | None: if includes_right_edge is None: includes_right_edge = self.includes_right_edge @@ -553,7 +555,9 @@ def _force_bin_existence_single(self, value: float, *, includes_right_edge: bool else: return None - def _force_bin_existence(self, values, *, includes_right_edge=None) -> int | BinMap | None: + def _force_bin_existence( + self, values, *, includes_right_edge=None + ) -> int | BinMap | None: if np.isscalar(values): return self._force_bin_existence_single( values, includes_right_edge=includes_right_edge diff --git a/src/physt/cli.py b/src/physt/cli.py index 8641fad..065d8e8 100644 --- a/src/physt/cli.py +++ b/src/physt/cli.py @@ -80,7 +80,7 @@ def _load_data(path: Path) -> nw.DataFrame: return nw.read_csv(str(path), backend=backend) # type: ignore[arg-type] except KeyError: try: - return nw.read_parquet(str(path), backend=backend) # type: ignore[arg-type] + return nw.read_parquet(str(path), backend=backend) # type: ignore[arg-type] except EnvironmentError: continue diff --git a/src/physt/histogram_base.py b/src/physt/histogram_base.py index 66318c7..204d5be 100644 --- a/src/physt/histogram_base.py +++ b/src/physt/histogram_base.py @@ -8,7 +8,7 @@ import numpy as np -from physt.binnings import BinningBase, as_binning, BinMap +from physt.binnings import BinMap, BinningBase, as_binning from physt.config import config from physt.statistics import INVALID_STATISTICS @@ -656,9 +656,7 @@ def has_same_bins(self, other: "HistogramBase") -> bool: return False return True - def copy( - self, *, include_frequencies: bool = True - ) -> Self: + def copy(self, *, include_frequencies: bool = True) -> Self: """Copy the histogram. Parameters diff --git a/tests/test_statistics.py b/tests/test_statistics.py index b8eb308..54bf7ea 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -87,7 +87,9 @@ def test_std(self, histogram, use_weights): def test_str(self, histogram, use_weights): str_repr = str(histogram.statistics) - pattern = re.compile(r"^Statistics\(mean=[0-9.\-]+, std=[0-9.\-]+, min=[0-9.\-]+, max=[0-9.\-]+, total=[0-9.\-]+\)$") + pattern = re.compile( + r"^Statistics\(mean=[0-9.\-]+, std=[0-9.\-]+, min=[0-9.\-]+, max=[0-9.\-]+, total=[0-9.\-]+\)$" + ) assert pattern.match(str_repr) From 71de436cc1871c91d0a717a1e5a5a612661d5e53 Mon Sep 17 00:00:00 2001 From: Jan Pipek Date: Wed, 8 Jul 2026 00:06:55 +0200 Subject: [PATCH 3/4] basedpyright --- pyproject.toml | 5 +++++ uv.lock | 32 +++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 03050ed..51b240d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "click>=8.1.8", "narwhals>=2.0.1", "rich>=14.1.0", + "basedpyright>=1.39.9", ] [dependency-groups] @@ -74,6 +75,10 @@ ignore_missing_imports = true # plugins = ["numpy.typing.mypy_plugin"] exclude = ["docs"] +[tool.pyright] +reportImplicitOverride = false +reportExplicitAny = false + [tool.isort] profile = "black" diff --git a/uv.lock b/uv.lock index ec74431..09e3368 100644 --- a/uv.lock +++ b/uv.lock @@ -187,6 +187,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "basedpyright" +version = "1.39.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -740,7 +752,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1787,6 +1799,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/78/843bcf0cf31f88d2f8a9a063d2d80817b1901657d83d65b89b3aa835732e/nbsphinx-0.9.8-py3-none-any.whl", hash = "sha256:92d95ee91784e56bc633b60b767a6b6f23a0445f891e24641ce3c3f004759ccf", size = 31961, upload-time = "2025-11-28T17:41:00.796Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" }, + { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -2215,6 +2243,7 @@ name = "physt" version = "0.9.0" source = { editable = "." } dependencies = [ + { name = "basedpyright" }, { name = "click" }, { name = "hypothesis" }, { name = "narwhals" }, @@ -2292,6 +2321,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "astropy", marker = "extra == 'astropy'", specifier = ">=6.0" }, + { name = "basedpyright", specifier = ">=1.39.9" }, { name = "click", specifier = ">=8.1.8" }, { name = "dask", extras = ["array"], marker = "extra == 'dask'", specifier = ">=2023.0" }, { name = "folium", marker = "extra == 'folium'" }, From 0ec6c669b6b715c3a732c6802d429874136b17dd Mon Sep 17 00:00:00 2001 From: Jan Pipek Date: Wed, 8 Jul 2026 00:07:13 +0200 Subject: [PATCH 4/4] A lot of typing... --- src/physt/_bin_utils.py | 12 +- src/physt/_util.py | 6 +- src/physt/binnings.py | 349 ++++++++++++++++-------------------- src/physt/histogram1d.py | 14 +- src/physt/histogram_base.py | 12 +- src/physt/histogram_nd.py | 6 +- 6 files changed, 182 insertions(+), 217 deletions(-) diff --git a/src/physt/_bin_utils.py b/src/physt/_bin_utils.py index d2c0b71..c1bab45 100644 --- a/src/physt/_bin_utils.py +++ b/src/physt/_bin_utils.py @@ -7,8 +7,6 @@ import numpy as np if TYPE_CHECKING: - from typing import Optional, Tuple, Union - from typing_extensions import Literal from physt.typing_aliases import ArrayLike @@ -68,7 +66,7 @@ def to_numpy_bins(bins: ArrayLike) -> np.ndarray: return np.concatenate([bins[:1, 0], bins[:, 1]]) -def to_numpy_bins_with_mask(bins: ArrayLike) -> Tuple[np.ndarray, np.ndarray]: +def to_numpy_bins_with_mask(bins: ArrayLike) -> tuple[np.ndarray, np.ndarray]: """Numpy binning edges including gaps. Parameters @@ -90,9 +88,9 @@ def to_numpy_bins_with_mask(bins: ArrayLike) -> Tuple[np.ndarray, np.ndarray]: """ bins = np.asarray(bins) if bins.ndim == 1: - edges_: Union[np.ndarray, list] = bins + edges_: np.ndarray | list[float] = bins if bins.shape[0] > 1: - mask_: Union[np.ndarray, list] = np.arange(bins.shape[0] - 1) + mask_: np.ndarray | list[int] = np.arange(bins.shape[0] - 1) else: mask_ = [] elif bins.ndim == 2: @@ -135,7 +133,7 @@ def is_rising(bins: ArrayLike) -> bool: return True -def is_consecutive(bins: ArrayLike, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: +def is_consecutive(bins: ArrayLike, *, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: """Check whether the bins are consecutive (edges match). Does not check if the bins are in rising order. @@ -209,7 +207,7 @@ def find_pretty_width_24(raw_width: float) -> int: def find_pretty_width( - raw_width: float, kind: Optional[Literal["time"]] = None + raw_width: float, kind: Literal["time"] | None = None ) -> float: """Find the best pretty width close to a given raw_width.""" # TODO: Deal with infinity diff --git a/src/physt/_util.py b/src/physt/_util.py index 8abe7a0..a54a56a 100644 --- a/src/physt/_util.py +++ b/src/physt/_util.py @@ -11,10 +11,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Dict, Tuple + from typing import Any, Callable -def all_subclasses(cls: type) -> Tuple[type, ...]: +def all_subclasses(cls: type) -> tuple[type, ...]: """All subclasses of a class. From: http://stackoverflow.com/a/17246726/2692780 @@ -41,7 +41,7 @@ def find_subclass(base: type, name: str) -> type: return class_candidates[0] -def pop_many(a_dict: Dict[str, Any], *args: str, **kwargs) -> Dict[str, Any]: +def pop_many(a_dict: dict[str, Any], *args: str, **kwargs) -> dict[str, Any]: """Pop multiple items from a dictionary. Parameters diff --git a/src/physt/binnings.py b/src/physt/binnings.py index 09cb37d..127d889 100644 --- a/src/physt/binnings.py +++ b/src/physt/binnings.py @@ -2,14 +2,15 @@ from __future__ import annotations +from functools import cached_property import warnings from abc import ABC, abstractmethod from collections.abc import Iterable from contextlib import suppress -from typing import TYPE_CHECKING, TypeAlias, final +from typing import TYPE_CHECKING, TypeAlias, final, cast import numpy as np -from typing_extensions import Self +from typing_extensions import Self, override from physt._bin_utils import ( find_pretty_width, @@ -23,23 +24,22 @@ from physt._util import deprecation_alias, find_subclass if TYPE_CHECKING: + from collections.abc import Sequence from typing import ( Any, Callable, ClassVar, - Dict, - Optional, - Sequence, - Tuple, TypeVar, - Union, ) + """Anything that can be converted to a binning.""" + from typing_extensions import Literal from physt.typing_aliases import ArrayLike, RangeTuple BinningType = TypeVar("BinningType", bound="BinningBase") + BinningLike: TypeAlias = "BinningBase" | ArrayLike BinMap: TypeAlias = Iterable[tuple[int, int]] @@ -50,7 +50,7 @@ """Dictionary of available binnings.""" -def register_binning(name: Optional[str] = None) -> Callable[[Callable], Callable]: +def register_binning(name: str | None = None) -> Callable[[Callable], Callable]: """Decorator to register among available binning methods.""" def decorator(f: Callable) -> Callable: @@ -64,6 +64,7 @@ def decorator(f: Callable) -> Callable: # TODO: Locking and edit operations (like numpy read-only) +# TODO: Using pydantic models strongly recommended here class BinningBase(ABC): """Abstract base class for binning schemas. @@ -85,35 +86,19 @@ class BinningBase(ABC): def __init__( self, - bins: Optional[ArrayLike] = None, - numpy_bins: Optional[ArrayLike] = None, + *, includes_right_edge: bool = False, adaptive: bool = False, ): # TODO: Incorporate integrity_check? - self._consecutive = None - if bins is not None: - if numpy_bins is not None: - raise ValueError("Cannot specify numpy_bins and bins at the same time.") - bins = make_bin_array(bins) - if not is_rising(bins): - raise ValueError("Bins must be in rising order.") - # TODO: Test for consecutiveness? - elif numpy_bins is not None: - numpy_bins = to_numpy_bins(numpy_bins) - if not np.all(numpy_bins[1:] > numpy_bins[:-1]): - raise ValueError("Bins must be in rising order.") - self._consecutive = True - self._bins = bins - self._numpy_bins = numpy_bins - self._includes_right_edge = includes_right_edge + self._includes_right_edge: bool = includes_right_edge if adaptive and not self.adaptive_allowed: raise ValueError(f"Adaptivity not allowed for {self.__class__.__name__}.") if adaptive and includes_right_edge: raise ValueError( "Adaptivity does not work together with right-edge inclusion." ) - self._adaptive = adaptive + self._adaptive: bool = adaptive def __getitem__(self, index: slice | int) -> StaticBinning | np.ndarray: if isinstance(index, slice): @@ -124,7 +109,7 @@ def __getitem__(self, index: slice | int) -> StaticBinning | np.ndarray: @staticmethod def from_dict(a_dict: dict[str, Any]) -> BinningBase: - binning_type = a_dict.pop("binning_type", "StaticBinning") + binning_type: str = a_dict.pop("binning_type", "StaticBinning") klass = find_subclass(BinningBase, binning_type) return klass(**a_dict) @@ -132,7 +117,8 @@ def from_dict(a_dict: dict[str, Any]) -> BinningBase: def to_dict(self) -> dict[str, Any]: """Dictionary representation of the binning schema. - This serves as template method, please implement _update_dict + This is a template method with the main attributes, please implement _update_dict + to add details. """ result: dict[str, Any] = { "adaptive": self._adaptive, @@ -168,11 +154,7 @@ def is_consecutive(self, *, rtol: float = 1.0e-5, atol: float = 1.0e-8) -> bool: rtol, atol : numpy tolerance parameters """ if self.inconsecutive_allowed: - if self._consecutive is None: - if self._numpy_bins is not None: - self._consecutive = True - self._consecutive = is_consecutive(self.bins, rtol, atol) - return self._consecutive + return is_consecutive(self.bins, rtol=rtol, atol=atol) return True def is_adaptive(self) -> bool: @@ -180,22 +162,21 @@ def is_adaptive(self) -> bool: return self._adaptive @final - def force_bin_existence(self, values: ArrayLike) -> int | BinMap | None: + def force_bin_existence(self, values: np.ndarray) -> int | BinMap | None: """Change schema so that there is a bin for value. It is necessary to implement the _force_bin_existence template method. Parameters ---------- - values: np.ndarray - All values we want bins for. + values: All values we want bins for. Returns ------- - bin_map: _BinMap or None or int + bin_map: BinMap or None or int None => There was no change in bins int => The bins are only shifted (allows mass assignment) - Otherwise => the iterable contains tuples (old bin index, new bin index) + otherwise => the iterable contains tuples (old bin index, new bin index) new bin index can occur multiple times, which corresponds to bin merging """ if not self.is_adaptive(): @@ -203,7 +184,8 @@ def force_bin_existence(self, values: ArrayLike) -> int | BinMap | None: else: return self._force_bin_existence(values) - def _force_bin_existence(self, values) -> int | BinMap | None: + def _force_bin_existence(self, values: np.ndarray) -> int | BinMap | None: + # Implement this if appropriate. It cannot be an abstract method. raise NotImplementedError() @final @@ -223,15 +205,15 @@ def adapt(self, other: "BinningBase") -> tuple[BinMap | None, BinMap | None]: ---- Implement the `_adapt` template method. """ + if not self.is_adaptive(): + raise RuntimeError("Cannot adapt non-adaptive binning.") if np.array_equal(self.bins, other.bins): + # Already adapted return None, None - elif not self.is_adaptive(): - raise RuntimeError("Cannot adapt non-adaptive binning.") - else: - return self._adapt(other) + return self._adapt(other) - def _adapt(self, other) -> tuple[BinMap | None, BinMap | None]: - # By default, this is not possible + def _adapt(self, other: "BinningBase") -> tuple[BinMap | None, BinMap | None]: + # Implement this if appropriate. It cannot be an abstract method. raise RuntimeError(f"Cannot adapt {self.__class__.__name__}.") def set_adaptive(self, value: bool = True) -> None: @@ -243,25 +225,12 @@ def set_adaptive(self, value: bool = True) -> None: raise RuntimeError("Cannot change binning to adaptive.") self._adaptive = value - @property - def bins(self) -> np.ndarray: - """Bins in the wider format (as edge pairs) - - Returns - ------- - bins: np.ndarray - shape=(bin_count, 2) - """ - if self._bins is None: - self._bins = make_bin_array(self.numpy_bins) - return self._bins - - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if self is other: return True - if other.__class__ != self.__class__: + if type(other) != type(self): return False - if self._bins is not None: + if self.bins is not None: return np.array_equal(self.bins, other.bins) if self._numpy_bins is not None: return np.array_equal(self.numpy_bins, other.numpy_bins) @@ -271,11 +240,23 @@ def __eq__(self, other): return False @property + @abstractmethod def bin_count(self) -> int: """The total number of bins.""" - return self.bins.shape[0] @property + @abstractmethod + def bins(self) -> np.ndarray: + """Bins in the wider format (as edge pairs) + + Returns + ------- + bins: np.ndarray + shape=(bin_count, 2) + """ + + @property + @abstractmethod def numpy_bins(self) -> np.ndarray: """Bins in the numpy format @@ -286,12 +267,9 @@ def numpy_bins(self) -> np.ndarray: edges: np.ndarray shape=(bin_count+1,) """ - if self._numpy_bins is None: - self._numpy_bins = to_numpy_bins(self.bins) - return self._numpy_bins @property - def numpy_bins_with_mask(self) -> Tuple[np.ndarray, np.ndarray]: + def numpy_bins_with_mask(self) -> tuple[np.ndarray, np.ndarray]: """Bins in the numpy format, including the gaps in inconsecutive binnings. Returns @@ -310,16 +288,12 @@ def numpy_bins_with_mask(self) -> Tuple[np.ndarray, np.ndarray]: @property def first_edge(self) -> float: """The left edge of the first bin.""" - if self._numpy_bins is not None: - return self._numpy_bins[0] - return self.bins[0][0] + return self.bins[0, 0].item() @property def last_edge(self) -> float: """The right edge of the last bin.""" - if self._numpy_bins is not None: - return self._numpy_bins[-1] - return self.bins[-1][1] + return self.bins[-1, 1].item() def as_static(self, copy: bool = True) -> "StaticBinning": # pylint: disable=unused-argument """Convert binning to a static form. @@ -349,7 +323,7 @@ def as_fixed_width(self, copy: bool = True) -> "FixedWidthBinning": # pylint: d raise ValueError("Cannot guess binning width with zero bins") elif self.bin_count == 1 or self.is_consecutive() and self.is_regular(): return FixedWidthBinning( - min=self.bins[0][0], + min=self.bins[0, 0], bin_count=self.bin_count, bin_width=self.bins[1] - self.bins[0], ) @@ -392,35 +366,41 @@ def __repr__(self): return f"{self.__class__.__name__}({self.numpy_bins!r})" -if TYPE_CHECKING: - BinningLike = Union[BinningBase, ArrayLike] - """Anything that can be converted to a binning.""" - - class StaticBinning(BinningBase): """Binning defined by an array of bin edge pairs.""" + + inconsecutive_allowed: ClassVar[bool] = True - def __init__(self, bins, includes_right_edge=True, **kwargs): - super().__init__(bins=bins, includes_right_edge=includes_right_edge) + _bins: np.ndarray - def as_static(self, copy: bool = True) -> "StaticBinning": - """Convert binning to a static form. + def __init__(self, bins: ArrayLike, includes_right_edge : bool = True, **kwargs): + super().__init__(includes_right_edge=includes_right_edge) + bins = make_bin_array(bins) + if not is_rising(bins): + raise ValueError("Bins must be in rising order.") + self._bins = bins - Returns - ------- - StaticBinning - A new static binning with a copy of bins. + @property + def bins(self) -> np.ndarray: + return self._bins - Parameters - ---------- - copy : if True, returns itself (already satisfying conditions). - """ + @property + def bin_count(self) -> int: + return self._bins.shape[0] + + @property + def numpy_bins(self) -> np.ndarray: + return to_numpy_bins(self._bins) + + + def as_static(self, copy: bool = True) -> "StaticBinning": if copy: return self.copy() return self + def copy(self) -> StaticBinning: return type(self)( bins=self.bins.copy(), includes_right_edge=self.includes_right_edge @@ -445,15 +425,31 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({self.bins!r})" -class NumpyBinning(BinningBase): +class _NumpyLikeBinning(BinningBase, ABC): + """Binning that provides numpy_bins and derives bins from it.""" + + @property + @override + def bins(self)-> np.ndarray: + return make_bin_array(self.numpy_bins) + + # TODO: Add custom numpy_bins_with_mask + + @property + @override + def bin_count(self) -> int: + return self.numpy_bins.shape[0] - 1 + + +class NumpyBinning(_NumpyLikeBinning): """Binning schema working as numpy.histogram.""" - def __init__(self, numpy_bins: ArrayLike, includes_right_edge=True, **kwargs): + def __init__(self, numpy_bins: ArrayLike, **kwargs): + numpy_bins = np.asarray(numpy_bins) if not is_rising(numpy_bins): raise ValueError("Bins not in rising order.") - super().__init__( - numpy_bins=numpy_bins, includes_right_edge=includes_right_edge, **kwargs - ) + super().__init__(**kwargs) + self._numpy_bins: np.ndarray = np.asarray(numpy_bins) @property def numpy_bins(self) -> np.ndarray: @@ -464,14 +460,14 @@ def copy(self) -> NumpyBinning: numpy_bins=self.numpy_bins, includes_right_edge=self.includes_right_edge ) - def _update_dict(self, a_dict: dict) -> None: + def _update_dict(self, a_dict: dict[str, Any]) -> None: a_dict["numpy_bins"] = self.numpy_bins.tolist() -class FixedWidthBinning(BinningBase): +class FixedWidthBinning(_NumpyLikeBinning): """Binning schema with predefined bin width.""" - adaptive_allowed = True + adaptive_allowed: ClassVar[bool] = True def __init__( self, @@ -497,18 +493,17 @@ def __init__( raise ValueError("Cannot specify both min and (times_min or shift)") if (bin_count == 0) and ((bin_times_min is not None) or (min is not None)): raise ValueError("Cannot set min for an empty binning.") - self._bin_width = float(bin_width) - self._align = align - self._bin_count = int(bin_count) + self._bin_width: float = float(bin_width) + self._align: bool = align + self._bin_count: int = int(bin_count) if min is not None: - self._times_min = int(np.floor(min / self.bin_width)) - self._shift = min - self._times_min * self.bin_width + self._times_min: int = int(np.floor(min / self.bin_width)) + self._shift: float = min - self._times_min * self.bin_width else: self._times_min = bin_times_min self._shift = bin_shift or 0.0 - self._bins = None - self._numpy_bins = None + @override def __repr__(self): result = ( f"{self.__class__.__name__}(bin_width={self.bin_width}, " @@ -532,8 +527,6 @@ def _force_bin_existence_single( if not self._align: self._shift = value - self._times_min * self.bin_width self._bin_count = 1 - self._bins = None - self._numpy_bins = None return () else: add_left = add_right = 0 @@ -549,18 +542,16 @@ def _force_bin_existence_single( add_right += 1 self._bin_count += 1 if add_left or add_right: - self._bins = None - self._numpy_bins = None return add_left else: return None def _force_bin_existence( - self, values, *, includes_right_edge=None + self, values: float | np.ndarray, *, includes_right_edge=None ) -> int | BinMap | None: if np.isscalar(values): return self._force_bin_existence_single( - values, includes_right_edge=includes_right_edge + cast(float, values), includes_right_edge=includes_right_edge ) else: min_, max_ = np.min(values), np.max(values) @@ -582,15 +573,13 @@ def last_edge(self) -> float: return (self._times_min + self._bin_count) * self._bin_width + self._shift @property + # TODO: Cache this def numpy_bins(self) -> np.ndarray: - if self._numpy_bins is None: - self._bins = None - if self._bin_count == 0: - return np.zeros((0, 2), dtype=float) - self._numpy_bins = ( - self._times_min + np.arange(self._bin_count + 1, dtype=int) - ) * self._bin_width + self._shift - return self._numpy_bins + if self._bin_count == 0: + return np.zeros((0, 2), dtype=float) + return ( + self._times_min + np.arange(self._bin_count + 1, dtype=int) + ) * self._bin_width + self._shift @property def bin_count(self) -> int: @@ -625,11 +614,9 @@ def _force_new_min_max(self, new_min, new_max) -> BinMap | None: ) return bin_map - def _set_min_and_count(self, times_min, bin_count) -> None: + def _set_min_and_count(self, times_min: int, bin_count: int) -> None: self._bin_count = bin_count self._times_min = times_min - self._bins = None - self._numpy_bins = None def _adapt(self, other: BinningBase) -> tuple[BinMap | None, BinMap | None]: other = other.as_fixed_width() @@ -662,7 +649,7 @@ def as_fixed_width(self, copy: bool = True) -> "FixedWidthBinning": return self.copy() return self - def _update_dict(self, a_dict: Dict[str, Any]) -> None: + def _update_dict(self, a_dict: dict[str, Any]) -> None: # TODO: Fix to be instantiable from JSON a_dict["bin_count"] = self.bin_count a_dict["bin_width"] = self.bin_width @@ -670,15 +657,16 @@ def _update_dict(self, a_dict: Dict[str, Any]) -> None: a_dict["bin_times_min"] = self._times_min -class ExponentialBinning(BinningBase): +class ExponentialBinning(_NumpyLikeBinning): """Binning schema with exponentially distributed bins.""" - adaptive_allowed = False + adaptive_allowed: ClassVar[bool] = False # TODO: Implement adaptivity def __init__( self, + *, log_min: float, log_width: float, bin_count: int, @@ -689,9 +677,9 @@ def __init__( super(ExponentialBinning, self).__init__( includes_right_edge=includes_right_edge, adaptive=adaptive ) - self._log_min = log_min - self._log_width = log_width - self._bin_count = bin_count + self._log_min: float = log_min + self._log_width: float = log_width + self._bin_count: int = bin_count def is_regular(self, **kwargs) -> bool: return False @@ -700,17 +688,15 @@ def is_regular(self, **kwargs) -> bool: def numpy_bins(self) -> np.ndarray: if self._bin_count == 0: return np.ndarray((0,), dtype=float) - if self._numpy_bins is None: - log_bins = self._log_min + np.arange(self._bin_count + 1) * self._log_width - self._numpy_bins = 10.0**log_bins - return self._numpy_bins + log_bins = self._log_min + np.arange(self._bin_count + 1) * self._log_width + return 10.0 ** log_bins def copy(self) -> "ExponentialBinning": return ExponentialBinning( - self._log_min, self._log_width, self._bin_count, self.includes_right_edge + log_min=self._log_min, log_width=self._log_width, bin_count=self._bin_count, includes_right_edge=self.includes_right_edge ) - def _update_dict(self, a_dict) -> None: + def _update_dict(self, a_dict: dict[str, Any]) -> None: a_dict["log_min"] = self._log_min a_dict["log_width"] = self._log_width a_dict["bin_count"] = self._bin_count @@ -718,22 +704,19 @@ def _update_dict(self, a_dict) -> None: @register_binning() def numpy_binning( - data: Optional[np.ndarray] = None, + data: np.ndarray | None = None, bin_count: int = 10, - range: Optional[RangeTuple] = None, + range: RangeTuple | None = None, **kwargs, ) -> NumpyBinning: """Construct binning schema compatible with numpy.histogram together with int argument Parameters ---------- - data: array_like, optional - This is optional if both bins and range are set + data: This is optional if both bins and range are set bin_count: int - range: Optional[tuple] - (min, max) - includes_right_edge: Optional[bool] - default: True + range: (min, max) + includes_right_edge: default: True See Also -------- @@ -778,13 +761,13 @@ def numpy_binning( @register_binning() def pretty_binning( - data: Optional[np.ndarray], - bin_count: Optional[int] = None, + data: np.ndarray | None, + bin_count: int | None = None, *, - kind: Optional[Literal["time"]] = None, - range: Optional[RangeTuple] = None, - min_bin_width: Optional[float] = None, - max_bin_width: Optional[float] = None, + kind: Literal["time"] | None = None, + range: RangeTuple | None = None, + min_bin_width: float | None = None, + max_bin_width: float | None = None, **kwargs, ) -> FixedWidthBinning: """Construct fixed-width ninning schema with bins automatically optimized to human-friendly widths. @@ -829,11 +812,11 @@ def pretty_binning( @register_binning() def quantile_binning( - data: Optional[np.ndarray], + data: np.ndarray | None, *, - bin_count: Optional[int] = None, - q: Optional[Sequence[float]] = None, - qrange: Optional[RangeTuple] = None, + bin_count: int | None = None, + q: Sequence[float] | None = None, + qrange: RangeTuple | None = None, **kwargs, ) -> StaticBinning: """Binning schema based on quantile ranges. @@ -876,23 +859,22 @@ def static_binning( @register_binning() -def integer_binning(data: np.ndarray | None = None, **kwargs) -> FixedWidthBinning: +def integer_binning(data: np.ndarray | None = None, *, range: RangeTuple | None = None, bin_width: int = 1, **kwargs) -> FixedWidthBinning: """Construct fixed-width binning schema with bins centered around integers. Parameters ---------- - range: Optional[Tuple[int]] - min (included) and max integer (excluded) bin - bin_width: Optional[int] - group "bin_width" integers into one bin (not recommended) + range: min (included) and max integer (excluded) bin + bin_width: group "bin_width" integers into one bin (not recommended) """ - if "range" in kwargs: - kwargs["range"] = tuple(r - 0.5 for r in kwargs["range"]) + if range: + range = tuple(r - 0.5 for r in range) return fixed_width_binning( data=data, - bin_width=kwargs.pop("bin_width", 1), + bin_width=bin_width, align=True, bin_shift=0.5, + range=range, **kwargs, ) @@ -900,9 +882,10 @@ def integer_binning(data: np.ndarray | None = None, **kwargs) -> FixedWidthBinni @register_binning() def fixed_width_binning( data: np.ndarray | None = None, - bin_width: Union[float, int] = 1, + bin_width: float = 1.0, *, range: RangeTuple | None = None, + align: bool = True, includes_right_edge: bool = False, **kwargs, ) -> FixedWidthBinning: @@ -911,13 +894,11 @@ def fixed_width_binning( Parameters ---------- bin_width: float - range: Optional[tuple] - (min, max) - align: Optional[float] - Must be multiple of bin_width + range: (min, max) + align: Must be multiple of bin_width """ result = FixedWidthBinning( - bin_width=bin_width, includes_right_edge=includes_right_edge, **kwargs + bin_width=bin_width, includes_right_edge=includes_right_edge, align=align, **kwargs ) if range: result._force_bin_existence(range[0]) @@ -933,10 +914,10 @@ def fixed_width_binning( @register_binning() def exponential_binning( - data: Optional[np.ndarray] = None, - bin_count: Optional[int] = None, + data: np.ndarray | None = None, + bin_count: int | None = None, *, - range: Optional[RangeTuple] = None, + range: RangeTuple | None = None, **kwargs, ) -> ExponentialBinning: """Construct exponential binning schema. @@ -976,15 +957,11 @@ def exponential_binning( warnings.filterwarnings("ignore", module="astropy\\..*") @register_binning(name="blocks") - def bayesian_blocks_binning(data, range=None, **kwargs) -> StaticBinning: + def bayesian_blocks_binning(data: np.ndarray, *, range: RangeTuple | None = None, **kwargs) -> StaticBinning: """Binning schema based on Bayesian blocks (from astropy). Computationally expensive for large data sets. - Parameters - ---------- - range: Optional[tuple] - See also -------- astropy.stats.histogram.bayesian_blocks @@ -998,16 +975,11 @@ def bayesian_blocks_binning(data, range=None, **kwargs) -> StaticBinning: return StaticBinning(edges, **kwargs) @register_binning() - def knuth_binning(data, range=None, **kwargs) -> StaticBinning: + def knuth_binning(data: np.ndarray, *, range: RangeTuple | None = None, **kwargs) -> StaticBinning: """Binning schema based on Knuth's rule (from astropy). Computationally expensive for large data sets. - Parameters - ---------- - data: arraylike - range: Optional[tuple] - See also -------- astropy.stats.histogram.knuth_bin_width @@ -1018,18 +990,13 @@ def knuth_binning(data, range=None, **kwargs) -> StaticBinning: if range is not None: data = data[(data >= range[0]) & (data <= range[1])] - _, edges = knuth_bin_width(data, True) + _, edges = knuth_bin_width(data, return_bins=True) return StaticBinning(edges, **kwargs) @register_binning() - def scott_binning(data, range=None, **kwargs) -> StaticBinning: + def scott_binning(data: np.ndarray, *, range: RangeTuple | None =None, **kwargs) -> StaticBinning: """Binning schema based on Scott's rule (from astropy). - Parameters - ---------- - data: arraylike - range: Optional[tuple] - See also -------- astropy.stats.histogram.scott_bin_width @@ -1039,11 +1006,11 @@ def scott_binning(data, range=None, **kwargs) -> StaticBinning: if range is not None: data = data[(data >= range[0]) & (data <= range[1])] - _, edges = scott_bin_width(data, True) + _, edges = scott_bin_width(data, return_bins=True) return StaticBinning(edges, **kwargs) @register_binning() - def freedman_binning(data, range=None, **kwargs) -> StaticBinning: + def freedman_binning(data: np.ndarray, *, range : RangeTuple | None=None, **kwargs) -> StaticBinning: """Binning schema based on Freedman-Diaconis rule (from astropy). Parameters @@ -1061,7 +1028,7 @@ def freedman_binning(data, range=None, **kwargs) -> StaticBinning: if range is not None: data = data[(data >= range[0]) & (data <= range[1])] - _, edges = freedman_bin_width(data, True) + _, edges = freedman_bin_width(data, return_bins=True) return StaticBinning(edges, **kwargs) diff --git a/src/physt/histogram1d.py b/src/physt/histogram1d.py index 1f57cc7..c55e95b 100644 --- a/src/physt/histogram1d.py +++ b/src/physt/histogram1d.py @@ -134,15 +134,15 @@ class Histogram1D(ObjectWithBinning, HistogramBase): def __init__( self, binning: BinningLike, - frequencies: Optional[ArrayLike] = None, - errors2: Optional[ArrayLike] = None, + frequencies: ArrayLike | None = None, + errors2: ArrayLike | None = None, *, keep_missed: bool = True, - stats: Optional[Statistics] = None, - overflow: Optional[float] = 0.0, - underflow: Optional[float] = 0.0, - inner_missed: Optional[float] = 0.0, - axis_name: Optional[str] = None, + stats: Statistics | None = None, + overflow: float | None = 0.0, + underflow: float| None = 0.0, + inner_missed: float| None = 0.0, + axis_name: str | None = None, **kwargs, ): """Constructor diff --git a/src/physt/histogram_base.py b/src/physt/histogram_base.py index 204d5be..1fb6373 100644 --- a/src/physt/histogram_base.py +++ b/src/physt/histogram_base.py @@ -567,7 +567,7 @@ def merge_bins( self._change_binning(new_binning, bin_map, axis=axis) return self - def _reshape_data(self, new_size: int, bin_map: BinMap, axis: int = 0): + def _reshape_data(self, new_size: int, bin_map: BinMap | int | None, axis: int = 0): """Reshape data to match new binning schema. Fills frequencies and errors with 0. @@ -709,7 +709,7 @@ def bins(self) -> Union[np.ndarray, List[np.ndarray]]: ... @abc.abstractmethod def fill( self, value: float, weight: float = 1, **kwargs - ) -> Union[None, int, Tuple[int, ...]]: + ) -> Union[None, int, tuple[int, ...]]: """Update histogram with a new value. It is an in-place operation. @@ -730,7 +730,7 @@ def fill( def fill_n( self, values: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, *, dropna: bool = True, ): @@ -800,7 +800,7 @@ def _update_dict(self, a_dict: Dict[str, Any]) -> None: pass @classmethod - def _kwargs_from_dict(cls, a_dict: Mapping[str, Any]) -> Dict[str, Any]: + def _kwargs_from_dict(cls, a_dict: Mapping[str, Any]) -> dict[str, Any]: """Modify __init__ arguments from an external dictionary. Template method for from dict. @@ -832,7 +832,7 @@ def from_dict(cls, a_dict: Mapping[str, Any]) -> Self: kwargs = cls._kwargs_from_dict(a_dict) return cls(**kwargs) - def to_json(self, path: Optional[str] = None, **kwargs) -> str: + def to_json(self, path: str | None = None, **kwargs) -> str: """Convert to JSON representation. Parameters @@ -922,7 +922,7 @@ def __iadd__(self, other): ) return self - def __sub__(self, other): + def __sub__(self, other) -> Self: new = self.copy() new -= other if isinstance(other, HistogramBase): diff --git a/src/physt/histogram_nd.py b/src/physt/histogram_nd.py index 2a37d45..98134ee 100644 --- a/src/physt/histogram_nd.py +++ b/src/physt/histogram_nd.py @@ -279,8 +279,8 @@ def get_bin_centers( # def find_bin(self, value: Number, axis: Axis) -> Optional[int]: ... def find_bin( - self, value: ArrayLike, axis: Optional[Axis] = None - ) -> Union[None, int, Tuple[int, ...]]: + self, value: ArrayLike, axis: Axis | None = None + ) -> Union[None, int, tuple[int, ...]]: """Index(-ices) of bin corresponding to a value. Parameters @@ -349,7 +349,7 @@ def fill(self, value: ArrayLike, weight: float = 1, **kwargs): def fill_n( self, values: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, *, dropna: bool = True, columns: bool = False,