Skip to content

Commit 9ae48d3

Browse files
committed
fix: filter invalid solar MVLR observations
1 parent 3312fb9 commit 9ae48d3

7 files changed

Lines changed: 276 additions & 5 deletions

File tree

openenergyid/mvlr/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66
IndependentVariableResult,
77
MultiVariableRegressionInput,
88
MultiVariableRegressionResult,
9+
OutlierFilteringDiagnostics,
910
ValidationParameters,
1011
)
12+
from .source_data_filtering import clean_regression_frame
1113

1214
__all__ = [
1315
"find_best_mvlr",
16+
"clean_regression_frame",
1417
"IndependentVariableInput",
1518
"MultiVariableRegressionInput",
1619
"MultiVariableRegressionResult",
20+
"OutlierFilteringDiagnostics",
1721
"ValidationParameters",
1822
"IndependentVariableResult",
1923
]

openenergyid/mvlr/main.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
from .helpers import resample_input_data
44
from .models import MultiVariableRegressionInput, MultiVariableRegressionResult
55
from .mvlr import MultiVariableLinearRegression
6+
from .source_data_filtering import clean_regression_frame
67

78

89
def find_best_mvlr(
910
data: MultiVariableRegressionInput,
1011
) -> MultiVariableRegressionResult:
1112
"""Cycle through multiple granularities and return the best model."""
1213
best_rsquared = 0
14+
best_filtering = None
1315
for granularity in data.granularities:
1416
frame = data.data_frame()
17+
frame, filtering = clean_regression_frame(frame, data.dependent_variable)
18+
best_filtering = filtering
1519
frame = resample_input_data(data=frame, granularity=granularity)
1620
mvlr = MultiVariableLinearRegression(
1721
data=frame,
@@ -27,8 +31,17 @@ def find_best_mvlr(
2731
max_f_pvalue=data.validation_parameters.f_pvalue,
2832
max_pvalues=data.validation_parameters.pvalues,
2933
):
30-
return MultiVariableRegressionResult.from_mvlr(mvlr)
34+
result = MultiVariableRegressionResult.from_mvlr(mvlr)
35+
result.outlier_filtering = filtering
36+
return result
3137
best_rsquared = max(best_rsquared, mvlr.fit.rsquared_adj)
32-
raise ValueError(
33-
f"No valid model found. Best R²: {best_rsquared:.3f} (need ≥{data.validation_parameters.rsquared})"
38+
detail = (
39+
f"No valid model found. Best R²: {best_rsquared:.3f} "
40+
f"(need ≥{data.validation_parameters.rsquared})"
3441
)
42+
if best_filtering and best_filtering.applied:
43+
detail += (
44+
f"; outlier filtering removed {best_filtering.removed_observation_count}/"
45+
f"{best_filtering.original_observation_count} observations"
46+
)
47+
raise ValueError(detail)

openenergyid/mvlr/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,23 @@ def from_fit(cls, fit: fm.ols, name: str) -> "IndependentVariableResult":
200200
)
201201

202202

203+
class OutlierFilteringDiagnostics(BaseModel):
204+
"""Diagnostics about source observations removed before regression fitting."""
205+
206+
enabled: bool = True
207+
original_observation_count: int = Field(alias="originalObservationCount")
208+
retained_observation_count: int = Field(alias="retainedObservationCount")
209+
removed_observation_count: int = Field(alias="removedObservationCount")
210+
removed_non_finite_count: int = Field(alias="removedNonFiniteCount", default=0)
211+
removed_negative_count: int = Field(alias="removedNegativeCount", default=0)
212+
removed_zero_with_solar_count: int = Field(alias="removedZeroWithSolarCount", default=0)
213+
removed_ratio_outlier_count: int = Field(alias="removedRatioOutlierCount", default=0)
214+
applied: bool
215+
reason: str | None = None
216+
217+
model_config = ConfigDict(populate_by_name=True)
218+
219+
203220
class MultiVariableRegressionResult(BaseModel):
204221
"""Result of a multivariable regression model."""
205222

@@ -212,6 +229,10 @@ class MultiVariableRegressionResult(BaseModel):
212229
intercept: IndependentVariableResult
213230
granularity: Granularity
214231
frame: TimeDataFrame
232+
outlier_filtering: OutlierFilteringDiagnostics | None = Field(
233+
default=None,
234+
alias="outlierFiltering",
235+
)
215236

216237
model_config = ConfigDict(populate_by_name=True)
217238

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Source-data filtering helpers for multi-variable regression."""
2+
3+
import numpy as np
4+
import pandas as pd
5+
6+
from .models import OutlierFilteringDiagnostics
7+
8+
MINIMUM_RETAINED_FRACTION = 0.50
9+
MINIMUM_RETAINED_ROWS = 30
10+
SOLAR_REFERENCE_NAMES = ("solarPowerGeneration", "solarRadiation")
11+
12+
13+
def _solar_reference_column(frame: pd.DataFrame) -> str | None:
14+
for name in SOLAR_REFERENCE_NAMES:
15+
if name in frame.columns:
16+
return name
17+
18+
for column in frame.columns:
19+
lower = column.lower()
20+
if "solar" in lower and ("generation" in lower or "radiation" in lower):
21+
return column
22+
23+
return None
24+
25+
26+
def _is_solar_production_model(dependent_variable: str, frame: pd.DataFrame) -> bool:
27+
dependent = dependent_variable.lower()
28+
if "solarphotovoltaic" in dependent:
29+
return True
30+
if "solar" in dependent and "production" in dependent:
31+
return True
32+
return "production" in dependent and _solar_reference_column(frame) is not None
33+
34+
35+
def _positive_reference_threshold(series: pd.Series) -> float:
36+
positive = series[series > 0]
37+
if positive.empty:
38+
return 0.0
39+
return max(float(positive.median()) * 0.10, 0.05)
40+
41+
42+
def _robust_ratio_outlier_mask(ratio: pd.Series) -> pd.Series:
43+
if len(ratio) < MINIMUM_RETAINED_ROWS:
44+
return pd.Series(False, index=ratio.index)
45+
46+
median = float(ratio.median())
47+
mad = float((ratio - median).abs().median())
48+
if not np.isfinite(mad) or mad <= 0:
49+
q1 = float(ratio.quantile(0.25))
50+
q3 = float(ratio.quantile(0.75))
51+
iqr = q3 - q1
52+
if not np.isfinite(iqr) or iqr <= 0:
53+
return pd.Series(False, index=ratio.index)
54+
return (ratio < q1 - 3.0 * iqr) | (ratio > q3 + 3.0 * iqr)
55+
56+
robust_z = 0.6745 * (ratio - median).abs() / mad
57+
return robust_z > 4.5
58+
59+
60+
def clean_regression_frame(
61+
frame: pd.DataFrame,
62+
dependent_variable: str,
63+
) -> tuple[pd.DataFrame, OutlierFilteringDiagnostics]:
64+
"""Remove obvious bad source observations before fitting a regression model."""
65+
66+
original_count = len(frame)
67+
diagnostics = OutlierFilteringDiagnostics(
68+
originalObservationCount=original_count,
69+
retainedObservationCount=original_count,
70+
removedObservationCount=0,
71+
applied=False,
72+
)
73+
74+
if original_count == 0 or dependent_variable not in frame.columns:
75+
diagnostics.reason = "empty frame or missing dependent variable"
76+
return frame, diagnostics
77+
78+
numeric_frame = frame.apply(pd.to_numeric, errors="coerce")
79+
keep = pd.Series(True, index=numeric_frame.index)
80+
81+
finite_mask = np.isfinite(numeric_frame).all(axis=1)
82+
diagnostics.removed_non_finite_count = int((keep & ~finite_mask).sum())
83+
keep &= finite_mask
84+
85+
if not _is_solar_production_model(dependent_variable, numeric_frame):
86+
cleaned = numeric_frame.loc[keep].copy()
87+
diagnostics.retained_observation_count = len(cleaned)
88+
diagnostics.removed_observation_count = original_count - len(cleaned)
89+
diagnostics.applied = diagnostics.removed_observation_count > 0
90+
diagnostics.reason = "generic non-finite filtering only"
91+
return cleaned, diagnostics
92+
93+
y = numeric_frame[dependent_variable]
94+
negative_mask = y < 0
95+
diagnostics.removed_negative_count = int((keep & negative_mask).sum())
96+
keep &= ~negative_mask
97+
98+
solar_column = _solar_reference_column(numeric_frame)
99+
if solar_column is not None:
100+
solar_reference = numeric_frame[solar_column]
101+
solar_threshold = _positive_reference_threshold(solar_reference[keep])
102+
103+
zero_with_solar_mask = (y <= 0) & (solar_reference > solar_threshold)
104+
diagnostics.removed_zero_with_solar_count = int((keep & zero_with_solar_mask).sum())
105+
keep &= ~zero_with_solar_mask
106+
107+
ratio_candidates = keep & (y > 0) & (solar_reference > solar_threshold)
108+
ratios = y[ratio_candidates] / solar_reference[ratio_candidates]
109+
ratio_outliers = _robust_ratio_outlier_mask(ratios)
110+
diagnostics.removed_ratio_outlier_count = int(ratio_outliers.sum())
111+
keep.loc[ratio_outliers[ratio_outliers].index] = False
112+
113+
cleaned = numeric_frame.loc[keep].copy()
114+
retained_count = len(cleaned)
115+
removed_count = original_count - retained_count
116+
117+
if retained_count < MINIMUM_RETAINED_ROWS:
118+
diagnostics.reason = "too few observations retained after filtering"
119+
return numeric_frame, diagnostics
120+
121+
if retained_count / original_count < MINIMUM_RETAINED_FRACTION:
122+
diagnostics.reason = "too much source data would be removed"
123+
return numeric_frame, diagnostics
124+
125+
diagnostics.retained_observation_count = retained_count
126+
diagnostics.removed_observation_count = removed_count
127+
diagnostics.applied = removed_count > 0
128+
diagnostics.reason = "solar production source-data filtering"
129+
return cleaned, diagnostics

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "openenergyid"
3-
version = "0.1.40"
3+
version = "0.1.41"
44
description = "Open Source Python library for energy analytics and simulations"
55
authors = [
66
{ name = "Jan Pecinovsky", email = "jan@energieid.be" },
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Tests for MVLR source-data filtering."""
2+
3+
import math
4+
5+
import pandas as pd
6+
7+
from openenergyid.models import TimeDataFrame
8+
from openenergyid.mvlr import (
9+
MultiVariableRegressionInput,
10+
clean_regression_frame,
11+
find_best_mvlr,
12+
)
13+
14+
DEPENDENT = "energyProduction/solarPhotovoltaic"
15+
SOLAR_REFERENCE = "solarPowerGeneration"
16+
17+
18+
def _solar_regression_input(
19+
*,
20+
zero_slice: slice = slice(20, 30),
21+
spikes: dict[int, float] | None = None,
22+
) -> MultiVariableRegressionInput:
23+
index = pd.date_range("2025-04-01", periods=90, freq="D", tz="Europe/Brussels")
24+
solar_reference = pd.Series(
25+
[1.0 + (i % 35) / 8.0 for i in range(len(index))],
26+
index=index,
27+
dtype=float,
28+
)
29+
production = 4.35 * solar_reference + 0.15
30+
31+
if zero_slice:
32+
production.iloc[zero_slice] = 0.0
33+
for idx, value in (spikes or {45: 70.0, 60: 75.0, 75: 80.0}).items():
34+
production.iloc[idx] = value
35+
36+
frame = pd.DataFrame(
37+
{
38+
DEPENDENT: production,
39+
SOLAR_REFERENCE: solar_reference,
40+
},
41+
index=index,
42+
)
43+
44+
return MultiVariableRegressionInput.model_validate(
45+
{
46+
"timeZone": "Europe/Brussels",
47+
"independentVariables": [
48+
{
49+
"name": SOLAR_REFERENCE,
50+
"allowNegativeCoefficient": False,
51+
},
52+
],
53+
"dependentVariable": DEPENDENT,
54+
"frame": TimeDataFrame.from_pandas(frame).model_dump(),
55+
"granularities": ["P1D"],
56+
"allowNegativePredictions": False,
57+
"validationParameters": {
58+
"rsquared": 0.95,
59+
"f_pvalue": 0.05,
60+
"pvalues": 0.05,
61+
},
62+
},
63+
)
64+
65+
66+
def test_clean_regression_frame_removes_solar_source_outliers() -> None:
67+
"""Solar production cleaning should drop zero-line and ratio outliers."""
68+
data = _solar_regression_input()
69+
frame = data.data_frame()
70+
71+
cleaned, diagnostics = clean_regression_frame(frame, DEPENDENT)
72+
73+
assert diagnostics.applied
74+
assert diagnostics.original_observation_count == 90
75+
assert diagnostics.removed_zero_with_solar_count == 10
76+
assert diagnostics.removed_ratio_outlier_count == 3
77+
assert diagnostics.removed_observation_count == 13
78+
assert len(cleaned) == 77
79+
assert (cleaned[DEPENDENT] > 0).all()
80+
81+
82+
def test_find_best_mvlr_returns_filtering_diagnostics() -> None:
83+
"""A model should fit after bad source observations are excluded."""
84+
data = _solar_regression_input()
85+
86+
result = find_best_mvlr(data)
87+
88+
assert result.r2 > 0.99
89+
assert result.outlier_filtering is not None
90+
assert result.outlier_filtering.applied
91+
assert result.outlier_filtering.removed_observation_count == 13
92+
assert math.isclose(result.independent_variables[0].coef, 4.35, rel_tol=0.02)
93+
94+
95+
def test_clean_regression_frame_keeps_original_data_when_filtering_too_much() -> None:
96+
"""Filtering should not apply when too little source data would remain."""
97+
data = _solar_regression_input(zero_slice=slice(0, 55), spikes={})
98+
frame = data.data_frame()
99+
100+
cleaned, diagnostics = clean_regression_frame(frame, DEPENDENT)
101+
102+
assert not diagnostics.applied
103+
assert diagnostics.reason == "too much source data would be removed"
104+
assert len(cleaned) == 90

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)