xarray-annotated 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xarray_annotated/__init__.py +50 -0
- xarray_annotated/_annotations.py +119 -0
- xarray_annotated/_config.py +101 -0
- xarray_annotated/_reader.py +110 -0
- xarray_annotated/_writer.py +99 -0
- xarray_annotated/py.typed +0 -0
- xarray_annotated/schema/__init__.py +62 -0
- xarray_annotated/schema/_annotations.py +254 -0
- xarray_annotated/schema/_check.py +232 -0
- xarray_annotated/schema/_config.py +147 -0
- xarray_annotated/schema/_decorator.py +124 -0
- xarray_annotated/temporal/__init__.py +60 -0
- xarray_annotated/temporal/_annotations.py +154 -0
- xarray_annotated/temporal/_check.py +326 -0
- xarray_annotated/temporal/_config.py +182 -0
- xarray_annotated/temporal/_decorator.py +133 -0
- xarray_annotated/units/__init__.py +53 -0
- xarray_annotated/units/_annotations.py +169 -0
- xarray_annotated/units/_check.py +592 -0
- xarray_annotated/units/_config.py +237 -0
- xarray_annotated/units/_decorator.py +203 -0
- xarray_annotated/units/_registry.py +94 -0
- xarray_annotated-0.5.2.dist-info/METADATA +156 -0
- xarray_annotated-0.5.2.dist-info/RECORD +26 -0
- xarray_annotated-0.5.2.dist-info/WHEEL +4 -0
- xarray_annotated-0.5.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Signature-driven structural-declaration decorator.
|
|
2
|
+
|
|
3
|
+
`declare_schema` wires the declaration side (`_annotations`) to the checking side
|
|
4
|
+
(`_check`): it reads a function's `Annotated[DataArray, <markers>]` hints once, at
|
|
5
|
+
decoration time, then on every call validates the declared inputs and outputs.
|
|
6
|
+
|
|
7
|
+
Like `units.declare_units` this is deliberately the *convenience* layer — a plain
|
|
8
|
+
function decorator over the public primitives (`schema_from_signature`,
|
|
9
|
+
`check_schema`, `assert_valid_schema`) with no subclassing hierarchy. Unlike it,
|
|
10
|
+
schema validation never mutates: inputs and outputs are *checked* and passed
|
|
11
|
+
through unchanged.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import functools
|
|
16
|
+
import inspect
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import xarray as xr
|
|
21
|
+
|
|
22
|
+
from ._annotations import SchemaMarker, schema_from_signature
|
|
23
|
+
from ._check import assert_valid_schema, check_schema
|
|
24
|
+
from ._config import OnMismatch, _validate_on_mismatch, get_policy
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def declare_schema(
|
|
28
|
+
func: Callable[..., Any] | None = None,
|
|
29
|
+
*,
|
|
30
|
+
on_mismatch: OnMismatch | None = None,
|
|
31
|
+
) -> Callable[..., Any]:
|
|
32
|
+
"""Apply a function's signature-declared structure at runtime.
|
|
33
|
+
|
|
34
|
+
Reads the decorated function's own type annotations once, via
|
|
35
|
+
`schema_from_signature`: parameters annotated `Annotated[DataArray, <markers>]`
|
|
36
|
+
declare input structure, and a `TypedDict`/dataclass return (or a bare
|
|
37
|
+
`Annotated[DataArray, <markers>]` return) declares output structure.
|
|
38
|
+
|
|
39
|
+
On each call, under the active `Policy` (`get_policy`), the wrapper validates
|
|
40
|
+
every declared `DataArray` input via `check_schema`, runs the wrapped function,
|
|
41
|
+
then validates every declared output the same way. Nothing is mutated; a
|
|
42
|
+
mismatch raises/warns/ignores per the effective `on_mismatch` (a marker's own
|
|
43
|
+
override wins over this decorator's `on_mismatch`, which wins over the policy
|
|
44
|
+
default). When the policy is **disabled** the wrapper is a total no-op.
|
|
45
|
+
|
|
46
|
+
Usable bare (`@declare_schema`) or called (`@declare_schema(on_mismatch=...)`).
|
|
47
|
+
Every marker declaration is validated **at decoration time** (`assert_valid_schema`),
|
|
48
|
+
so a malformed declaration fails fast at import.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
func: The function to decorate (when used bare); `None` when parametrised.
|
|
52
|
+
on_mismatch: Default severity for this function, overriding the policy
|
|
53
|
+
default. `None` (default) resolves per call from `get_policy`.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A wrapped function that validates declared inputs and outputs.
|
|
57
|
+
"""
|
|
58
|
+
if on_mismatch is not None:
|
|
59
|
+
_validate_on_mismatch(on_mismatch)
|
|
60
|
+
|
|
61
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
62
|
+
input_markers, output_markers = schema_from_signature(fn)
|
|
63
|
+
sig = inspect.signature(fn)
|
|
64
|
+
qualname = getattr(fn, "__qualname__", repr(fn))
|
|
65
|
+
|
|
66
|
+
# Fail fast on malformed declarations (independent of policy).
|
|
67
|
+
for pname, markers in input_markers.items():
|
|
68
|
+
for marker in markers:
|
|
69
|
+
assert_valid_schema(marker, f"{qualname} input {pname!r}")
|
|
70
|
+
_assert_output_declarations(output_markers, qualname)
|
|
71
|
+
|
|
72
|
+
def _check_output(result: Any, eff: OnMismatch) -> Any:
|
|
73
|
+
if isinstance(output_markers, list):
|
|
74
|
+
if isinstance(result, xr.DataArray):
|
|
75
|
+
check_schema(result, output_markers, "return", eff, qualname)
|
|
76
|
+
elif isinstance(output_markers, dict):
|
|
77
|
+
for key, markers in output_markers.items():
|
|
78
|
+
if isinstance(result, dict):
|
|
79
|
+
value = result.get(key)
|
|
80
|
+
elif dataclasses.is_dataclass(result):
|
|
81
|
+
value = getattr(result, key, None)
|
|
82
|
+
else:
|
|
83
|
+
continue
|
|
84
|
+
if isinstance(value, xr.DataArray):
|
|
85
|
+
check_schema(value, markers, key, eff, qualname)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
@functools.wraps(fn)
|
|
89
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
90
|
+
pol = get_policy()
|
|
91
|
+
if not pol.enabled:
|
|
92
|
+
# Master switch off: a total no-op (no validation at all).
|
|
93
|
+
return fn(*args, **kwargs)
|
|
94
|
+
# Per-call default severity (a marker's own override still wins in check_schema).
|
|
95
|
+
eff = on_mismatch if on_mismatch is not None else pol.on_mismatch
|
|
96
|
+
if input_markers:
|
|
97
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
98
|
+
# bind_partial omits parameters left at their default; apply
|
|
99
|
+
# them so a declared default is validated too.
|
|
100
|
+
bound.apply_defaults()
|
|
101
|
+
for pname, val in list(bound.arguments.items()):
|
|
102
|
+
markers = input_markers.get(pname)
|
|
103
|
+
if markers is not None and isinstance(val, xr.DataArray):
|
|
104
|
+
check_schema(val, markers, pname, eff, qualname)
|
|
105
|
+
return _check_output(fn(*args, **kwargs), eff)
|
|
106
|
+
|
|
107
|
+
wrapper.__annotations__ = dict(fn.__annotations__)
|
|
108
|
+
return wrapper
|
|
109
|
+
|
|
110
|
+
return decorate if func is None else decorate(func)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _assert_output_declarations(
|
|
114
|
+
output_markers: dict[str, list[SchemaMarker]] | list[SchemaMarker] | None,
|
|
115
|
+
qualname: str,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Fail-fast-validate declared output markers at decoration time."""
|
|
118
|
+
if isinstance(output_markers, list):
|
|
119
|
+
for marker in output_markers:
|
|
120
|
+
assert_valid_schema(marker, f"{qualname} output")
|
|
121
|
+
elif isinstance(output_markers, dict):
|
|
122
|
+
for key, markers in output_markers.items():
|
|
123
|
+
for marker in markers:
|
|
124
|
+
assert_valid_schema(marker, f"{qualname} output {key!r}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Temporal-frequency domain for xarray-annotated.
|
|
2
|
+
|
|
3
|
+
Declares and validates the *frequency* of a DataArray's time axis — a property
|
|
4
|
+
derived from the values of its datetime coordinate rather than carried in its
|
|
5
|
+
metadata, which is why it is a domain of its own rather than a fourth structural
|
|
6
|
+
marker. Like ``xarray_annotated.schema`` (and unlike ``units``) it only *asserts*:
|
|
7
|
+
it never converts, resamples, or stamps anything.
|
|
8
|
+
|
|
9
|
+
A declaration compares on two things: **spacing** (always) and **phase** (the
|
|
10
|
+
``End``/``Begin`` convention, and the anchor where both sides spell one). So
|
|
11
|
+
``Freq("7D")`` accepts a weekly axis on any weekday, while ``Freq("W-SUN")`` catches
|
|
12
|
+
the classic resample footgun of a series landing on Wednesdays::
|
|
13
|
+
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
import xarray as xr
|
|
16
|
+
from xarray_annotated.temporal import declare_freq, Freq
|
|
17
|
+
|
|
18
|
+
@declare_freq
|
|
19
|
+
def weekly_mean(x: Annotated[xr.DataArray, Freq("D")]) -> Annotated[xr.DataArray, Freq("W-SUN")]:
|
|
20
|
+
return x.resample(time="W-SUN").mean()
|
|
21
|
+
|
|
22
|
+
``freq_compatible`` is the same comparison with no array in hand, for a build-time
|
|
23
|
+
check of a producer/consumer edge.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from ._annotations import Freq, annotated_freq, freq_from_signature
|
|
27
|
+
from ._check import (
|
|
28
|
+
FreqError,
|
|
29
|
+
FreqWarning,
|
|
30
|
+
assert_valid_freq,
|
|
31
|
+
check_freq,
|
|
32
|
+
freq_compatible,
|
|
33
|
+
)
|
|
34
|
+
from ._config import (
|
|
35
|
+
OnMismatch,
|
|
36
|
+
OnUninferable,
|
|
37
|
+
Policy,
|
|
38
|
+
get_policy,
|
|
39
|
+
policy,
|
|
40
|
+
set_policy,
|
|
41
|
+
)
|
|
42
|
+
from ._decorator import declare_freq
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Freq",
|
|
46
|
+
"FreqError",
|
|
47
|
+
"FreqWarning",
|
|
48
|
+
"OnMismatch",
|
|
49
|
+
"OnUninferable",
|
|
50
|
+
"Policy",
|
|
51
|
+
"annotated_freq",
|
|
52
|
+
"assert_valid_freq",
|
|
53
|
+
"check_freq",
|
|
54
|
+
"declare_freq",
|
|
55
|
+
"freq_compatible",
|
|
56
|
+
"freq_from_signature",
|
|
57
|
+
"get_policy",
|
|
58
|
+
"policy",
|
|
59
|
+
"set_policy",
|
|
60
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The `Freq` marker declaring a DataArray's temporal frequency, and the reader.
|
|
2
|
+
|
|
3
|
+
The temporal counterpart to ``schema._annotations``: where the schema markers
|
|
4
|
+
declare properties an array *carries* (its dims, coords, dtype), ``Freq`` declares
|
|
5
|
+
a property derived from the *values* of its datetime coordinate — the spacing (and
|
|
6
|
+
phase) of its time axis::
|
|
7
|
+
|
|
8
|
+
Annotated[xr.DataArray, "degC", Freq("W-SUN")]
|
|
9
|
+
|
|
10
|
+
The declared string is kept **verbatim**: pandas silently defaults the anchor a user
|
|
11
|
+
did not spell (``"W"`` normalises to ``"W-SUN"``), so the raw spelling is the only
|
|
12
|
+
place the *anchoredness* of a declaration survives. See ``_check`` for how it is
|
|
13
|
+
compared.
|
|
14
|
+
|
|
15
|
+
There is deliberately **no bare-string shorthand** — a string in the metadata is a
|
|
16
|
+
unit or a description, never a frequency. The markers are immutable (``__slots__``)
|
|
17
|
+
and hashable so an annotation carrying them stays hashable; ``eq``/``repr`` cover all
|
|
18
|
+
fields, so ``eval(repr(m)) == m``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import (
|
|
22
|
+
Annotated,
|
|
23
|
+
Any,
|
|
24
|
+
get_args,
|
|
25
|
+
get_origin,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .._annotations import _is_dataarray_type, walk_signature
|
|
29
|
+
from ._config import OnMismatch, _validate_on_mismatch
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Freq:
|
|
33
|
+
"""Declare the expected temporal frequency of a DataArray's time axis.
|
|
34
|
+
|
|
35
|
+
``Freq("7D")`` declares an array whose datetime coordinate advances in
|
|
36
|
+
seven-day steps; ``Freq("W-SUN")`` declares the same spacing *and* pins the
|
|
37
|
+
phase to Sundays. Two independent comparisons follow from the declaration:
|
|
38
|
+
|
|
39
|
+
* **spacing** — always compared (``"7D"`` and ``"W-WED"`` have the same spacing);
|
|
40
|
+
* **phase** — the ``End``/``Begin`` convention (``"ME"`` vs ``"MS"``), always
|
|
41
|
+
compared; and the anchor (``"-WED"``, ``"-MAR"``), compared only where the
|
|
42
|
+
declaration *spells* it. ``Freq("W")`` therefore means "weekly, any weekday",
|
|
43
|
+
a deliberate divergence from pandas (which would default it to ``W-SUN``).
|
|
44
|
+
|
|
45
|
+
Pass ``anchored=True`` to opt in to pandas' default anchor anyway
|
|
46
|
+
(``Freq("W", anchored=True)`` means ``W-SUN`` and means it), or ``anchored=False``
|
|
47
|
+
to suppress the anchor comparison of a spelled-out anchor
|
|
48
|
+
(``Freq("W-SUN", anchored=False)`` = "weekly, any weekday").
|
|
49
|
+
|
|
50
|
+
Pass ``dim`` to name the time coordinate explicitly; by default the array's sole
|
|
51
|
+
datetime-like coordinate is used, and an array carrying two is ambiguous.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_freq: str
|
|
55
|
+
_dim: str | None
|
|
56
|
+
_anchored: bool | None
|
|
57
|
+
_on_mismatch: OnMismatch | None
|
|
58
|
+
__slots__ = ("_freq", "_dim", "_anchored", "_on_mismatch")
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
freq: str,
|
|
63
|
+
*,
|
|
64
|
+
dim: str | None = None,
|
|
65
|
+
anchored: bool | None = None,
|
|
66
|
+
on_mismatch: OnMismatch | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._freq = freq
|
|
69
|
+
self._dim = dim
|
|
70
|
+
self._anchored = anchored
|
|
71
|
+
self._on_mismatch = (
|
|
72
|
+
None if on_mismatch is None else _validate_on_mismatch(on_mismatch)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def freq(self) -> str:
|
|
77
|
+
"""The declared frequency string, exactly as spelled."""
|
|
78
|
+
return self._freq
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def dim(self) -> str | None:
|
|
82
|
+
"""The declared time dimension, or `None` to auto-detect it."""
|
|
83
|
+
return self._dim
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def anchored(self) -> bool | None:
|
|
87
|
+
"""Whether the anchor is binding, or `None` to infer it from the spelling."""
|
|
88
|
+
return self._anchored
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def on_mismatch(self) -> OnMismatch | None:
|
|
92
|
+
"""Per-marker severity override, or `None` to use the policy default."""
|
|
93
|
+
return self._on_mismatch
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
parts = [repr(self._freq)]
|
|
97
|
+
if self._dim is not None:
|
|
98
|
+
parts.append(f"dim={self._dim!r}")
|
|
99
|
+
if self._anchored is not None:
|
|
100
|
+
parts.append(f"anchored={self._anchored!r}")
|
|
101
|
+
if self._on_mismatch is not None:
|
|
102
|
+
parts.append(f"on_mismatch={self._on_mismatch!r}")
|
|
103
|
+
return f"Freq({', '.join(parts)})"
|
|
104
|
+
|
|
105
|
+
def __eq__(self, other: object) -> bool:
|
|
106
|
+
if isinstance(other, Freq):
|
|
107
|
+
return (self._freq, self._dim, self._anchored, self._on_mismatch) == (
|
|
108
|
+
other._freq,
|
|
109
|
+
other._dim,
|
|
110
|
+
other._anchored,
|
|
111
|
+
other._on_mismatch,
|
|
112
|
+
)
|
|
113
|
+
return NotImplemented
|
|
114
|
+
|
|
115
|
+
def __hash__(self) -> int:
|
|
116
|
+
return hash((self._freq, self._dim, self._anchored, self._on_mismatch))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def annotated_freq(hint: Any) -> Freq | None:
|
|
120
|
+
"""Return the `Freq` marker carried by an `Annotated` hint, or `None`.
|
|
121
|
+
|
|
122
|
+
The first `Freq` marker in the metadata, and only when the annotated base type
|
|
123
|
+
is a `DataArray` (incl. `DataArray | None`). Non-`Annotated` hints, hints on
|
|
124
|
+
non-DataArray types, and hints with no `Freq` marker return `None` (so
|
|
125
|
+
`walk_signature`'s "no declaration" filter drops them).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
hint: A type hint, typically from `get_type_hints(..., include_extras=True)`.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The declared `Freq`, or `None`.
|
|
132
|
+
"""
|
|
133
|
+
if get_origin(hint) is not Annotated:
|
|
134
|
+
return None
|
|
135
|
+
args = get_args(hint)
|
|
136
|
+
if not _is_dataarray_type(args[0]):
|
|
137
|
+
return None
|
|
138
|
+
return next((m for m in args[1:] if isinstance(m, Freq)), None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def freq_from_signature(
|
|
142
|
+
func: object,
|
|
143
|
+
) -> tuple[dict[str, Freq], dict[str, Freq] | Freq | None]:
|
|
144
|
+
"""Extract declared frequencies from a function's type annotations.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
func: A callable whose hints carry `Annotated[DataArray, Freq(...)]`.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
An `(inputs, output)` pair, where each declaration is that parameter's
|
|
151
|
+
`Freq`; `output` is a per-field dict for a `TypedDict` or dataclass return,
|
|
152
|
+
a single `Freq` for one declared `DataArray` return, or `None`.
|
|
153
|
+
"""
|
|
154
|
+
return walk_signature(func, annotated_freq)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Comparing declared frequencies, and validating a DataArray's time axis.
|
|
2
|
+
|
|
3
|
+
Two legs, sharing one model of what a frequency *is*:
|
|
4
|
+
|
|
5
|
+
* `freq_compatible` — marker vs marker, with no array in hand (the counterpart to
|
|
6
|
+
`schema.dims_compatible`), for a build-time check of a producer/consumer edge.
|
|
7
|
+
* `check_freq` — marker vs DataArray, the runtime leg.
|
|
8
|
+
|
|
9
|
+
**The model.** A declaration is compared along two independent keys:
|
|
10
|
+
|
|
11
|
+
* **spacing** — always compared. Fixed-length offsets (ticks, `D`, and `W`, which is
|
|
12
|
+
seven fixed days) reduce to a nanosecond count, so `"7D"` and `"W-WED"` have the
|
|
13
|
+
*same* spacing; calendar offsets reduce to a month count, so `"QE"` and `"3ME"` do
|
|
14
|
+
too. The two families never compare equal (`"ME"` is not `"30D"`). Anything else
|
|
15
|
+
(business days, custom offsets) is opaque and compares by its exact string.
|
|
16
|
+
* **phase** — the `End`/`Begin` convention (`"ME"` vs `"MS"`), always compared; and the
|
|
17
|
+
anchor (`"-WED"`, `"-MAR"`), compared only where **both** declarations determine it.
|
|
18
|
+
|
|
19
|
+
That last clause is the whole point. pandas silently defaults an anchor the user did
|
|
20
|
+
not spell (`to_offset("W").freqstr == "W-SUN"`), so anchoredness is read off the *raw
|
|
21
|
+
declared string* — a `"-"` in it — never off the normalised offset. `Freq("W")` thus
|
|
22
|
+
means "weekly, any weekday" (a deliberate divergence from pandas), while `Freq("W-SUN")`
|
|
23
|
+
means Sundays and will not accept a `W-WED` axis. A marker's `anchored=` keyword
|
|
24
|
+
overrides that inference either way.
|
|
25
|
+
|
|
26
|
+
Known limitation: `Day` is DST-aware in pandas while `24h` is not, so treating them as
|
|
27
|
+
the same spacing is an approximation for timezone-aware axes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import warnings
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import xarray as xr
|
|
34
|
+
from pandas.tseries.frequencies import to_offset
|
|
35
|
+
from pandas.tseries.offsets import (
|
|
36
|
+
BaseOffset,
|
|
37
|
+
Day,
|
|
38
|
+
MonthBegin,
|
|
39
|
+
MonthEnd,
|
|
40
|
+
QuarterBegin,
|
|
41
|
+
QuarterEnd,
|
|
42
|
+
Tick,
|
|
43
|
+
Week,
|
|
44
|
+
YearBegin,
|
|
45
|
+
YearEnd,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
from ._annotations import Freq
|
|
49
|
+
from ._config import (
|
|
50
|
+
OnMismatch,
|
|
51
|
+
OnUninferable,
|
|
52
|
+
_validate_on_mismatch,
|
|
53
|
+
_validate_on_uninferable,
|
|
54
|
+
get_policy,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_NANOS_PER_DAY = 86_400_000_000_000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FreqWarning(UserWarning):
|
|
61
|
+
"""Warning issued for a frequency mismatch or an uninferable time axis."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FreqError(Exception):
|
|
65
|
+
"""Raised for a frequency mismatch under ``on_mismatch="error"``.
|
|
66
|
+
|
|
67
|
+
Deliberately **not** a ``ValueError``: a mismatch (the *data* fails the
|
|
68
|
+
declaration) is a distinct event from a malformed *declaration* (an unparseable
|
|
69
|
+
offset string), which raises ``ValueError`` from ``assert_valid_freq``. Keeping
|
|
70
|
+
them on separate hierarchies means catching one never silently swallows the other.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_valid_freq(marker: Freq, context: str) -> None:
|
|
75
|
+
"""Validate a `Freq` *declaration* itself, independent of any DataArray.
|
|
76
|
+
|
|
77
|
+
Used at decoration time to fail fast on a malformed declaration (an unparseable
|
|
78
|
+
offset string, an empty dim name) rather than only when the decorated function
|
|
79
|
+
first runs. pandas' own message is kept — it is the one that explains that the
|
|
80
|
+
legacy aliases ``"M"`` and ``"H"`` are now spelled ``"ME"`` and ``"h"``.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
marker: The declared `Freq`.
|
|
84
|
+
context: A label for the error message (e.g. ``"f input 'x'"``).
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the declaration is malformed.
|
|
88
|
+
"""
|
|
89
|
+
if marker.dim is not None and not marker.dim:
|
|
90
|
+
raise ValueError(f"{context}: Freq dim must be a non-empty string.")
|
|
91
|
+
try:
|
|
92
|
+
to_offset(marker.freq)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"{context}: invalid frequency {marker.freq!r}. {exc}"
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _spacing(off: BaseOffset) -> tuple[str, object]:
|
|
100
|
+
"""The comparable spacing of an offset: fixed nanoseconds, months, or opaque."""
|
|
101
|
+
if isinstance(off, (Tick, Day)): # `Day` is not a `Tick` in pandas 3
|
|
102
|
+
return ("fixed", off.nanos)
|
|
103
|
+
if isinstance(off, Week):
|
|
104
|
+
return ("fixed", off.n * 7 * _NANOS_PER_DAY) # what makes `7D` ≡ `W-*`
|
|
105
|
+
if isinstance(off, (MonthEnd, MonthBegin)):
|
|
106
|
+
return ("months", off.n)
|
|
107
|
+
if isinstance(off, (QuarterEnd, QuarterBegin)):
|
|
108
|
+
return ("months", 3 * off.n)
|
|
109
|
+
if isinstance(off, (YearEnd, YearBegin)):
|
|
110
|
+
return ("months", 12 * off.n)
|
|
111
|
+
# Business days, custom offsets: no reduction we can trust, so compare exactly.
|
|
112
|
+
return ("opaque", off.freqstr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _convention(off: BaseOffset) -> str | None:
|
|
116
|
+
"""The `"end"` / `"begin"` convention of a calendar offset, else `None`."""
|
|
117
|
+
if isinstance(off, (MonthEnd, QuarterEnd, YearEnd)):
|
|
118
|
+
return "end"
|
|
119
|
+
if isinstance(off, (MonthBegin, QuarterBegin, YearBegin)):
|
|
120
|
+
return "begin"
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _anchor(off: BaseOffset) -> int | None:
|
|
125
|
+
"""The anchor pandas assigned to an offset (weekday / month), else `None`.
|
|
126
|
+
|
|
127
|
+
Note this is pandas' *normalised* anchor, which it fills in even where the user
|
|
128
|
+
spelled none — `_phase` decides whether it is binding. (The `type: ignore`s are
|
|
129
|
+
because these attributes live on the Cython offset classes but not in the stubs.)
|
|
130
|
+
"""
|
|
131
|
+
if isinstance(off, Week):
|
|
132
|
+
return off.weekday # type: ignore[attr-defined]
|
|
133
|
+
if isinstance(off, (QuarterEnd, QuarterBegin)):
|
|
134
|
+
return off.startingMonth # type: ignore[attr-defined]
|
|
135
|
+
if isinstance(off, (YearEnd, YearBegin)):
|
|
136
|
+
return off.month
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _phase(marker: Freq) -> tuple[str | None, int | None]:
|
|
141
|
+
"""The comparable phase of a declaration: `(convention, anchor-or-None)`.
|
|
142
|
+
|
|
143
|
+
The anchor is `None` — meaning *undetermined*, so it is not compared — unless the
|
|
144
|
+
declaration determines it: either the raw string spells it out (`"W-WED"`) or the
|
|
145
|
+
marker forces the question with `anchored=`.
|
|
146
|
+
"""
|
|
147
|
+
off = to_offset(marker.freq)
|
|
148
|
+
anchored = marker.anchored
|
|
149
|
+
if anchored is None:
|
|
150
|
+
anchored = "-" in marker.freq
|
|
151
|
+
return _convention(off), _anchor(off) if anchored else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def freq_compatible(a: Freq, b: Freq) -> bool:
|
|
155
|
+
"""Return whether two `Freq` declarations can describe the same time axis.
|
|
156
|
+
|
|
157
|
+
The marker-vs-marker counterpart to `check_freq` (no array in hand), for a static
|
|
158
|
+
/ build-time check of a producer/consumer edge. Two declarations are compatible
|
|
159
|
+
when their **spacing** is the same and their **phase** does not conflict — where
|
|
160
|
+
"conflict" needs *both* sides to determine the thing being compared, so a
|
|
161
|
+
declaration that does not spell an anchor never conflicts on one.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
a: A `Freq` marker.
|
|
165
|
+
b: Another `Freq` marker.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
`True` unless the two are provably inconsistent.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: If either frequency string is unparseable.
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
>>> from xarray_annotated.temporal import Freq, freq_compatible
|
|
175
|
+
>>> freq_compatible(Freq("7D"), Freq("W-WED")) # same spacing, no anchor on 7D
|
|
176
|
+
True
|
|
177
|
+
>>> freq_compatible(Freq("W-SUN"), Freq("W-WED")) # the resample-phase footgun
|
|
178
|
+
False
|
|
179
|
+
>>> freq_compatible(Freq("W"), Freq("W-WED")) # "W" spells no anchor
|
|
180
|
+
True
|
|
181
|
+
>>> freq_compatible(Freq("QE"), Freq("3ME")) # same spacing and convention
|
|
182
|
+
True
|
|
183
|
+
>>> freq_compatible(Freq("ME"), Freq("MS")) # month-end is not month-start
|
|
184
|
+
False
|
|
185
|
+
"""
|
|
186
|
+
if _spacing(to_offset(a.freq)) != _spacing(to_offset(b.freq)):
|
|
187
|
+
return False
|
|
188
|
+
(conv_a, anchor_a), (conv_b, anchor_b) = _phase(a), _phase(b)
|
|
189
|
+
if conv_a is not None and conv_b is not None and conv_a != conv_b:
|
|
190
|
+
return False
|
|
191
|
+
return not (anchor_a is not None and anchor_b is not None and anchor_a != anchor_b)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_datetime_like(da: xr.DataArray, name: str) -> bool:
|
|
195
|
+
"""Whether a coordinate carries timestamps — numpy datetimes or cftime ones."""
|
|
196
|
+
if np.issubdtype(da.coords[name].dtype, np.datetime64):
|
|
197
|
+
return True
|
|
198
|
+
return isinstance(da.indexes.get(name), xr.CFTimeIndex)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _locate_axis(da: xr.DataArray, marker: Freq) -> tuple[str | None, str]:
|
|
202
|
+
"""Find the time coordinate a `Freq` declaration is about.
|
|
203
|
+
|
|
204
|
+
Returns `(dim, "")` on success, or `(None, detail)` when the array offers no
|
|
205
|
+
unambiguous answer — which is itself a mismatch (the declaration says the array
|
|
206
|
+
has a time axis; it does not, or it has two).
|
|
207
|
+
"""
|
|
208
|
+
candidates = [str(name) for name in da.coords if _is_datetime_like(da, str(name))]
|
|
209
|
+
if marker.dim is not None:
|
|
210
|
+
if marker.dim not in candidates:
|
|
211
|
+
return None, (
|
|
212
|
+
f"declared {marker!r} but {marker.dim!r} is not a datetime "
|
|
213
|
+
f"coordinate; datetime coords: {candidates}"
|
|
214
|
+
)
|
|
215
|
+
return marker.dim, ""
|
|
216
|
+
if not candidates:
|
|
217
|
+
return None, f"declared {marker!r} but the array has no datetime coordinate"
|
|
218
|
+
if len(candidates) > 1:
|
|
219
|
+
return None, (
|
|
220
|
+
f"declared {marker!r} but the time axis is ambiguous: datetime coords "
|
|
221
|
+
f"{candidates}; name one with Freq(..., dim=...)"
|
|
222
|
+
)
|
|
223
|
+
return candidates[0], ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _infer(coord: xr.DataArray) -> str | None:
|
|
227
|
+
"""The frequency of a time coordinate, or `None` if it has none.
|
|
228
|
+
|
|
229
|
+
`xarray.infer_freq` (not pandas') so a `CFTimeIndex` — a 360-day or noleap
|
|
230
|
+
calendar — works too. It *raises* on fewer than three points and *returns* `None`
|
|
231
|
+
on irregular spacing; both mean the same thing here: uninferable.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
return xr.infer_freq(coord)
|
|
235
|
+
except ValueError:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _report(ok: bool, severity: OnMismatch | OnUninferable, message: str) -> None:
|
|
240
|
+
"""Act on a check result under the resolved severity."""
|
|
241
|
+
if ok or severity == "ignore":
|
|
242
|
+
return
|
|
243
|
+
if severity == "error":
|
|
244
|
+
raise FreqError(message)
|
|
245
|
+
# stacklevel=3 targets the declare_freq call path (user -> wrapper ->
|
|
246
|
+
# check_freq -> _report); one frame too far for a direct check_freq call.
|
|
247
|
+
warnings.warn(message, FreqWarning, stacklevel=3)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def check_freq(
|
|
251
|
+
da: xr.DataArray,
|
|
252
|
+
declared: Freq | list[Freq],
|
|
253
|
+
name: str,
|
|
254
|
+
on_mismatch: OnMismatch | None = None,
|
|
255
|
+
on_uninferable: OnUninferable | None = None,
|
|
256
|
+
qualname: str | None = None,
|
|
257
|
+
) -> xr.DataArray:
|
|
258
|
+
"""Validate `da`'s time axis against its declared frequency; return it unchanged.
|
|
259
|
+
|
|
260
|
+
Locates the declared time coordinate (the array's sole datetime-like coordinate,
|
|
261
|
+
unless the marker names one), infers its frequency with `xarray.infer_freq`, and
|
|
262
|
+
compares that with the declaration via `freq_compatible`. Because pandas always
|
|
263
|
+
hands back a fully-anchored string (`"W-WED"`, never `"7D"`), the *declaration*
|
|
264
|
+
alone decides how strict the comparison is — `Freq("7D")` accepts any weekday,
|
|
265
|
+
`Freq("W-SUN")` accepts only Sundays.
|
|
266
|
+
|
|
267
|
+
Two events, two axes. A frequency that contradicts the declaration is a
|
|
268
|
+
**mismatch**, as is an array with no (or an ambiguous) time axis. An axis whose
|
|
269
|
+
frequency cannot be inferred at all — fewer than three points, or irregular
|
|
270
|
+
spacing — is **uninferable**: the declaration was not violated, it was never
|
|
271
|
+
tested, and by default that warns rather than raises.
|
|
272
|
+
|
|
273
|
+
Severity resolves per marker as: the marker's own `on_mismatch` override, else the
|
|
274
|
+
`on_mismatch` argument, else the policy default. When the master switch is off
|
|
275
|
+
(`enabled=False`) this is a total no-op.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
da: The DataArray to validate.
|
|
279
|
+
declared: A single `Freq` or a list of them declared for `da`.
|
|
280
|
+
name: The parameter/field name, for error messages.
|
|
281
|
+
on_mismatch: Per-call default severity for a mismatch, overriding the policy
|
|
282
|
+
default but overridden by a marker's own `on_mismatch`.
|
|
283
|
+
on_uninferable: Per-call severity for an uninferable axis, overriding the
|
|
284
|
+
policy default.
|
|
285
|
+
qualname: Optional qualified function name, prefixed to messages.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
`da`, unchanged (validation never mutates).
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
FreqError: On a mismatch when the effective severity is ``"error"``.
|
|
292
|
+
"""
|
|
293
|
+
pol = get_policy()
|
|
294
|
+
if not pol.enabled:
|
|
295
|
+
return da
|
|
296
|
+
if on_mismatch is not None:
|
|
297
|
+
on_mismatch = _validate_on_mismatch(on_mismatch)
|
|
298
|
+
if on_uninferable is not None:
|
|
299
|
+
on_uninferable = _validate_on_uninferable(on_uninferable)
|
|
300
|
+
|
|
301
|
+
markers = [declared] if isinstance(declared, Freq) else declared
|
|
302
|
+
uninferable = on_uninferable or pol.on_uninferable
|
|
303
|
+
prefix = f"[{qualname}] {name!r}" if qualname else f"{name!r}"
|
|
304
|
+
for marker in markers:
|
|
305
|
+
severity = marker.on_mismatch or on_mismatch or pol.on_mismatch
|
|
306
|
+
dim, detail = _locate_axis(da, marker)
|
|
307
|
+
if dim is None:
|
|
308
|
+
_report(False, severity, f"{prefix} {detail}")
|
|
309
|
+
continue
|
|
310
|
+
inferred = _infer(da[dim])
|
|
311
|
+
if inferred is None:
|
|
312
|
+
_report(
|
|
313
|
+
False,
|
|
314
|
+
uninferable,
|
|
315
|
+
f"{prefix} frequency of {dim!r} is uninferable (fewer than three "
|
|
316
|
+
f"points, or irregular spacing), so {marker!r} was not checked",
|
|
317
|
+
)
|
|
318
|
+
continue
|
|
319
|
+
ok = freq_compatible(marker, Freq(inferred))
|
|
320
|
+
_report(
|
|
321
|
+
ok,
|
|
322
|
+
severity,
|
|
323
|
+
f"{prefix} frequency mismatch on {dim!r}: expected {marker.freq!r}, "
|
|
324
|
+
f"got {inferred!r}",
|
|
325
|
+
)
|
|
326
|
+
return da
|