Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ coverage:
build:
uv build --require-hashes --build-constraints=.build-constraints.txt

update-api-docs:
cd docs/impulse && uv run pydoc-markdown

lock-dependencies: UV_LOCKED := 0
lock-dependencies:
uv lock
Expand All @@ -56,4 +59,4 @@ fork-sync:
./.github/scripts/fork-sync-pr.sh $(PR)

.DEFAULT: all
.PHONY: all clean dev lint fmt test coverage build lock-dependencies fork-sync
.PHONY: all clean dev lint fmt test coverage build update-api-docs lock-dependencies fork-sync
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,31 @@ def dtype()

Returns the Spark data type for PointsInTimeSeries.

For numeric values the element is a homogeneous ``[tstart, value]`` double
pair (``ArrayType(ArrayType(DoubleType))``). String-valued series cannot use
that homogeneous nested array, so their element is a ``(tstart, value)``
struct with a double timestamp and a string value.

**Returns**:

`pyspark.sql.types.ArrayType`: Spark ArrayType for points in time series: [[tstart_1, value_1], ...].
`pyspark.sql.types.ArrayType`: Spark ArrayType matching ``get_data``'s shape for this series' value type.

#### get\_data

```python
def get_data() -> list
```

Returns the series as a list of [tstart, value] lists.
Returns the series as a list of ``[tstart, value]`` pairs.

For numeric values this is a list of two-element double lists. For string
values, ``column_stack`` would coerce the timestamps to strings, so the
pairs are built explicitly as ``[float(tstart), str(value)]`` — matching the
struct element type declared by :meth:`dtype`.

**Returns**:

`list`: List of [tstart, value] pairs.
`list`: List of ``[tstart, value]`` pairs.

#### \_\_len\_\_

Expand Down

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to change this file in this PR?

Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,16 @@ Only includes computation-affecting attributes:
- input_expressions
- statistics to be calculated
- event expression if there is any
- custom statistics (name, kind, declared input indices, and function
bytecode, so implementation or input-wiring changes invalidate cached
results; only appended when custom statistics are configured so
aggregators without them keep their previous hash)

Excludes: name, desc, signal_name, units, page_number, report_id, and the
cross-channel descriptors' channel_name (presentation metadata, like
channel_names).
- channel_names, and each cross-channel descriptor's channel_name. These
are the fact table's ``channel_name`` merge key, so a rename must force
a recompute (a changed definition recomputes and prunes all containers);
otherwise, in incremental mode, already-processed containers would keep
rows under the old name.
- custom statistics (labels, kind, declared input indices, params, and
function bytecode, so implementation or input-wiring changes invalidate
cached results; only appended when custom statistics are configured)

Excludes: name, desc, units, page_number, report_id.

**Returns**:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ Configuration for data sink location in Unity Catalog.
- `catalog` (`str`): Target catalog name for output tables.
- `schema` (`str`): Target schema name for output tables.
- `table_prefix` (`str`): Prefix to use for generated output table names.
- `cleanup_temp_tables` (`bool`): When ``True``, the intermediate ``__impulse_temp_*`` tables written to this
sink during batch solving are dropped after ``persist_results()`` completes
successfully. Defaults to ``False`` (temp tables are retained for inspection
and only cleared at the start of the next report run).

## Comparator

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ Get the list of calculated channels associated with the report.
#### persist\_results

```python
def persist_results()
def persist_results(cleanup_temp_tables: bool | None = None)
```

Persist report results using appropriate strategy based on definition changes.
Expand All @@ -308,6 +308,14 @@ Uses tracked state from determine_report() to decide persistence strategy:
- Changed definitions: replaceWhere (atomic delete + insert)
- Unchanged definitions: MERGE (upsert)

**Arguments**:

- `cleanup_temp_tables` (`bool`): Whether to drop the batch-solving ``__impulse_temp_*`` tables from the
sink schema after persistence completes successfully.
- True/False: use this value, overriding the config flag.
- None (default): fall back to ``config.unity_sink.cleanup_temp_tables``
(which itself defaults to False).

**Returns**:

`None`:
Expand Down
88 changes: 82 additions & 6 deletions src/impulse_query_engine/model/series/points_in_time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from __future__ import annotations

from collections.abc import Sized
import functools
from collections.abc import Callable, Sized

import numpy as np
import numpy.typing as npt
Expand All @@ -15,6 +16,34 @@
FloatOrNaN = float | np.float64


def _numeric_only(method: Callable) -> Callable:
"""Decorator that rejects the wrapped method on a string-valued series.

String-valued :class:`PointsInTimeSeries` support only sampling and equality
(``==`` / ``!=``); arithmetic, ordering and numeric reductions have no meaning
for them. Numpy would either raise (``-``, ``/``, ``mean``) or — worse —
silently succeed with a nonsensical result (``+`` concatenates, ``*`` repeats,
``sum`` concatenates), so guard those methods explicitly and fail loudly.

Applied to arithmetic, ordering-comparison and reduction methods.

Raises
------
TypeError
When the decorated method is called on a string-valued series.
"""

@functools.wraps(method)
def wrapper(self: PointsInTimeSeries, *args, **kwargs):
if self._is_string:
raise TypeError(
f"{method.__name__} is not supported for string-valued PointsInTimeSeries"
)
return method(self, *args, **kwargs)

return wrapper


class PointsInTimeSeries:
def __init__(self, tstarts: Sized, values: Sized):
"""
Expand All @@ -32,31 +61,62 @@ def __init__(self, tstarts: Sized, values: Sized):
Array-like of values, one per time point.
"""
assert len(tstarts) == len(values)
# Timestamps are always numeric. Values may be numeric or string:
# string-valued series support sampling (``synchronized`` / ``.where``)
# and equality comparisons (``==`` / ``!=``) only — arithmetic, ordering
# and numeric reductions are rejected (see the ``@_numeric_only`` methods).
# An empty series has no observed value type, so it defaults to numeric
# (the safe, backward-compatible case).
self.tstarts = np.array(tstarts, dtype=np.float64)
self.values = np.array(values, dtype=np.float64)
self._is_string = np.asarray(values).dtype.kind in ("U", "S", "O")
if self._is_string:
self.values = np.asarray(values, dtype=object)
else:
self.values = np.array(values, dtype=np.float64)

def dtype(self):
"""
Returns the Spark data type for PointsInTimeSeries.

For numeric values the element is a homogeneous ``[tstart, value]`` double
pair (``ArrayType(ArrayType(DoubleType))``). String-valued series cannot use
that homogeneous nested array, so their element is a ``(tstart, value)``
struct with a double timestamp and a string value.

Returns
-------
pyspark.sql.types.ArrayType
Spark ArrayType for points in time series: [[tstart_1, value_1], ...].
"""
Spark ArrayType matching ``get_data``'s shape for this series' value type.
"""
if self._is_string:
return T.ArrayType(
T.StructType(
[
T.StructField("tstart", T.DoubleType()),
T.StructField("value", T.StringType()),
]
)
)
return T.ArrayType(T.ArrayType(T.DoubleType()))

def get_data(self) -> list:
"""
Returns the series as a list of [tstart, value] lists.
Returns the series as a list of ``[tstart, value]`` pairs.

For numeric values this is a list of two-element double lists. For string
values, ``column_stack`` would coerce the timestamps to strings, so the
pairs are built explicitly as ``[float(tstart), str(value)]`` — matching the
struct element type declared by :meth:`dtype`.

Returns
-------
list
List of [tstart, value] pairs.
List of ``[tstart, value]`` pairs.
"""
if len(self) == 0:
return []
if self._is_string:
return [[float(t), str(v)] for t, v in zip(self.tstarts, self.values, strict=True)]
return np.column_stack([self.tstarts, self.values]).tolist()

def __len__(self) -> int:
Expand Down Expand Up @@ -354,34 +414,42 @@ def _apply_basic_rop(self, operation, other: float | SampleSeries | PointsInTime
return PointsInTimeSeries(s0.tstarts, operation(s1.values, s0.values))
return PointsInTimeSeries(self.tstarts, operation(other, self.values))

@_numeric_only

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is cool! 👍

def __add__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Add another series or scalar to this series."""
return self._apply_basic_op(np.add, other)

@_numeric_only
def __radd__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Add this series to another series or scalar (reversed operands)."""
return self._apply_basic_rop(np.add, other)

@_numeric_only
def __sub__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Subtract another series or scalar from this series."""
return self._apply_basic_op(np.subtract, other)

@_numeric_only
def __rsub__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Subtract this series from another series or scalar (reversed operands)."""
return self._apply_basic_rop(np.subtract, other)

@_numeric_only
def __mul__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Multiply this series by another series or scalar."""
return self._apply_basic_op(np.multiply, other)

@_numeric_only
def __rmul__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Multiply another series or scalar by this series (reversed operands)."""
return self._apply_basic_rop(np.multiply, other)

@_numeric_only
def __truediv__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Divide this series by another series or scalar."""
return self._apply_basic_op(np.true_divide, other)

@_numeric_only
def __rtruediv__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTimeSeries:
"""Divide another series or scalar by this series (reversed operands)."""
return self._apply_basic_rop(np.true_divide, other)
Expand Down Expand Up @@ -411,18 +479,22 @@ def __apply_op(
idx = operation(self.values, other)
return PointsInTime(self.tstarts[idx])

@_numeric_only
def __gt__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTime:
"""Return points where this series is greater than another."""
return self.__apply_op(np.greater, other)

@_numeric_only
def __ge__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTime:
"""Return points where this series is greater than or equal to another."""
return self.__apply_op(np.greater_equal, other)

@_numeric_only
def __lt__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTime:
"""Return points where this series is less than another."""
return self.__apply_op(np.less, other)

@_numeric_only
def __le__(self, other: float | SampleSeries | PointsInTimeSeries) -> PointsInTime:
"""Return points where this series is less than or equal to another."""
return self.__apply_op(np.less_equal, other)
Expand All @@ -448,6 +520,7 @@ def count(self) -> int:
"""
return len(self)

@_numeric_only
def sum(self) -> FloatOrNaN:
"""
Returns the sum of the values.
Expand All @@ -461,6 +534,7 @@ def sum(self) -> FloatOrNaN:
return np.nan
return np.sum(self.values)

@_numeric_only
def mean(self) -> FloatOrNaN:
"""
Returns the mean of the values.
Expand All @@ -474,6 +548,7 @@ def mean(self) -> FloatOrNaN:
return np.nan
return np.mean(self.values)

@_numeric_only
def min(self) -> FloatOrNaN:
"""
Returns the minimum value.
Expand All @@ -487,6 +562,7 @@ def min(self) -> FloatOrNaN:
return np.nan
return np.min(self.values)

@_numeric_only
def max(self) -> FloatOrNaN:
"""
Returns the maximum value.
Expand Down
Loading
Loading