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.
@@ -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