Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fca7038
Overload get_quantity so a non-null input types as a Quantity
d-chambers Aug 8, 2026
ae8b481
Bring tests into ty's scope with two narrow overrides
d-chambers Aug 8, 2026
87b72ab
Share the open-range select types and cast the structured arrays
d-chambers Aug 8, 2026
6c32f5a
Make the test FiberIO subclasses conform to the base signatures
d-chambers Aug 8, 2026
3325d21
Narrow coords in tests and let write's return follow its argument
d-chambers Aug 8, 2026
3b4c885
Overload get_quantity_str and type the splatted kwarg dicts
d-chambers Aug 8, 2026
d0b3093
Narrow optionals and bind the splatted dicts in the index tests
d-chambers Aug 8, 2026
0e410e4
Widen get_filter_units and get_coord to match what they accept
d-chambers Aug 8, 2026
c575aa0
Narrow coord types in tests and let get_coord take a sequence
d-chambers Aug 8, 2026
59cf1d9
Narrow optionals in the planned index and filesystem iteration tests
d-chambers Aug 8, 2026
423c45a
Correct filter_df's return type and narrow the patch/pandas tests
d-chambers Aug 8, 2026
040e0b7
Import the submodules the tests reach through their parent package
d-chambers Aug 8, 2026
9f58392
Fix the import and assert style the linter flagged
d-chambers Aug 8, 2026
97ed62c
Keep tests out of ty's scope until their burn-down lands
d-chambers Aug 8, 2026
587863e
Drop the get_quantity overloads; the empty string breaks them
d-chambers Aug 8, 2026
ee9f240
Restore the reflected-operator case in test_boolean_comparisons
d-chambers Aug 8, 2026
0ca13c3
Address the counterpart review
d-chambers Aug 8, 2026
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
10 changes: 9 additions & 1 deletion dascore/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Callable, Mapping
from functools import partial
from pathlib import Path
from types import MappingProxyType
from types import EllipsisType, MappingProxyType
from typing import Literal, Protocol, TypeVar, get_args, runtime_checkable

import numpy as np
Expand Down Expand Up @@ -34,6 +34,14 @@ def map(self, func, iterables, **kwargs):
timeable_types = int | float | str | np.datetime64 | pd.Timestamp
opt_timeable_types = None | timeable_types

# A (start, stop) selection range. Either end may be `...` to leave that
# side open, which is why these are not simply tuples of the value type.
time_select_type = tuple[
opt_timeable_types | EllipsisType,
opt_timeable_types | EllipsisType,
]
float_select_type = tuple[float | EllipsisType | None, float | EllipsisType | None]

# Number types
numeric_types = int | float

Expand Down
7 changes: 5 additions & 2 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import json
import math
import re
from collections.abc import Sized
from collections.abc import Sequence, Sized
from contextlib import suppress
from functools import cache
from operator import gt, lt
Expand Down Expand Up @@ -2841,7 +2841,10 @@ def _max(self):

def get_coord(
*,
data: ArrayLike | np.ndarray | BaseCoord | None = None,
# An int names a length, producing a partial coord of that shape.
# Sequence is spelled out because ArrayLike does not cover a plain
# list, which is accepted here and used throughout the tests.
data: ArrayLike | np.ndarray | BaseCoord | Sequence | int | None = None,
values: ArrayLike | np.ndarray | None = None,
start=None,
min=None,
Expand Down
17 changes: 12 additions & 5 deletions dascore/io/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
NotRequired,
Protocol,
TypedDict,
TypeVar,
cast,
get_type_hints,
)
Expand All @@ -30,8 +31,9 @@
from dascore.compat import Progress, UPath
from dascore.constants import (
PROGRESS_LEVELS,
float_select_type,
path_types,
timeable_types,
time_select_type,
)
from dascore.core.attrs import PatchAttrs
from dascore.core.coordmanager import CoordManager
Expand Down Expand Up @@ -1061,8 +1063,8 @@ def read(
path: path_types | IOResourceManager,
file_format: str | None = None,
file_version: str | None = None,
time: tuple[timeable_types | None, timeable_types | None] | None = None,
distance: tuple[float | None, float | None] | None = None,
time: time_select_type | None = None,
distance: float_select_type | None = None,
**kwargs,
) -> dc.BaseSpool:
"""
Expand Down Expand Up @@ -1682,14 +1684,19 @@ def _has_gaps(patch):
return dc.spool(patches)


# write hands back the path it was given, so the return follows the
# argument rather than collapsing to the union: a Path in, a Path out.
_PathT = TypeVar("_PathT", bound=path_types)


def write(
patch_or_spool,
path: path_types,
path: _PathT,
file_format: str,
file_version: str | None = None,
split: bool = False,
**kwargs,
) -> path_types:
) -> _PathT:
"""
Write a Patch or Spool to disk.

Expand Down
17 changes: 10 additions & 7 deletions dascore/io/febus/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
from __future__ import annotations

import warnings
from types import EllipsisType
from typing import Literal

import numpy as np

import dascore as dc
from dascore.constants import opt_timeable_types, timeable_types
from dascore.constants import (
float_select_type,
opt_timeable_types,
time_select_type,
timeable_types,
)
from dascore.io import FiberIO, ScanPayload
from dascore.io.core import _make_scan_payload
from dascore.utils.hdf5 import H5Reader
Expand Down Expand Up @@ -39,11 +43,10 @@
)
from .t1utils import _get_t1_patch, _is_t1_file, _scan_t1

_float_select_type = tuple[float | EllipsisType | None, float | EllipsisType | None]
_time_select_type = tuple[
opt_timeable_types | EllipsisType,
opt_timeable_types | EllipsisType,
]
# Kept as module-local names for the many signatures below; the shared
# definitions live in dascore.constants.
_float_select_type = float_select_type
_time_select_type = time_select_type


class FebusPatchAttrs(dc.PatchAttrs):
Expand Down
8 changes: 7 additions & 1 deletion dascore/proc/resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import dascore as dc
import dascore.compat as compat
from dascore.constants import PatchType
from dascore.exceptions import FilterValueError
from dascore.exceptions import FilterValueError, ParameterError
from dascore.units import get_filter_units
from dascore.utils.imports import lazy_import
from dascore.utils.patch import (
Expand Down Expand Up @@ -229,6 +229,12 @@ def resample(
if coord_units is not None:
coord_units = 1 / coord_units
new_step, _ = get_filter_units(value, value, to_unit=coord_units)
if new_step is None:
msg = (
f"resample requires a sampling period for dimension {dim!r}; "
f"got {value!r}. Pass samples=True to resample by length."
)
raise ParameterError(msg)
# nasty hack so that ints/floats get converted to seconds.
if isinstance(step, np.timedelta64):
new_step = to_timedelta64(new_step)
Expand Down
17 changes: 13 additions & 4 deletions dascore/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ def get_quantity(
"""
Convert a value to a pint quantity.

Returns None for a null-ish input: None, Ellipsis, or the empty
string, which is how dascore spells "carries no units". Callers doing
arithmetic on the result have to handle that, since an unset unit
reaching a multiplication is a real error rather than a typing one.

Parameters
----------
value
Expand Down Expand Up @@ -289,6 +294,8 @@ def get_quantity_str(quant_value: unit_like) -> str | None:
"""
Ensure a unit/quantity is valid and return its string representation.

Returns None for a null input, including the empty string.

If it is not valid raise a [UnitError](`dascore.exceptions.UnitError`).

Parameters
Expand Down Expand Up @@ -353,11 +360,11 @@ def get_inverted_quant(quant: Quantity | None, data_units):


def get_filter_units(
arg1: Quantity | float,
arg2: Quantity | float,
arg1: Quantity | float | EllipsisType | None,
arg2: Quantity | float | EllipsisType | None,
to_unit: unit_like,
dim: str | None = None,
) -> tuple[float, float]:
) -> tuple[float | None, float | None]:
"""
Get a tuple for applying filter based on dimension coordinates.

Expand Down Expand Up @@ -427,7 +434,9 @@ def _check_to_units(to_unit, dim):
return out1, out2


def quant_sequence_to_quant_array(sequence: Sequence[Quantity]) -> Quantity:
def quant_sequence_to_quant_array(
sequence: Sequence[Quantity] | np.ndarray,
) -> Quantity:
"""
Convert a sequence of Quantities (eg list) to a Quantity array.

Expand Down
10 changes: 7 additions & 3 deletions dascore/utils/pd.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,9 @@ def adjust_segments(df, ignore_bad_kwargs=False, **kwargs):
return out.assign(_modified=~not_modified)


def filter_df(df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs) -> np.ndarray:
def filter_df(
df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs
) -> np.ndarray | pd.Series:
"""
Determine if each row of the index meets some filter requirements.

Expand All @@ -487,8 +489,10 @@ def filter_df(df: pd.DataFrame, ignore_bad_kwargs=False, **kwargs) -> np.ndarray

Returns
-------
A boolean array of the same len as df indicating if each row meets the
requirements.
A boolean mask of the same len as df indicating if each row meets the
requirements. Whether it comes back as a bare array or a Series
depends on which queries applied, so treat it as an opaque boolean
container rather than relying on either.
"""
min_max_query = _convert_times(df, _get_min_max_query(kwargs, df))
kwargs, range_query, _ = split_df_query(kwargs, df, ignore_bad_kwargs)
Expand Down
3 changes: 2 additions & 1 deletion docs/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ The [releases page](https://github.com/DASDAE/dascore/releases) tracks changes f

- **`to_float` handles pint quantities explicitly.** A time quantity now converts to its duration in seconds (`to_float(2 * dc.units.min) == 120.0`), which previously raised `DimensionalityError`. Every other quantity raises `UnitError` instead of being silently converted: pint's `float()` reduces a *dimensionless* quantity to base units, so a data size came back eight times too large (`25 MB` → `2e8`, the count in bits). Use `convert_units` or `get_byte_count` for an explicit conversion. Relatedly, filtering a coordinate that has no units with *any* quantity — `patch.notch_filter(distance=5 * dc.units.m)`, and dimensionless ones such as `20 %` — now raises `UnitError` with an explanatory message. Previously a dimensionless quantity was silently read as a bare number (`20 %` became 0.2 Hz) and a dimensional one leaked pint's `DimensionalityError`.
- `dascore.utils.time.to_int` and `to_float` are now overloaded wrappers over private `singledispatch` implementations, so a `Series` input is typed as returning a `Series` and an array as returning an array. Their runtime behaviour is unchanged, but `to_int.register(...)` and `to_float.register(...)` no longer exist; register new implementations on `_to_int` / `_to_float` instead. `convert_units` no longer declares a constrained `numeric` type variable — it accepted (and still accepts) `None`, quantities, and numpy scalars, none of which that variable admitted. `WARNING_ACTIONS` no longer lists `"all"`, which Python only began accepting in 3.14 and which raises on the 3.11–3.13 interpreters DASCore also supports; use `"always"`, which it aliases.
- `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, `BaseCoord.unit_str` is `None` for a coord carrying no units, and `dc.write` returns the path it was handed, which is not always a `Path`.
- Several public signatures now describe what they already accepted and returned. `get_filter_units` takes `None` or `...` for an open bound and returns `None` there, neither of which its annotation allowed. `filter_df` returns a `Series` once any filter applies and a bare array otherwise, having claimed only the array. `get_coord` accepts a plain sequence for `data`, and an `int` naming a length. `dc.read`'s `time` and `distance` accept the documented `(value, ...)` open-range form, via new `time_select_type` and `float_select_type` aliases in `dascore.constants` that replace equivalents previously private to the febus reader. `dc.write` is generic over its path type, so a `Path` in yields a `Path` out. Relatedly, `patch.resample(dim=None)` now raises `ParameterError` naming the dimension instead of failing later with `ValueError: cannot convert float NaN to integer`.
- `BaseCoord.size` is now a builtin `int` rather than an `np.int64`, and `1` rather than `1.0` for a shapeless coord, since it is computed with `math.prod` instead of `np.prod`. `Patch.sobel_filter`, `Patch.notch_filter` and `velocity_to_strain_rate_edgeless` build their result with `patch.new(...)` instead of a bare `dc.Patch(...)`, so a `Patch` subclass now survives them as their signatures already promised. Several other return annotations were corrected to describe long-standing behaviour rather than change it: `CoordManager.drop_disassociated_coords` and `CoordManager.drop_private_coords` return a `(coord_manager, array)` tuple, `BaseCoord.get_next_index` returns an array for a sized value and a numpy integer otherwise, and `BaseCoord.unit_str` is `None` for a coord carrying no units.
- `Patch.drop_coords` and `CoordManager.drop_coords` accept a sequence of names as well as bare names, so `patch.drop_coords(["latitude", "longitude"])` works alongside `patch.drop_coords("latitude", "longitude")`. Previously a list or set raised `TypeError` and a tuple or generator was silently ignored; a tuple now drops the named coordinates, and one naming a dimension raises `ParameterError` as a bare dimension name always has.
- **Runtime configuration is now two-tier.** `dc.set_config(...)` sets the process-wide base permanently (visible from every thread and task) and returns the new config; it is no longer a context manager. Temporary, thread/task-local overrides use the new `dc.config_context(...)` context manager, which restores on exit and isolates concurrent overrides via a `ContextVar`. `Spool.map(...)` captures the config active at the call and re-applies it in each worker, so thread- and process-pool workers observe the caller's config regardless of the pool's start method. Unknown config field names now raise instead of being silently ignored. Migrate `with dc.set_config(...)` to `with dc.config_context(...)`.
- PRODML 2.1 now supports writing one raw time-by-distance patch with `dc.write` as a standalone HDF5 file. Full PRODML 2.3 conformance requires EPC/XML packaging, which DASCore does not write.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,11 @@ markers = [
line-ending = "lf"

[tool.ty.src]
include = ["dascore"] # tests/ still has many diagnostics; expand scope later.
include = ["dascore"] # tests/ is burned down separately before it lands here.

# No rule is ignored any more: invalid-method-override,
# No rule is ignored globally: invalid-method-override,
# invalid-argument-type and invalid-return-type have each been burned down
# to zero and left on. Widening [tool.ty.src] to tests/ is the next step.
# to zero and left on.

# These files lazily import optional or untyped modules (xarray, numba,
# h5py.h5r) that are not installed in the pre-commit hook's environment.
Expand Down
23 changes: 19 additions & 4 deletions tests/test_core/test_coord_segmented.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ def float_gap_coord() -> CoordSegmented:
"""Two evenly sampled float blocks (0..9, 15..24) separated by a gap."""
c1 = get_coord(start=0.0, stop=10.0, step=1.0)
c2 = get_coord(start=15.0, stop=25.0, step=1.0)
return concat_coords(c1, c2)
out = concat_coords(c1, c2)
assert isinstance(out, CoordSegmented)
return out


@pytest.fixture(scope="session")
Expand All @@ -37,7 +39,9 @@ def time_gap_coord() -> CoordSegmented:
t0 = np.datetime64("2020-01-01T00:00:00", "ns")
c1 = get_coord(start=t0, stop=t0 + 10 * one_s, step=one_s)
c2 = get_coord(start=t0 + 12 * one_s, stop=t0 + 22 * one_s, step=one_s)
return concat_coords(c1, c2)
out = concat_coords(c1, c2)
assert isinstance(out, CoordSegmented)
return out


@pytest.fixture(scope="session")
Expand All @@ -46,15 +50,19 @@ def mixed_segment_coord() -> CoordSegmented:
c1 = get_coord(start=0.0, stop=10.0, step=1.0)
c2 = get_coord(data=np.array([12.0, 12.1, 13.7, 20.0]))
assert isinstance(c2, CoordMonotonicArray)
return concat_coords(c1, c2)
out = concat_coords(c1, c2)
assert isinstance(out, CoordSegmented)
return out


@pytest.fixture(scope="session")
def reverse_gap_coord() -> CoordSegmented:
"""A reverse-sorted segmented coordinate."""
c1 = get_coord(start=24.0, stop=14.0, step=-1.0)
c2 = get_coord(start=9.0, stop=-1.0, step=-1.0)
return concat_coords(c1, c2)
out = concat_coords(c1, c2)
assert isinstance(out, CoordSegmented)
return out


class TestConstruction:
Expand All @@ -79,6 +87,7 @@ def test_uniform_array_segments_promoted(self):
c1 = get_coord(data=np.arange(5.0))
c2 = get_coord(start=8.0, stop=12.0, step=1.0)
out = concat_coords(c1, c2)
assert isinstance(out, CoordSegmented)
assert all(isinstance(x, CoordRange) for x in out.segments)

def test_canonical_across_construction_orders(self):
Expand Down Expand Up @@ -193,6 +202,7 @@ def test_segmented_inputs_flatten(self, float_gap_coord):
"""Segmented inputs contribute their segments."""
c3 = get_coord(start=30.0, stop=40.0, step=1.0)
out = concat_coords(float_gap_coord, c3)
assert isinstance(out, CoordSegmented)
assert out.segment_count == 3

def test_units_param_sets_units(self):
Expand Down Expand Up @@ -554,6 +564,7 @@ def test_simplify_promotes_close_array_segment(self):
get_coord(data=values), get_coord(start=10.0, stop=14.0, step=1.0)
)
out = coord.simplify(0.1)
assert isinstance(out, CoordSegmented)
assert all(isinstance(x, CoordRange) for x in out.segments)

def test_negative_tolerance_raises(self, float_gap_coord):
Expand Down Expand Up @@ -700,6 +711,7 @@ def test_slice_can_promote_and_fuse(self):
a = get_coord(start=0.0, stop=10.0, step=1.0)
b = CoordMonotonicArray(values=np.array([10.0, 11.0, 12.0, 13.5]))
coord = concat_coords(a, b)
assert isinstance(coord, CoordSegmented)
assert coord.segment_count == 2
out = coord[0:13]
assert isinstance(out, CoordRange)
Expand Down Expand Up @@ -1055,6 +1067,7 @@ def test_isolated_sample_between_gaps(self):
"""A lone sample between gaps becomes its own segment."""
values = np.array([0.0, 1, 2, 10, 20, 21, 22])
coord = CoordSegmented.from_array(values)
assert isinstance(coord, CoordSegmented)
assert coord.segment_count == 3
assert np.array_equal(coord.values, values)
assert len(coord.get_discontinuities()) == 2
Expand All @@ -1067,6 +1080,7 @@ def test_datetime_gap(self):
[t0 + np.arange(5) * one_s, t0 + (np.arange(5) + 8) * one_s]
)
coord = CoordSegmented.from_array(values)
assert isinstance(coord, CoordSegmented)
assert coord.segment_count == 2
assert np.array_equal(coord.values, values)
assert len(coord.get_discontinuities("gaps")) == 1
Expand All @@ -1082,6 +1096,7 @@ def test_reverse_array(self):
"""Reverse-sorted arrays segment correctly."""
values = np.array([13.0, 12, 11, 10, 3, 2, 1, 0])
coord = CoordSegmented.from_array(values)
assert isinstance(coord, CoordSegmented)
assert coord.segment_count == 2
assert coord.reverse_sorted
assert np.array_equal(coord.values, values)
Expand Down
5 changes: 4 additions & 1 deletion tests/test_core/test_coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,7 @@ def test_len_one_array_like_start_no_deprecation(self):
action="always", category=DeprecationWarning, record=True
) as records:
coord = get_coord(shape=601, start=np.array([0]), step=1)
assert isinstance(coord, CoordRange)
assert coord.start == 0
assert coord.stop == 601
assert coord.shape == (601,)
Expand Down Expand Up @@ -1804,6 +1805,7 @@ def test_start_stop(self):
assert coord.step == 1
# Test start/stop
coord = get_coord(start=10, shape=10)
assert isinstance(coord, CoordPartial)
assert coord.start == 10
assert len(coord) == 10

Expand Down Expand Up @@ -2076,7 +2078,7 @@ def test_between_values(self, evenly_sampled_coord):
def test_units(self, evenly_sampled_float_coord_with_units):
"""Ensure values with units work."""
coord = evenly_sampled_float_coord_with_units
val1 = np.array([10, 20]) * get_quantity("m")
val1 = get_quantity("m") * np.array([10, 20])
val2 = val1.to(get_quantity("ft"))
ind1 = coord.get_next_index(val1)
ind2 = coord.get_next_index(val2)
Expand Down Expand Up @@ -2423,6 +2425,7 @@ def test_wildcard_select_question_mark(self):
coord = get_coord(data=np.array(["ch_1", "ch_2", "ch_10", "xx_1"]))
out, indexer = coord.select("ch_?")
assert np.array_equal(out.values, np.array(["ch_1", "ch_2"]))
assert isinstance(indexer, np.ndarray)
assert np.array_equal(indexer, np.array([True, True, False, False]))

def test_wildcard_select_no_match_returns_empty(self, string_coord):
Expand Down
Loading
Loading