modus-py 0.1.0a1__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.
- modus/__init__.py +177 -0
- modus/_builder.py +61 -0
- modus/errors.py +37 -0
- modus/expr.py +149 -0
- modus/frame.py +1095 -0
- modus/serialisation.py +151 -0
- modus/transform/__init__.py +157 -0
- modus/transform/_base.py +189 -0
- modus/transform/_formula.py +278 -0
- modus/transform/aggregations.py +623 -0
- modus/transform/cleaners.py +185 -0
- modus/transform/derivations.py +588 -0
- modus/transform/engine.py +617 -0
- modus/transform/event_views.py +373 -0
- modus/transform/filters.py +242 -0
- modus/transform/group_filters.py +165 -0
- modus/transform/groupers.py +563 -0
- modus/units.py +89 -0
- modus_py-0.1.0a1.dist-info/METADATA +110 -0
- modus_py-0.1.0a1.dist-info/RECORD +22 -0
- modus_py-0.1.0a1.dist-info/WHEEL +4 -0
- modus_py-0.1.0a1.dist-info/licenses/LICENSE +28 -0
modus/__init__.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""modus — schema-driven, unit-aware Polars transform engine.
|
|
2
|
+
|
|
3
|
+
modus pairs a Polars `ModusFrame` (a DataFrame with per-column Astropy unit
|
|
4
|
+
metadata) with `FrameTransform`, a builder-pattern engine for expressing
|
|
5
|
+
timeseries transform pipelines — grouping, cleaning, deriving, filtering, and
|
|
6
|
+
aggregating — declaratively, while keeping unit metadata accurate throughout.
|
|
7
|
+
|
|
8
|
+
Quick start
|
|
9
|
+
-----------
|
|
10
|
+
>>> import astropy.units as u
|
|
11
|
+
>>> import polars
|
|
12
|
+
>>> import modus
|
|
13
|
+
>>>
|
|
14
|
+
>>> frame = modus.ModusFrame(
|
|
15
|
+
... data=polars.DataFrame({"hoist_pressure1": [101.3, 98.7]}),
|
|
16
|
+
... units={"hoist_pressure1": u.kPa},
|
|
17
|
+
... )
|
|
18
|
+
>>> velocity = frame.col("distance") / frame.col("time") # UnitExpr; unit propagates
|
|
19
|
+
>>> frame = frame.with_columns(velocity=velocity)
|
|
20
|
+
|
|
21
|
+
Public surface
|
|
22
|
+
--------------
|
|
23
|
+
`ModusFrame`
|
|
24
|
+
Polars DataFrame paired with per-column Astropy unit metadata. Exposes
|
|
25
|
+
`~ModusFrame.col` to obtain unit-aware expressions and explicit
|
|
26
|
+
transformation methods (`~ModusFrame.with_columns`, `~ModusFrame.select`,
|
|
27
|
+
`~ModusFrame.filter`, `~ModusFrame.rename`, `~ModusFrame.drop`,
|
|
28
|
+
`~ModusFrame.sort`, `~ModusFrame.join`, `~ModusFrame.concat`) that
|
|
29
|
+
keep the unit dict accurate after each operation.
|
|
30
|
+
|
|
31
|
+
`UnitExpr`
|
|
32
|
+
A `polars.Expr` paired with an `u.UnitBase`. Returned by
|
|
33
|
+
`ModusFrame.col`; supports arithmetic operator overloads (``+``, ``-``,
|
|
34
|
+
``*``, ``/``) that propagate unit algebra alongside the Polars expression
|
|
35
|
+
tree.
|
|
36
|
+
|
|
37
|
+
:data:`TIMESTAMP_COLUMN`
|
|
38
|
+
Module-level constant (``"ts"``) naming the canonical timestamp column
|
|
39
|
+
present in every timeseries `ModusFrame`.
|
|
40
|
+
|
|
41
|
+
`ModusFrameBuilder`
|
|
42
|
+
Helper used to construct an `ModusFrame` from raw Polars data and unit
|
|
43
|
+
strings, parsed via `modus.units.registry`.
|
|
44
|
+
|
|
45
|
+
`FrameTransform`
|
|
46
|
+
Schema-driven, unit-aware transform engine. Build up a pipeline with
|
|
47
|
+
groupers, cleaners, derivations, filters, and aggregations, then call
|
|
48
|
+
`~FrameTransform.apply` to execute it against an `ModusFrame`. Both a
|
|
49
|
+
labelled timeseries and a per-event aggregated summary are returned as a
|
|
50
|
+
`TransformResult`.
|
|
51
|
+
|
|
52
|
+
`ModusError`, `FrameError`, `TransformError`
|
|
53
|
+
modus's exception hierarchy — see `modus.errors`.
|
|
54
|
+
|
|
55
|
+
`modus.units.registry`
|
|
56
|
+
Shared, extensible unit registry (see `modus.units`). Register custom
|
|
57
|
+
unit strings against it, Astropy-style, before parsing them.
|
|
58
|
+
|
|
59
|
+
Transform operations
|
|
60
|
+
---------------------
|
|
61
|
+
- Groupers: `DataGap`, `EventSequence`, `PatternSequence`
|
|
62
|
+
- Cleaners: `FillForward`, `FillBackward`, `FillNull`, `Interpolate`
|
|
63
|
+
- Derivations: `TimeDelta`, `Rate`, `LinearCombination`, `Formula`,
|
|
64
|
+
`ColumnExpression`, and the `Derivation` extension point with
|
|
65
|
+
`DerivationRegistry` for declarative construction by name
|
|
66
|
+
- Filters: `SuppressLeadingEvent`, `SuppressTrailingEvent`,
|
|
67
|
+
`SuppressEventCrossingGrouper`
|
|
68
|
+
- Aggregations: `Mean`, `Sum`, `Sem`, `Min`, `Max`, `MaxAbs`, `Std`, `First`,
|
|
69
|
+
`Last`, `Mode`, `NthValue`, `ValueSequence`, `Duration`, `Count`, and the
|
|
70
|
+
`Aggregation` extension point with `AggregationRegistry`
|
|
71
|
+
- Group filters: `GroupCondition`, `GroupFilter`
|
|
72
|
+
- Event views: `EventView`, `Budget`, `Rows`, `Seconds`
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Local
|
|
76
|
+
from modus import units
|
|
77
|
+
from modus._builder import ModusFrameBuilder
|
|
78
|
+
from modus.errors import FrameError, ModusError
|
|
79
|
+
from modus.expr import UnitExpr
|
|
80
|
+
from modus.frame import TIMESTAMP_COLUMN, ModusFrame
|
|
81
|
+
from modus.transform import (
|
|
82
|
+
Aggregation,
|
|
83
|
+
AggregationRegistry,
|
|
84
|
+
Budget,
|
|
85
|
+
ColumnExpression,
|
|
86
|
+
Count,
|
|
87
|
+
DataGap,
|
|
88
|
+
Derivation,
|
|
89
|
+
DerivationRegistry,
|
|
90
|
+
Duration,
|
|
91
|
+
EventSequence,
|
|
92
|
+
EventView,
|
|
93
|
+
FillBackward,
|
|
94
|
+
FillForward,
|
|
95
|
+
FillNull,
|
|
96
|
+
First,
|
|
97
|
+
Formula,
|
|
98
|
+
FrameTransform,
|
|
99
|
+
GroupCondition,
|
|
100
|
+
GroupFilter,
|
|
101
|
+
Interpolate,
|
|
102
|
+
Last,
|
|
103
|
+
LinearCombination,
|
|
104
|
+
Max,
|
|
105
|
+
MaxAbs,
|
|
106
|
+
Mean,
|
|
107
|
+
Min,
|
|
108
|
+
Mode,
|
|
109
|
+
NthValue,
|
|
110
|
+
PatternSequence,
|
|
111
|
+
Rate,
|
|
112
|
+
Rows,
|
|
113
|
+
Seconds,
|
|
114
|
+
Sem,
|
|
115
|
+
Std,
|
|
116
|
+
Sum,
|
|
117
|
+
SuppressEventCrossingGrouper,
|
|
118
|
+
SuppressLeadingEvent,
|
|
119
|
+
SuppressTrailingEvent,
|
|
120
|
+
TimeDelta,
|
|
121
|
+
TransformError,
|
|
122
|
+
TransformResult,
|
|
123
|
+
ValueSequence,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = [
|
|
128
|
+
"TIMESTAMP_COLUMN",
|
|
129
|
+
"Aggregation",
|
|
130
|
+
"AggregationRegistry",
|
|
131
|
+
"Budget",
|
|
132
|
+
"ColumnExpression",
|
|
133
|
+
"Count",
|
|
134
|
+
"DataGap",
|
|
135
|
+
"Derivation",
|
|
136
|
+
"DerivationRegistry",
|
|
137
|
+
"Duration",
|
|
138
|
+
"EventSequence",
|
|
139
|
+
"EventView",
|
|
140
|
+
"FillBackward",
|
|
141
|
+
"FillForward",
|
|
142
|
+
"FillNull",
|
|
143
|
+
"First",
|
|
144
|
+
"Formula",
|
|
145
|
+
"FrameError",
|
|
146
|
+
"FrameTransform",
|
|
147
|
+
"GroupCondition",
|
|
148
|
+
"GroupFilter",
|
|
149
|
+
"Interpolate",
|
|
150
|
+
"Last",
|
|
151
|
+
"LinearCombination",
|
|
152
|
+
"Max",
|
|
153
|
+
"MaxAbs",
|
|
154
|
+
"Mean",
|
|
155
|
+
"Min",
|
|
156
|
+
"Mode",
|
|
157
|
+
"ModusError",
|
|
158
|
+
"ModusFrame",
|
|
159
|
+
"ModusFrameBuilder",
|
|
160
|
+
"NthValue",
|
|
161
|
+
"PatternSequence",
|
|
162
|
+
"Rate",
|
|
163
|
+
"Rows",
|
|
164
|
+
"Seconds",
|
|
165
|
+
"Sem",
|
|
166
|
+
"Std",
|
|
167
|
+
"Sum",
|
|
168
|
+
"SuppressEventCrossingGrouper",
|
|
169
|
+
"SuppressLeadingEvent",
|
|
170
|
+
"SuppressTrailingEvent",
|
|
171
|
+
"TimeDelta",
|
|
172
|
+
"TransformError",
|
|
173
|
+
"TransformResult",
|
|
174
|
+
"UnitExpr",
|
|
175
|
+
"ValueSequence",
|
|
176
|
+
"units",
|
|
177
|
+
]
|
modus/_builder.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Builds an `ModusFrame` from raw Polars data and unit strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Standard Library
|
|
6
|
+
import collections.abc
|
|
7
|
+
|
|
8
|
+
# Third Party
|
|
9
|
+
import astropy.units as u
|
|
10
|
+
import polars
|
|
11
|
+
|
|
12
|
+
# Local
|
|
13
|
+
from modus.errors import FrameError
|
|
14
|
+
from modus.frame import ModusFrame
|
|
15
|
+
from modus.units import registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModusFrameBuilder:
|
|
19
|
+
"""Internal helper that builds a `ModusFrame` from raw Polars data
|
|
20
|
+
and a resolved unit map.
|
|
21
|
+
|
|
22
|
+
This is used by the retrieval layer and is not part of the public API.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def build(
|
|
27
|
+
data: polars.DataFrame,
|
|
28
|
+
column_units: collections.abc.Mapping[str, str],
|
|
29
|
+
) -> ModusFrame:
|
|
30
|
+
"""Build a `ModusFrame` from a Polars DataFrame and unit strings.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
data:
|
|
35
|
+
Raw Polars DataFrame with canonical column names.
|
|
36
|
+
column_units:
|
|
37
|
+
Mapping of column name to Astropy-compatible unit string
|
|
38
|
+
(e.g. ``"kPa"``, ``"kph"``).
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
ModusFrame
|
|
43
|
+
Frame with parsed Astropy units.
|
|
44
|
+
|
|
45
|
+
Raises
|
|
46
|
+
------
|
|
47
|
+
FrameError
|
|
48
|
+
When a unit string cannot be parsed by Astropy.
|
|
49
|
+
"""
|
|
50
|
+
parsed: dict[str, u.UnitBase] = {}
|
|
51
|
+
for col, unit_str in column_units.items():
|
|
52
|
+
if not unit_str:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
parsed[col] = registry.parse(unit_str)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise FrameError(f"Cannot parse unit string {unit_str!r} for column {col!r}: {exc}") from exc
|
|
58
|
+
return ModusFrame(data=data, units=parsed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["ModusFrameBuilder"]
|
modus/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""modus exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModusError(Exception):
|
|
7
|
+
"""Base class for all modus exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrameError(ModusError):
|
|
11
|
+
"""Raised for `ModusFrame` construction or structural validation failures.
|
|
12
|
+
|
|
13
|
+
Covers unparseable unit strings in frame definitions, malformed JSON
|
|
14
|
+
envelopes, and other structural validation failures detected during
|
|
15
|
+
frame initialization.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IncompatibleUnitsError(ModusError):
|
|
20
|
+
"""Raised when an operation encounters a physical dimensional mismatch.
|
|
21
|
+
|
|
22
|
+
Fired immediately at expression-build time when performing binary
|
|
23
|
+
operations between incompatible physical dimensions (e.g., adding
|
|
24
|
+
meters to seconds) on a `UnitExpr`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidTransformSchemaError(FrameError):
|
|
29
|
+
""""""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"FrameError",
|
|
34
|
+
"IncompatibleUnitsError",
|
|
35
|
+
"InvalidTransformSchemaError",
|
|
36
|
+
"ModusError",
|
|
37
|
+
]
|
modus/expr.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""UnitExpr — Polars expression paired with an Astropy unit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Standard Library
|
|
6
|
+
import dataclasses
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
# Third Party
|
|
10
|
+
import astropy.units as u
|
|
11
|
+
import polars
|
|
12
|
+
|
|
13
|
+
# Local
|
|
14
|
+
from modus.errors import IncompatibleUnitsError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Types that can appear on the right-hand side of a UnitExpr operation.
|
|
18
|
+
_Operand = typing.Union["UnitExpr", u.Quantity, int, float]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass(repr=False, eq=False, slots=True)
|
|
22
|
+
class UnitExpr:
|
|
23
|
+
"""A Polars expression paired with an Astropy physical unit.
|
|
24
|
+
|
|
25
|
+
`UnitExpr` overloads standard Python arithmetic operators to propagate
|
|
26
|
+
unit algebra alongside the underlying Polars expression tree. Unit
|
|
27
|
+
compatibility checks and dimensional algebra are evaluated immediately at
|
|
28
|
+
expression-build time, raising errors before any data processing or data
|
|
29
|
+
evaluation occurs within Polars.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
expr : polars.Expr
|
|
34
|
+
The underlying Polars expression tree.
|
|
35
|
+
unit : u.UnitBase
|
|
36
|
+
The Astropy unit associated with this expression root.
|
|
37
|
+
|
|
38
|
+
Examples
|
|
39
|
+
--------
|
|
40
|
+
>>> import polars as pl
|
|
41
|
+
>>> import astropy.units as u
|
|
42
|
+
>>> from modus.expr import UnitExpr
|
|
43
|
+
>>> # Simulating an expression generated from a ModusFrame
|
|
44
|
+
>>> distance = UnitExpr(pl.col("distance"), u.m)
|
|
45
|
+
>>> time = UnitExpr(pl.col("time"), u.s)
|
|
46
|
+
>>> velocity = distance / time
|
|
47
|
+
>>> velocity
|
|
48
|
+
<UnitExpr unit='m / s'>
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
expr: polars.Expr
|
|
52
|
+
unit: u.UnitBase
|
|
53
|
+
|
|
54
|
+
def __add__(self, other: _Operand) -> UnitExpr:
|
|
55
|
+
other_expr, other_unit = self._decompose(other)
|
|
56
|
+
if isinstance(other, (int, float)):
|
|
57
|
+
raise TypeError(
|
|
58
|
+
"Cannot add a bare scalar to a UnitExpr: the physical meaning is ambiguous. "
|
|
59
|
+
"Wrap the scalar as an astropy Quantity (e.g. 5 * u.m) to make units explicit."
|
|
60
|
+
)
|
|
61
|
+
if not self.unit.is_equivalent(other_unit):
|
|
62
|
+
raise IncompatibleUnitsError(f"Incompatible units for addition: {self.unit!r} + {other_unit!r}.")
|
|
63
|
+
return UnitExpr(expr=self.expr + other_expr, unit=self.unit)
|
|
64
|
+
|
|
65
|
+
def __radd__(self, other: _Operand) -> UnitExpr:
|
|
66
|
+
return self.__add__(other)
|
|
67
|
+
|
|
68
|
+
def __sub__(self, other: _Operand) -> UnitExpr:
|
|
69
|
+
other_expr, other_unit = self._decompose(other)
|
|
70
|
+
if isinstance(other, (int, float)):
|
|
71
|
+
raise TypeError(
|
|
72
|
+
"Cannot subtract a bare scalar from a UnitExpr: the physical meaning is ambiguous. "
|
|
73
|
+
"Wrap the scalar as an astropy Quantity (e.g. 5 * u.m) to make units explicit."
|
|
74
|
+
)
|
|
75
|
+
if not self.unit.is_equivalent(other_unit):
|
|
76
|
+
raise IncompatibleUnitsError(f"Incompatible units for subtraction: {self.unit!r} - {other_unit!r}.")
|
|
77
|
+
return UnitExpr(expr=self.expr - other_expr, unit=self.unit)
|
|
78
|
+
|
|
79
|
+
def __rsub__(self, other: _Operand) -> UnitExpr:
|
|
80
|
+
other_expr, other_unit = self._decompose(other)
|
|
81
|
+
if isinstance(other, (int, float)):
|
|
82
|
+
raise TypeError(
|
|
83
|
+
"Cannot subtract a UnitExpr from a bare scalar: the physical meaning is ambiguous. "
|
|
84
|
+
"Wrap the scalar as an astropy Quantity (e.g. 5 * u.m) to make units explicit."
|
|
85
|
+
)
|
|
86
|
+
if not self.unit.is_equivalent(other_unit):
|
|
87
|
+
raise IncompatibleUnitsError(f"Incompatible units for subtraction: {other_unit!r} - {self.unit!r}.")
|
|
88
|
+
return UnitExpr(expr=other_expr - self.expr, unit=self.unit)
|
|
89
|
+
|
|
90
|
+
def __mul__(self, other: _Operand) -> UnitExpr:
|
|
91
|
+
other_expr, other_unit = self._decompose(other)
|
|
92
|
+
return UnitExpr(expr=self.expr * other_expr, unit=self.unit * other_unit)
|
|
93
|
+
|
|
94
|
+
def __rmul__(self, other: _Operand) -> UnitExpr:
|
|
95
|
+
return self.__mul__(other)
|
|
96
|
+
|
|
97
|
+
def __truediv__(self, other: _Operand) -> UnitExpr:
|
|
98
|
+
other_expr, other_unit = self._decompose(other)
|
|
99
|
+
return UnitExpr(expr=self.expr / other_expr, unit=self.unit / other_unit)
|
|
100
|
+
|
|
101
|
+
def __rtruediv__(self, other: _Operand) -> UnitExpr:
|
|
102
|
+
other_expr, other_unit = self._decompose(other)
|
|
103
|
+
return UnitExpr(expr=other_expr / self.expr, unit=other_unit / self.unit)
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str:
|
|
106
|
+
return f"<UnitExpr unit='{self.unit}'>"
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _decompose(operand: _Operand) -> tuple[polars.Expr, u.UnitBase]:
|
|
110
|
+
"""Decompose an arithmetic operand into its Polars expression and Astropy unit components.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
operand : UnitExpr | u.Quantity | int | float
|
|
115
|
+
The operand to evaluate and convert into valid expression components.
|
|
116
|
+
Supported combinations map as follows:
|
|
117
|
+
|
|
118
|
+
- `UnitExpr`: Extracts the underlying components directly.
|
|
119
|
+
- `u.Quantity`: Wraps the quantity's array or scalar value
|
|
120
|
+
as a `polars.lit` expression and returns its physical unit metadata.
|
|
121
|
+
- `int` or `float`: Handled as an unscaled, dimensionless literal
|
|
122
|
+
value (`u.dimensionless_unscaled`).
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
expr : polars.Expr
|
|
127
|
+
A Polars expression tree component corresponding to the operand value or column reference.
|
|
128
|
+
unit : u.UnitBase
|
|
129
|
+
The Astropy physical unit tracking data associated with the operand component.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
TypeError
|
|
134
|
+
If the `operand` is not one of the strictly supported arithmetic types
|
|
135
|
+
(e.g., passing raw string literals, dicts, or un-aliased standard dataframes).
|
|
136
|
+
"""
|
|
137
|
+
if isinstance(operand, UnitExpr):
|
|
138
|
+
return operand.expr, operand.unit
|
|
139
|
+
if isinstance(operand, u.Quantity):
|
|
140
|
+
return polars.lit(operand.value), operand.unit
|
|
141
|
+
if isinstance(operand, (int, float)):
|
|
142
|
+
return polars.lit(operand), u.dimensionless_unscaled
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"Unsupported operand type for UnitExpr arithmetic: {type(operand).__name__}. "
|
|
145
|
+
"Expected UnitExpr, astropy Quantity, int, or float."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["UnitExpr"]
|