"""
Time Series Infill Module
This module provides a flexible framework for filling missing values (infilling) in time series data using
Polars and SciPy. Infill methods are implemented as subclasses of ``InfillMethod`` and can be registered
and instantiated by name, class, or instance.
The infill pipeline handles:
- Padding the time series to ensure consistent timestamps
- Identifying gaps and their sizes
- Applying constraints such as maximum gap size and observation intervals
- Delegating to a specific infill method to fill missing values
"""
import logging
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Literal
import numpy as np
import polars as pl
from scipy.interpolate import Akima1DInterpolator, PchipInterpolator, make_interp_spline
from time_stream import Period
from time_stream.exceptions import InfillError, InfillInsufficientValuesError
from time_stream.operation import Operation
from time_stream.utils import check_columns_in_dataframe, gap_size_count, get_date_filter, pad_time
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class InfillCtx:
"""Immutable context passed to infill methods."""
df: pl.DataFrame
time_name: str
periodicity: Period
class InfillMethod(Operation, ABC):
"""Base class for infill methods."""
def _infilled_column_name(self, infill_column: str) -> str:
"""Return the name of the infilled column."""
return f"{infill_column}_{self.name}"
@abstractmethod
def _fill(self, df: pl.DataFrame, infill_column: str, ctx: InfillCtx) -> pl.DataFrame:
"""Return the Polars dataframe containing infilled data.
Args:
df: The DataFrame to infill.
infill_column: The column to infill.
ctx: The infill context.
Returns:
pl.DataFrame with infilled values
"""
pass
def apply(
self,
df: pl.DataFrame,
time_name: str,
periodicity: Period,
infill_column: str,
observation_interval: datetime | tuple[datetime, datetime | None] | None = None,
max_gap_size: int | None = None,
) -> pl.DataFrame:
"""Apply the infill method to the time series data.
Args:
df: The Polars DataFrame containing the time series data to infill
time_name: Name of the time column in the dataframe
infill_column: The column to infill data within.
periodicity: Periodicity of the time series
observation_interval: Optional time interval to limit the infilling to.
max_gap_size: The maximum size of consecutive null gaps that should be filled. Any gap larger than this
will not be infilled and will remain as null.
Returns:
The infilled time series
"""
ctx = InfillCtx(df, time_name, periodicity)
pipeline = InfillMethodPipeline(self, ctx, infill_column, observation_interval, max_gap_size)
return pipeline.execute()
[docs]
class InfillMethodPipeline:
"""Encapsulates the logic for the infill pipeline steps."""
[docs]
def __init__(
self,
infill_method: InfillMethod,
ctx: InfillCtx,
column: str,
observation_interval: datetime | tuple[datetime, datetime | None] | None = None,
max_gap_size: int | None = None,
):
self.infill_method = infill_method
self.ctx = ctx
self.column = column
self.observation_interval = observation_interval
self.max_gap_size = max_gap_size
def execute(self) -> pl.DataFrame:
"""Execute the infill pipeline"""
self._validate()
# We need to make sure the data is padded so that missing time steps are filled with nulls
df = pad_time(self.ctx.df, self.ctx.time_name, self.ctx.periodicity)
# Calculate sizes of each gap in the time series
df = gap_size_count(df, self.column)
# Create a mask determining which values get infilled
infill_mask = self._infill_mask()
# Check if there is actually anything to infill
if df.filter(infill_mask).is_empty():
# If not, return the original data
return self.ctx.df
# Apply the specific infill logic from the child class
df_infilled = self.infill_method._fill(df, self.column, self.ctx)
infilled_column = self.infill_method._infilled_column_name(self.column)
# Limit the infilled data to where the infill mask is True
df_infilled = df_infilled.with_columns(
pl.when(infill_mask).then(pl.col(infilled_column)).otherwise(pl.col(self.column)).alias(infilled_column)
)
# Do some tidying up of columns, leaving only the original column names
df_infilled = df_infilled.with_columns(
pl.col(infilled_column).alias(self.column) # Rename the infilled column back to the original name
).drop([infilled_column, "gap_size"], strict=False) # Drop the temporary processing columns
return df_infilled
def _validate(self) -> None:
"""Carry out validation that the infill method can actually be carried out."""
if self.ctx.df.is_empty():
raise InfillError("Cannot perform infilling on an empty DataFrame.")
check_columns_in_dataframe(self.ctx.df, [self.column, self.ctx.time_name])
def _infill_mask(self) -> pl.Expr:
"""Create a mask for determining which values in a time series to infill.
Take into account:
- Observation interval - constraining the time series to a specific datetime range
- Maximum gap size - constraining the infilling to gaps of a maximum size
- Start and end gaps - constraining so nulls at the beginning and end of the series remain null.
Returns:
Polars expression that can be used to determine which values to infill (True) or not (False)
"""
# Base assumption is that any gap can be infilled
filter_expr = pl.col("gap_size") > 0
# Check for any gaps
if self.max_gap_size:
# If constrained, change the filter to check if there is any missing data with: 0 < gap <= max_gap_size
filter_expr = pl.col("gap_size").is_between(0, self.max_gap_size, closed="right")
# Apply observation interval constraint
if self.observation_interval:
# Check if these gaps are within the specified observation interval
filter_expr = filter_expr & get_date_filter(self.ctx.time_name, self.observation_interval)
# Make a mask to ensure that Nulls at the beginning and end of the series remain null.
not_null_mask = pl.col(self.column).is_not_null()
row_idx = pl.arange(0, pl.len())
filter_expr = filter_expr & row_idx.is_between(
(row_idx.filter(not_null_mask).min()), # first True
(row_idx.filter(not_null_mask).max()), # last True
)
return filter_expr
class ScipyInterpolation(InfillMethod, ABC):
"""Base class for scipy-based interpolation methods."""
def __init__(self, **kwargs):
"""Initialize a scipy interpolation method.
Args:
**kwargs: Additional parameters passed to scipy interpolator method.
"""
self.scipy_kwargs = kwargs
@abstractmethod
def _create_interpolator(self, x_valid: np.ndarray, y_valid: np.ndarray) -> Any:
"""Create the scipy interpolator object.
Args:
x_valid: Array of row indices (0, 1, 2, ...) corresponding to non-null data points.
For example, if rows 0, 2, 5 have valid data, x_valid = [0, 2, 5].
y_valid: Array of actual data values at those row indices.
Returns:
Scipy interpolator object.
Raises:
ValueError: If insufficient data for this interpolation method.
Example:
If original data is [10.5, NaN, 12.3, NaN, NaN, 9.8]:
- x_valid = [0, 2, 5] (row indices of non-null values)
- y_valid = [10.5, 12.3, 9.8] (the actual non-null values)
- The interpolator will estimate values for indices 1, 3, 4
"""
pass
@property
@abstractmethod
def min_points_required(self) -> int:
"""Minimum number of data points required for this interpolation method."""
pass
def _fill(self, df: pl.DataFrame, infill_column: str, ctx: InfillCtx) -> pl.DataFrame:
"""Apply scipy interpolation to fill missing values in the specified column.
This method handles the common scipy interpolation workflow:
1. Converts data to numpy arrays for scipy compatibility
2. Identifies valid (non-null) data points for interpolation
3. Validates that sufficient data points exist for interpolation method
4. Creates and applies the specific scipy interpolator
5. Handles edge cases like infinite values in the interpolated result
6. Returns the DataFrame with a new column containing interpolated values
Args:
df: The DataFrame to infill.
infill_column: The column to infill.
ctx: The infill context.
Returns:
pl.DataFrame with infilled values
"""
# Convert to numpy
values = df[infill_column].to_numpy()
x = np.arange(len(values))
# Find non-null points
mask = ~np.isnan(values)
n_valid = np.sum(mask)
# Check if we have enough points
if n_valid < self.min_points_required:
raise InfillInsufficientValuesError(
f"Infill method '{self.name}' requires at least {self.min_points_required} data points, "
f"but only {n_valid} valid points found."
)
x_valid = x[mask]
y_valid = values[mask]
# Create the specific interpolator
interpolator = self._create_interpolator(x_valid, y_valid)
# Apply interpolation
interpolated = interpolator(x)
# Handle any remaining NaNs or infinities
interpolated = np.where(np.isfinite(interpolated), interpolated, np.nan)
return df.with_columns(pl.Series(self._infilled_column_name(infill_column), interpolated))
[docs]
@InfillMethod.register
class BSplineInterpolation(ScipyInterpolation):
"""B-spline interpolation using scipy make_interp_spline with configurable order.
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.make_interp_spline.html
"""
name = "bspline"
[docs]
def __init__(self, order: int, **kwargs):
"""Initialize B-spline interpolation.
Args:
order: Order of the B-spline (1-5, where 3=cubic, 2=quadratic, 1=linear).
**kwargs: Additional scipy parameters for the `make_interp_spline` method.
"""
super().__init__(**kwargs)
self.order = order
@property
def min_points_required(self) -> int:
"""B-spline needs at least order+1 points."""
return self.order + 1
def _create_interpolator(self, x_valid: np.ndarray, y_valid: np.ndarray) -> Any:
"""Create scipy B-spline interpolator."""
return make_interp_spline(x_valid, y_valid, k=self.order, **self.scipy_kwargs)
[docs]
@InfillMethod.register
class LinearInterpolation(BSplineInterpolation):
"""Linear spline interpolation (Convenience wrapper around B-spline with order=1).
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.make_interp_spline.html
"""
name = "linear"
[docs]
def __init__(self, **kwargs):
"""Initialize linear interpolation."""
super().__init__(order=1, **kwargs)
[docs]
@InfillMethod.register
class QuadraticInterpolation(BSplineInterpolation):
"""Quadratic spline interpolation (Convenience wrapper around B-spline with order=2).
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.make_interp_spline.html
"""
name = "quadratic"
[docs]
def __init__(self, **kwargs):
"""Initialize quadratic interpolation."""
super().__init__(order=2, **kwargs)
[docs]
@InfillMethod.register
class CubicInterpolation(BSplineInterpolation):
"""Cubic spline interpolation (Convenience wrapper around B-spline with order=3).
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.make_interp_spline.html
"""
name = "cubic"
[docs]
def __init__(self, **kwargs):
"""Initialize cubic interpolation."""
super().__init__(order=3, **kwargs)
[docs]
@InfillMethod.register
class AkimaInterpolation(ScipyInterpolation):
"""Akima interpolation using scipy (good for avoiding oscillations).
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.Akima1DInterpolator.html
"""
name = "akima"
min_points_required = 5 # type: ignore[override]
def _create_interpolator(self, x_valid: np.ndarray, y_valid: np.ndarray) -> Any:
"""Create scipy Akima interpolator."""
return Akima1DInterpolator(x_valid, y_valid, **self.scipy_kwargs)
[docs]
@InfillMethod.register
class PchipInterpolation(ScipyInterpolation):
"""PCHIP interpolation using scipy (preserves monotonicity).
https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.PchipInterpolator.html
"""
name = "pchip"
min_points_required = 2 # type: ignore[override]
def _create_interpolator(self, x_valid: np.ndarray, y_valid: np.ndarray) -> Any:
"""Create scipy PCHIP interpolator."""
return PchipInterpolator(x_valid, y_valid, **self.scipy_kwargs)
[docs]
@InfillMethod.register
class AltData(InfillMethod):
"""
Infills missing values using an alternative data source and optional correction factor.
The alternative data corresponding to the missing interval is scaled by the correction
factor to produce the infilled values.
"""
name = "alt_data"
[docs]
def __init__(self, alt_data_column: str, correction_factor: float = 1.0, alt_df: pl.DataFrame | None = None):
"""Initialize the alternative data infill method.
Args:
alt_data_column: The name of the column providing the alternative data.
correction_factor: An optional correction factor to apply to the alternative data.
alt_df: The DataFrame containing the alternative data.
"""
self.alt_data_column = alt_data_column
self.correction_factor = correction_factor
self.alt_df = alt_df
def _fill(self, df: pl.DataFrame, infill_column: str, ctx: InfillCtx) -> pl.DataFrame:
"""Fill missing values using data from the alternative column.
Args:
df: The DataFrame to infill.
infill_column: The column to infill.
ctx: The infill context.
Returns:
pl.DataFrame with infilled values.
"""
if self.alt_df is None:
check_columns_in_dataframe(df, [self.alt_data_column])
alt_data_column_name = self.alt_data_column
else:
time_column_name = ctx.time_name
check_columns_in_dataframe(self.alt_df, [time_column_name, self.alt_data_column])
alt_data_column_name = f"__ALT_DATA__{self.alt_data_column}"
alt_df = self.alt_df.select([time_column_name, self.alt_data_column]).rename(
{self.alt_data_column: alt_data_column_name}
)
df = df.join(
alt_df,
on=time_column_name,
how="left",
suffix="_alt",
)
infilled = df.with_columns(
pl.when(pl.col(infill_column).is_null())
.then(pl.col(alt_data_column_name) * self.correction_factor)
.otherwise(pl.col(infill_column))
.alias(self._infilled_column_name(infill_column))
)
if self.alt_df is not None:
infilled = infilled.drop(alt_data_column_name)
return infilled
[docs]
@InfillMethod.register
class AltDataDynamic(InfillMethod):
"""
Infills missing values using an alternative data source and a dynamic
correction factor derived from surrounding data.
For each contiguous gap in the original dataset, a time window is defined
around the gap. A correction factor is computed as the ratio of the sum of
the original data to the sum of the alternative data within this window.
The alternative data corresponding to the missing interval is scaled by the
correction factor to produce the infilled values.
The method defaults to using data on both sides of the gap.
If window_side is specified as "left" or "right", then only data left or right of the gap will be used.
"""
name = "alt_data_dynamic"
[docs]
def __init__(
self,
alt_data_column: str,
window_size: str | Period | timedelta,
alt_df: pl.DataFrame | None = None,
min_threshold: int = 0,
max_threshold: int | None = None,
window_side: Literal["left", "right", "both"] = "both",
):
"""Initialize the alternative data infill method.
Args:
alt_data_column: Name of the column providing the alternative data.
window_size: Time window around each gap used to calculate the correction factor.
Accepts an ISO duration string, Period, or timedelta.
alt_df: Optional separate DataFrame containing the alternative data. If None,
alt_data_column must exist in the DataFrame passed to the infill method.
min_threshold: Minimum number of data points required in the window to calculate
a correction factor. Gaps with windows that have fewer points than the min_threshold are not infilled.
max_threshold: Maximum number of data points to use per window. Points closest
to the gap are used first.
window_side: Which side of each gap to use for the window. Defaults to "both".
"left" uses only data before the gap; "right" uses only data after.
"""
if max_threshold is not None:
if min_threshold > max_threshold:
raise ValueError(f"max_threshold must be greater than min_threshold ({min_threshold}).")
if max_threshold == 0:
raise ValueError("max_threshold must be greater than zero.")
self.alt_data_column = alt_data_column
self.alt_df = alt_df
self.window_size = window_size
self.min_threshold = min_threshold
self.max_threshold = max_threshold
self.window_side = window_side
def _fill(
self,
df: pl.DataFrame,
infill_column: str,
ctx: InfillCtx,
) -> pl.DataFrame:
"""Fill missing values using data from the alternative column.
Args:
df: The DataFrame to infill.
infill_column: Name of the column to infill.
ctx: The infill context.
Returns:
DataFrame with an additional infilled column containing the corrected values.
"""
time_column_name = ctx.time_name
window_duration = self._window_duration(ctx)
# Join original and alternative dataframes if the latter exists
if self.alt_df is None:
check_columns_in_dataframe(df, [self.alt_data_column])
alt_data_column_name = self.alt_data_column
else:
check_columns_in_dataframe(self.alt_df, [time_column_name, self.alt_data_column])
alt_data_column_name = f"__ALT_DATA__{self.alt_data_column}"
alt_df = self.alt_df.select([time_column_name, self.alt_data_column]).rename(
{self.alt_data_column: alt_data_column_name}
)
df = df.join(
alt_df,
on=time_column_name,
how="left",
suffix="_alt",
)
# Identify gaps in original dataset
null_mask = pl.col(infill_column).is_null()
gap_id = (null_mask != null_mask.shift(1, fill_value=False)).cum_sum()
gap_id_column_name = f"__GAP_ID__{infill_column}"
df = df.with_columns(gap_id.alias(gap_id_column_name))
# Find start and end times of gaps
gap_bounds = (
df.filter(null_mask)
.group_by(gap_id_column_name)
.agg(
pl.min(time_column_name).alias("__GAP_START__"),
pl.max(time_column_name).alias("__GAP_END__"),
)
)
# Filter out all null values from both the original and alternative dataset.
filtered_df = df.filter(pl.col(infill_column).is_not_null() & pl.col(alt_data_column_name).is_not_null())
# Build windowed source data - must not overwrite df
filtered_df = filtered_df.drop(gap_id_column_name).join_where(
gap_bounds,
pl.col(time_column_name) >= pl.col("__GAP_START__") - window_duration,
pl.col(time_column_name) <= pl.col("__GAP_END__") + window_duration,
)
# Define windows either side of each gap
windowed_df = self._build_windowed_df(
filtered_df,
time_column_name,
gap_id_column_name,
)
# Attach correction factors
cf_column_name = f"__CF__{infill_column}"
cf_df = self._build_correction_factors(
windowed_df,
gap_id_column_name,
infill_column,
alt_data_column_name,
cf_column_name,
)
if cf_df is not None:
df = df.join(cf_df, on=gap_id_column_name, how="left")
else:
df = df.with_columns(pl.lit(None).alias(cf_column_name))
# Fill gaps
infilled = df.with_columns(
pl.when(pl.col(infill_column).is_null() & pl.col(cf_column_name).is_not_null())
.then(pl.col(alt_data_column_name) * pl.col(cf_column_name)) # null if alt_data is null
.otherwise(pl.col(infill_column))
.alias(self._infilled_column_name(infill_column))
)
# Cleanup
if self.alt_df is not None:
infilled = infilled.drop(alt_data_column_name)
infilled = infilled.drop([gap_id_column_name, cf_column_name])
return infilled
def _window_duration(self, ctx: InfillCtx) -> timedelta:
"""Resolve self.window_size to a timedelta and validate it against the data periodicity.
Args:
ctx: The infill context, used to obtain the data periodicity.
Returns:
The window duration as a timedelta.
Raises:
ValueError: If window_size cannot be resolved to a timedelta (e.g. a month or year
Period), if the window is smaller than the data periodicity, or if the window
is too small to satisfy min_threshold.
"""
window_size = self.window_size
# If window_size is a string, convert to Period type
if isinstance(window_size, str):
window_size = Period.of_iso_duration(window_size)
# Convert window_size to timedelta if not already
window_duration = window_size.timedelta if isinstance(window_size, Period) else window_size
# Check window_size gives a valid timedelta
if window_duration is None:
raise ValueError(
"Window size must be given in days, hours or seconds. Cannot resolve month or year to timedelta."
)
periodicity = ctx.periodicity
if periodicity.timedelta is None:
return window_duration
# window_duration must be greater than or equal to the periodicity of the data.
if window_duration < periodicity.timedelta:
raise ValueError("Window size must be greater than periodicity")
# Check the window duration contains min_threshold number of datapoints
factor = 1 if self.window_side in ["left", "right"] else 2
if window_duration * factor < periodicity.timedelta * self.min_threshold:
raise ValueError(
f"Windows must contain at least min_threshold {self.min_threshold} of data points. "
"Reduce the min_threshold or increase the window size."
)
return window_duration
def _build_windowed_df(
self,
df: pl.DataFrame,
time_column_name: str,
gap_id_column_name: str,
) -> pl.DataFrame | None:
"""Builds a filtered DataFrame containing only the window data around each gap.
Optionally restricts to one side of each gap, and applies
max and min threshold filtering.
Args:
df: DataFrame with gap IDs and gap bounds already joined.
time_column_name: Name of the time column.
gap_id_column_name: Name of the gap ID column.
Returns:
Filtered DataFrame with window data for each gap, or None if no data remains
after filtering.
"""
# Use left or right window only, if window_side is specified.
windowed_df = self._filter_side(df, time_column_name)
# Trim data if a max_threshold is specified.
if self.max_threshold is not None:
windowed_df = self._apply_max_threshold(
windowed_df,
gap_id_column_name,
time_column_name,
)
# Check there is enough data to meet the min_threshold if specified.
if self.min_threshold > 0:
windowed_df = self._apply_min_threshold(windowed_df, gap_id_column_name)
# If no data is left after filtering, no windows can be defined.
if windowed_df.is_empty():
logger.warning("Windows around each gap are empty. No gaps will be infilled.")
return None
else:
return windowed_df
def _filter_side(
self,
windowed_df: pl.DataFrame,
time_column_name: str,
) -> pl.DataFrame:
"""Restrict the window to one side of each gap based on self.window_side.
Args:
windowed_df: Window DataFrame.
time_column_name: Name of the time column.
Returns:
DataFrame with rows from the excluded side of each gap removed.
Unchanged if self.window_side is "both".
"""
if self.window_side == "left":
windowed_df = windowed_df.filter(pl.col(time_column_name) < pl.col("__GAP_START__"))
elif self.window_side == "right":
windowed_df = windowed_df.filter(pl.col(time_column_name) > pl.col("__GAP_END__"))
return windowed_df
def _apply_max_threshold(
self,
windowed_df: pl.DataFrame,
gap_id_column_name: str,
time_column_name: str,
) -> pl.DataFrame:
"""Trim each gap's window to at most max_threshold rows, keeping the closest rows to the gap.
When both sides of a gap have sufficient data, trims symmetrically so each side
contributes at most floor(max_threshold / 2) rows. When one side is smaller,
keeps all rows from that side and fills the remainder from the other side.
Args:
windowed_df: Window DataFrame with gap ID and time columns.
gap_id_column_name: Name of the gap ID column.
time_column_name: Name of the time column.
Returns:
DataFrame with each gap's window trimmed to at most max_threshold rows.
"""
# Return early if max_threshold is not specified
if self.max_threshold is None:
return windowed_df
# Label which rows occur before each gap
windowed_df = windowed_df.with_columns(
(pl.col(time_column_name) < pl.col("__GAP_START__")).alias("__IS_BEFORE__")
)
# Count rows in window on each side of each gap
windowed_df = windowed_df.with_columns(
pl.len().over([gap_id_column_name, "__IS_BEFORE__"]).alias("__SIDE_COUNT__")
)
# Count total rows before, after, and total in window around each gap
windowed_df = windowed_df.with_columns(
[
pl.when(pl.col("__IS_BEFORE__"))
.then(pl.col("__SIDE_COUNT__"))
.otherwise(0)
.max()
.over(gap_id_column_name)
.alias("__BEFORE_COUNT__"),
pl.when(~pl.col("__IS_BEFORE__"))
.then(pl.col("__SIDE_COUNT__"))
.otherwise(0)
.max()
.over(gap_id_column_name)
.alias("__AFTER_COUNT__"),
pl.len().over(gap_id_column_name).alias("__TOTAL_COUNT__"),
]
)
# Rank closest
windowed_df = windowed_df.with_columns(
pl.when(pl.col("__IS_BEFORE__"))
.then(pl.col(time_column_name).rank("ordinal", descending=True).over([gap_id_column_name, "__IS_BEFORE__"]))
.otherwise(
pl.col(time_column_name).rank("ordinal", descending=False).over([gap_id_column_name, "__IS_BEFORE__"])
)
.alias("__RANK__")
)
# Track which windows around each gap are symmetric/asymmetric
windowed_df = windowed_df.with_columns(
[
# Track which side is largest/smallest
# In a tie, before count wins
(pl.col("__BEFORE_COUNT__") > pl.col("__AFTER_COUNT__")).alias("__AFTER_IS_SMALLER__"),
# If both sides have at least half the min threshold,
# then use same number of datapoints on either side of gap
(
(pl.col("__BEFORE_COUNT__") >= math.ceil(self.min_threshold / 2))
& (pl.col("__AFTER_COUNT__") >= math.ceil(self.min_threshold / 2))
& (self.max_threshold >= 2)
).alias("__SYMMETRIC__"),
]
)
# Trim rows such that only the closest datapoints to each gap,
# up to the max_threshold number of datapoints in a window around each gap are used.
windowed_df = windowed_df.with_columns(
# No trimming needed
pl.when(pl.col("__TOTAL_COUNT__") <= self.max_threshold)
.then(pl.col("__SIDE_COUNT__"))
# Symmetric: only use up to half of max_threshold number of datapoints on each side of gap
.when(pl.col("__SYMMETRIC__"))
.then(pl.lit(math.floor(self.max_threshold / 2))) # Never zero, symmetric filter ensures max_threshold >=2
# If not enough data on each side of gap for windows to be same size,
# keep all data in smaller window and trim larger window such that
# the total number of datapoints across windows is up to the max_threshold.
.when((pl.col("__IS_BEFORE__") != pl.col("__AFTER_IS_SMALLER__")))
.then(pl.min_horizontal(pl.col("__SIDE_COUNT__"), self.max_threshold))
.otherwise(
pl.lit(self.max_threshold)
- pl.min_horizontal("__BEFORE_COUNT__", "__AFTER_COUNT__", self.max_threshold)
)
.alias("__FINAL_COUNT__")
)
windowed_df = windowed_df.filter(pl.col("__RANK__") <= pl.col("__FINAL_COUNT__"))
return windowed_df.drop(
[
"__IS_BEFORE__",
"__SIDE_COUNT__",
"__BEFORE_COUNT__",
"__AFTER_COUNT__",
"__TOTAL_COUNT__",
"__SYMMETRIC__",
"__RANK__",
"__AFTER_IS_SMALLER__",
"__FINAL_COUNT__",
]
)
def _apply_min_threshold(
self,
windowed_df: pl.DataFrame,
gap_id_column_name: str,
) -> pl.DataFrame:
"""Remove gaps whose window contains fewer than self.min_threshold rows.
Logs a warning listing the gap IDs that are dropped.
Args:
windowed_df: Window DataFrame with gap ID column.
gap_id_column_name: Name of the gap ID column.
Returns:
DataFrame with gaps removed whose windows contain below the minimum threshold datapoints.
"""
window_sizes = windowed_df.group_by(gap_id_column_name).agg(pl.len().alias("__COUNT__"))
gaps_with_window_below_threshold = window_sizes.filter(pl.col("__COUNT__") < self.min_threshold)[
gap_id_column_name
].to_list()
if len(gaps_with_window_below_threshold) > 0:
logger.warning(
f"gap(s): {gaps_with_window_below_threshold} cannot be filled, "
f"window size is below min threshold ({self.min_threshold}).",
)
valid_ids = window_sizes.filter(pl.col("__COUNT__") >= self.min_threshold)[gap_id_column_name]
return windowed_df.filter(pl.col(gap_id_column_name).is_in(valid_ids.implode()))
def _build_correction_factors(
self,
windowed_df: pl.DataFrame | None,
gap_id_column_name: str,
infill_column: str,
alt_data_column_name: str,
cf_column_name: str,
) -> pl.DataFrame | None:
"""Compute a correction factor per gap as sum(infill) / sum(alt_data) over the window.
Logs a warning for any gap where the alternative data sums to zero, as no
correction factor can be computed for those gaps.
Args:
windowed_df: Window DataFrame per gap, or None if no window data is available.
gap_id_column_name: Name of the gap ID column.
infill_column: Name of the infill column.
alt_data_column_name: Name of the alternative data column.
cf_column_name: Name to give the correction factor column in the output.
Returns:
DataFrame with one row per gap containing the correction factor, or None if
windowed_df is None or no correction factors could be computed.
"""
if windowed_df is None:
return None
infill_sum_column_name = f"__SUM__{infill_column}"
alt_sum_column_name = f"__ALT_SUM__{alt_data_column_name}"
cf_df = windowed_df.group_by(gap_id_column_name).agg(
pl.col(infill_column).sum().alias(infill_sum_column_name),
pl.col(alt_data_column_name).sum().alias(alt_sum_column_name),
)
# list gaps where the alt_data sum is zero.
zero_alt_sum_gaps = cf_df.filter(pl.col(alt_sum_column_name) == 0)[gap_id_column_name].to_list()
if len(zero_alt_sum_gaps) > 0:
logger.warning("alt_sum is zero for gap(s) %s and will not be infilled.", zero_alt_sum_gaps)
cf_df = cf_df.with_columns(
pl.when(pl.col(alt_sum_column_name) != 0)
.then(pl.col(infill_sum_column_name) / pl.col(alt_sum_column_name))
.otherwise(None)
.alias(cf_column_name)
).drop([infill_sum_column_name, alt_sum_column_name])
return cf_df if not cf_df.is_empty() else None