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 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"]