tidy3a 0.1.0__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.
tidy3a/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """tidy3A: tidy-style verbs for named multidimensional arrays."""
2
+
3
+ from tidy3a.__version__ import __version__
4
+ from tidy3a.errors import (
5
+ AlignError,
6
+ FilterError,
7
+ GroupError,
8
+ MutateError,
9
+ ReadError,
10
+ RefError,
11
+ SelectError,
12
+ SliceError,
13
+ SummariseError,
14
+ TidyError,
15
+ )
16
+ from tidy3a.expr import (
17
+ all,
18
+ any,
19
+ bins,
20
+ col,
21
+ env,
22
+ fields,
23
+ if_else,
24
+ label,
25
+ max,
26
+ mean,
27
+ median,
28
+ min,
29
+ n,
30
+ std,
31
+ sum,
32
+ var,
33
+ variable,
34
+ )
35
+ from tidy3a.formula import FormulaError, formula
36
+ from tidy3a.frame import TidyArray, tidy
37
+ from tidy3a.io import fixed_text, infer, read_array, read_binary
38
+ from tidy3a.linalg import (
39
+ angle,
40
+ arccos,
41
+ arcsin,
42
+ arctan,
43
+ arctan2,
44
+ ceil,
45
+ conj,
46
+ cos,
47
+ cosh,
48
+ dot,
49
+ exp,
50
+ floor,
51
+ imag,
52
+ log,
53
+ log2,
54
+ log10,
55
+ pack,
56
+ real,
57
+ rename_axes,
58
+ sign,
59
+ sin,
60
+ sinh,
61
+ sqrt,
62
+ tan,
63
+ tanh,
64
+ )
65
+ from tidy3a.selectors import (
66
+ all_of,
67
+ any_of,
68
+ contains,
69
+ ends_with,
70
+ everything,
71
+ is_numeric,
72
+ last_col,
73
+ matches,
74
+ num_range,
75
+ starts_with,
76
+ where,
77
+ )
78
+ from tidy3a.verbs import (
79
+ assign,
80
+ bind_cols,
81
+ filter,
82
+ full_join,
83
+ group_by,
84
+ inner_join,
85
+ left_join,
86
+ mutate,
87
+ pull,
88
+ select,
89
+ slice,
90
+ slice_head,
91
+ slice_tail,
92
+ summarise,
93
+ summarize,
94
+ ungroup,
95
+ )
96
+
97
+ nan = float("nan")
98
+
99
+ __all__ = [
100
+ "AlignError", "FilterError", "GroupError", "MutateError", "ReadError", "RefError",
101
+ "SelectError", "SliceError", "SummariseError", "TidyArray", "TidyError", "__version__",
102
+ "all", "all_of", "any", "any_of", "assign", "bind_cols", "bins", "col", "contains",
103
+ "ends_with", "env", "everything", "fields", "filter", "fixed_text", "full_join", "group_by", "if_else",
104
+ "infer", "inner_join", "is_numeric", "label", "last_col", "left_join", "matches", "max",
105
+ "mean", "median", "min", "mutate", "n", "nan", "num_range", "pull", "read_array", "read_binary", "select",
106
+ "slice", "slice_head", "slice_tail", "starts_with", "std", "sum", "summarise", "summarize",
107
+ "tidy", "ungroup", "var", "variable", "where",
108
+ # formula
109
+ "FormulaError", "formula",
110
+ # array layer
111
+ "angle", "arccos", "arcsin", "arctan", "arctan2", "ceil", "conj", "cos", "cosh", "dot",
112
+ "exp", "floor", "imag", "log", "log2", "log10", "pack", "real", "rename_axes", "sign",
113
+ "sin", "sinh", "sqrt", "tan", "tanh",
114
+ ]
tidy3a/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tidy3a/align.py ADDED
@@ -0,0 +1,136 @@
1
+ """Alignment rules (plan §5.5).
2
+
3
+ Labels on a shared axis must match exactly. The same labels in another
4
+ order are matched by label and reported; different labels need a join
5
+ verb. A labelled axis never pairs with an unlabelled one by length.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Hashable, Sequence
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ import xarray as xr
15
+
16
+ from tidy3a.errors import AlignError
17
+
18
+
19
+ def align_pair(
20
+ left: xr.DataArray, right: xr.DataArray, left_text: str, right_text: str, notes: list[str],
21
+ ) -> tuple[xr.DataArray, xr.DataArray]:
22
+ """Return *left* and *right* ready for exact arithmetic, or raise ``AlignError``."""
23
+ for dim in left.dims:
24
+ if dim not in right.dims:
25
+ continue
26
+ right = _match_axis(
27
+ dim, left, right, left_text, right_text, notes,
28
+ context=f"in {left_text} and {right_text}",
29
+ )
30
+ return left, right
31
+
32
+
33
+ def align_many(values: Sequence[Any], texts: Sequence[str], notes: list[str]) -> list[Any]:
34
+ """Align each array to earlier operands on every shared axis; keep scalars."""
35
+ aligned = []
36
+ for value, text in zip(values, texts):
37
+ if isinstance(value, xr.DataArray):
38
+ for previous, previous_text in zip(aligned, texts):
39
+ if isinstance(previous, xr.DataArray):
40
+ _, value = align_pair(previous, value, previous_text, text, notes)
41
+ aligned.append(value)
42
+ return aligned
43
+
44
+
45
+ def align_to(
46
+ value: xr.DataArray, target: xr.Dataset, text: str, notes: list[str],
47
+ ) -> xr.DataArray:
48
+ """Match *value*'s shared axes to *target*'s, reordering by label when needed."""
49
+ for dim in value.dims:
50
+ if dim not in target.dims:
51
+ continue
52
+ value = _match_axis(dim, target, value, "this data", text, notes, context=f"for {text}")
53
+ return value
54
+
55
+
56
+ def check_join_axes(left: xr.Dataset, right: xr.Dataset, right_text: str) -> None:
57
+ """Joins align by label; a labelled axis still never pairs with an unlabelled one."""
58
+ for dim in left.dims:
59
+ if dim not in right.dims:
60
+ continue
61
+ labelled = (dim in left.indexes, dim in right.indexes)
62
+ if labelled[0] != labelled[1]:
63
+ raise AlignError(_labelled_message(dim, labelled[0], "this data", right_text))
64
+ if not any(labelled) and left.sizes[dim] != right.sizes[dim]:
65
+ raise AlignError(
66
+ f"{dim} is unlabelled on both sides with different lengths "
67
+ f"({left.sizes[dim]:,} and {right.sizes[dim]:,}); positions cannot be paired"
68
+ )
69
+
70
+
71
+ def _match_axis(
72
+ dim: Hashable, left, right: xr.DataArray, left_text: str, right_text: str,
73
+ notes: list[str], *, context: str,
74
+ ) -> xr.DataArray:
75
+ left_has = dim in left.indexes
76
+ right_has = dim in right.indexes
77
+ if left_has != right_has:
78
+ raise AlignError(_labelled_message(dim, left_has, left_text, right_text))
79
+ if not left_has:
80
+ if left.sizes[dim] != right.sizes[dim]:
81
+ raise AlignError(
82
+ f"{dim} has {left.sizes[dim]:,} positions in {left_text} but "
83
+ f"{right.sizes[dim]:,} in {right_text}, and neither has labels to match"
84
+ )
85
+ return right
86
+ left_index = left.indexes[dim]
87
+ right_index = right.indexes[dim]
88
+ if left_index.equals(right_index):
89
+ return right
90
+ if len(left_index) == len(right_index) and left_index.sort_values().equals(right_index.sort_values()):
91
+ before = " ".join(str(label) for label in _shown(right_index))
92
+ after = " ".join(str(label) for label in _shown(left_index))
93
+ notes.append(f"changed: {dim} reordered to match ({before} → {after}) {context}")
94
+ return right.sel({dim: left_index})
95
+ only_right = len(right_index.difference(left_index))
96
+ only_left = len(left_index.difference(right_index))
97
+ raise AlignError(
98
+ f"{dim} labels differ between {left_text} ({len(left_index):,}) "
99
+ f"and {right_text} ({len(right_index):,}).\n"
100
+ f" {only_right:,} labels are only in {right_text}; {only_left:,} only in {left_text}.\n"
101
+ f" To keep shared {dim} labels: inner_join(...)\n"
102
+ f" To keep this data's: left_join(...)"
103
+ + _rounding_hint(dim, left_index, right_index)
104
+ )
105
+
106
+
107
+ def _labelled_message(dim: Hashable, left_has: bool, left_text: str, right_text: str) -> str:
108
+ labelled, unlabelled = (left_text, right_text) if left_has else (right_text, left_text)
109
+ return (
110
+ f"{dim} is labelled on {labelled} but unlabelled on {unlabelled}.\n"
111
+ " Pairing by length could match the wrong positions.\n"
112
+ f' If {unlabelled} follows the same order, attach it by position: bind_cols(..., along="{dim}")'
113
+ )
114
+
115
+
116
+ def _rounding_hint(dim: Hashable, left_index, right_index) -> str:
117
+ if len(left_index) != len(right_index):
118
+ return ""
119
+ try:
120
+ left_values = np.asarray(left_index, dtype=float)
121
+ right_values = np.sort(np.asarray(right_index, dtype=float))
122
+ except (TypeError, ValueError):
123
+ return ""
124
+ gap = float(np.max(np.abs(np.sort(left_values) - right_values), initial=0.0))
125
+ scale = float(np.max(np.abs(left_values), initial=1.0)) or 1.0
126
+ if 0 < gap <= 1e-6 * scale:
127
+ return (
128
+ f"\n The labels differ only by up to {gap:.2g} (rounding).\n"
129
+ " To match them within a tolerance: left_join(..., tolerance=1e-9)"
130
+ )
131
+ return ""
132
+
133
+
134
+ def _shown(index) -> list:
135
+ values = list(index)
136
+ return values if len(values) <= 4 else [*values[:3], "…"]
tidy3a/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Exceptions raised by tidy3A.
2
+
3
+ Every tidy3A error is a ``ValueError``, so code that already catches bad
4
+ input keeps working; the subclasses say which step rejected it.
5
+ """
6
+
7
+
8
+ class TidyError(ValueError):
9
+ """Base class for tidy3A errors."""
10
+
11
+
12
+ class ReadError(TidyError):
13
+ """An array or record reader cannot read the source as described."""
14
+
15
+
16
+ class RefError(TidyError):
17
+ """A name in an expression is unknown or means more than one thing."""
18
+
19
+
20
+ class SelectError(RefError):
21
+ """``select`` cannot pick what was asked for."""
22
+
23
+
24
+ class FilterError(TidyError):
25
+ """``filter`` cannot keep a rectangle of positions."""
26
+
27
+
28
+ class SliceError(TidyError):
29
+ """``slice`` was given positions that do not exist."""
30
+
31
+
32
+ class MutateError(TidyError):
33
+ """``mutate`` cannot store a result."""
34
+
35
+
36
+ class GroupError(TidyError):
37
+ """``group_by`` cannot group by what was asked for."""
38
+
39
+
40
+ class SummariseError(TidyError):
41
+ """``summarise`` cannot reduce a summary to one value per group."""
42
+
43
+
44
+ class AlignError(TidyError):
45
+ """Labels on a shared axis do not match."""