Skip to content

Commit 27ea174

Browse files
committed
refactor: decouple solar source filtering from MVLR
Move source-data cleaning into a standalone openenergyid.cleaning module and return MVLR to pure regression fitting. - clean_solar_production_frame takes explicit production/reference columns instead of sniffing the dependent-variable name, and never mutates values: guardrail rejection returns the original frame. - FilteringDiagnostics reports a three-state status (applied, rejected-guardrail, nothing-to-remove); identified counts survive guardrail rejection while removed/retained describe the actual result. - find_best_mvlr no longer filters; sourceDataFiltering is removed from the MVLR input and outlierFiltering from the result. Callers run cleaning as an explicit pre-step and carry the diagnostics.
1 parent a19a910 commit 27ea174

10 files changed

Lines changed: 497 additions & 536 deletions

File tree

openenergyid/cleaning/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Source-data cleaning module.
2+
3+
Standalone data-quality filters that run as an explicit pre-step before
4+
analysis modules such as MVLR. Analysis modules stay pure; callers decide
5+
when to clean and carry the diagnostics.
6+
"""
7+
8+
from .models import (
9+
FilteringDiagnostics,
10+
FilteringStatus,
11+
SolarSourceFilteringParameters,
12+
)
13+
from .solar import clean_solar_production_frame
14+
15+
__all__ = [
16+
"FilteringDiagnostics",
17+
"FilteringStatus",
18+
"SolarSourceFilteringParameters",
19+
"clean_solar_production_frame",
20+
]

openenergyid/cleaning/models.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Models for source-data cleaning."""
2+
3+
from typing import Literal
4+
5+
from pydantic import BaseModel, ConfigDict, Field
6+
7+
FilteringStatus = Literal["applied", "rejected-guardrail", "nothing-to-remove"]
8+
9+
10+
class SolarSourceFilteringParameters(BaseModel):
11+
"""Parameters for solar production source-data filtering."""
12+
13+
minimum_retained_fraction: float = Field(
14+
0.50,
15+
ge=0,
16+
le=1,
17+
alias="minimumRetainedFraction",
18+
description="Minimum fraction of original observations that must remain after filtering.",
19+
)
20+
minimum_retained_rows: int = Field(
21+
30,
22+
ge=0,
23+
alias="minimumRetainedRows",
24+
description="Minimum number of observations that must remain after filtering.",
25+
)
26+
solar_reference_names: tuple[str, ...] = Field(
27+
("solarPowerGeneration", "solarRadiation"),
28+
alias="solarReferenceNames",
29+
description="Preferred source column names used as solar production/radiation references.",
30+
)
31+
positive_reference_median_fraction: float = Field(
32+
0.10,
33+
ge=0,
34+
alias="positiveReferenceMedianFraction",
35+
description="Fraction of the positive solar-reference median used as meaningful-solar threshold.",
36+
)
37+
minimum_positive_reference: float = Field(
38+
0.05,
39+
ge=0,
40+
alias="minimumPositiveReference",
41+
description="Minimum meaningful-solar threshold for the solar reference column.",
42+
)
43+
ratio_iqr_multiplier: float = Field(
44+
3.0,
45+
gt=0,
46+
alias="ratioIqrMultiplier",
47+
description="IQR multiplier used when MAD-based ratio filtering is unavailable.",
48+
)
49+
ratio_robust_z_threshold: float = Field(
50+
4.5,
51+
gt=0,
52+
alias="ratioRobustZThreshold",
53+
description="Robust z-score threshold for production-to-reference ratio outliers.",
54+
)
55+
56+
model_config = ConfigDict(populate_by_name=True)
57+
58+
59+
class FilteringDiagnostics(BaseModel):
60+
"""Diagnostics for a source-data filtering run.
61+
62+
The `identified*` counts always describe what the filter identified as
63+
invalid, even when guardrails rejected the filtering. The observation
64+
counts describe what actually happened to the returned frame.
65+
"""
66+
67+
status: FilteringStatus
68+
original_observation_count: int = Field(alias="originalObservationCount")
69+
retained_observation_count: int = Field(alias="retainedObservationCount")
70+
removed_observation_count: int = Field(alias="removedObservationCount")
71+
identified_negative_count: int = Field(0, alias="identifiedNegativeCount")
72+
identified_zero_with_solar_count: int = Field(0, alias="identifiedZeroWithSolarCount")
73+
identified_ratio_outlier_count: int = Field(0, alias="identifiedRatioOutlierCount")
74+
reason: str | None = None
75+
76+
model_config = ConfigDict(populate_by_name=True)

openenergyid/cleaning/solar.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Solar production source-data filtering.
2+
3+
Removes physically implausible solar production observations from a source
4+
frame before analysis: negative production, zero production while a solar
5+
reference signal indicates meaningful production, and robust
6+
production-to-reference ratio outliers.
7+
8+
The filter removes rows; it never mutates values. Run it on raw (pre-resample)
9+
data so aggregated energy totals are computed from the retained observations.
10+
"""
11+
12+
import numpy as np
13+
import pandas as pd
14+
15+
from .models import FilteringDiagnostics, SolarSourceFilteringParameters
16+
17+
18+
def _resolve_reference_column(
19+
frame: pd.DataFrame,
20+
reference_column: str | None,
21+
parameters: SolarSourceFilteringParameters,
22+
) -> str | None:
23+
if reference_column is not None:
24+
if reference_column not in frame.columns:
25+
raise ValueError(f"Reference column '{reference_column}' not found in frame.")
26+
return reference_column
27+
28+
for name in parameters.solar_reference_names:
29+
if name in frame.columns:
30+
return name
31+
32+
for column in frame.columns:
33+
lower = column.lower()
34+
if "solar" in lower and ("generation" in lower or "radiation" in lower):
35+
return column
36+
37+
return None
38+
39+
40+
def _positive_reference_threshold(
41+
series: pd.Series,
42+
parameters: SolarSourceFilteringParameters,
43+
) -> float:
44+
positive = series[series > 0]
45+
if positive.empty:
46+
return 0.0
47+
return max(
48+
float(positive.median()) * parameters.positive_reference_median_fraction,
49+
parameters.minimum_positive_reference,
50+
)
51+
52+
53+
def _robust_ratio_outlier_mask(
54+
ratio: pd.Series,
55+
parameters: SolarSourceFilteringParameters,
56+
) -> pd.Series:
57+
if len(ratio) < parameters.minimum_retained_rows:
58+
return pd.Series(False, index=ratio.index)
59+
60+
median = float(ratio.median())
61+
mad = float((ratio - median).abs().median())
62+
if not np.isfinite(mad) or mad <= 0:
63+
q1 = float(ratio.quantile(0.25))
64+
q3 = float(ratio.quantile(0.75))
65+
iqr = q3 - q1
66+
if not np.isfinite(iqr) or iqr <= 0:
67+
return pd.Series(False, index=ratio.index)
68+
return (ratio < q1 - parameters.ratio_iqr_multiplier * iqr) | (
69+
ratio > q3 + parameters.ratio_iqr_multiplier * iqr
70+
)
71+
72+
robust_z = 0.6745 * (ratio - median).abs() / mad
73+
return robust_z > parameters.ratio_robust_z_threshold
74+
75+
76+
def clean_solar_production_frame(
77+
frame: pd.DataFrame,
78+
production_column: str,
79+
reference_column: str | None = None,
80+
parameters: SolarSourceFilteringParameters | None = None,
81+
) -> tuple[pd.DataFrame, FilteringDiagnostics]:
82+
"""Remove invalid solar production observations from a source frame.
83+
84+
Returns the filtered frame and diagnostics. When guardrails reject the
85+
filtering (too few or too small a fraction of observations would remain),
86+
the original frame is returned untouched and the diagnostics keep the
87+
identified counts so callers can see what would have been removed.
88+
"""
89+
90+
parameters = parameters or SolarSourceFilteringParameters()
91+
92+
if production_column not in frame.columns:
93+
raise ValueError(f"Production column '{production_column}' not found in frame.")
94+
95+
original_count = len(frame)
96+
diagnostics = FilteringDiagnostics(
97+
status="nothing-to-remove",
98+
originalObservationCount=original_count,
99+
retainedObservationCount=original_count,
100+
removedObservationCount=0,
101+
)
102+
103+
if original_count == 0:
104+
diagnostics.reason = "empty frame"
105+
return frame, diagnostics
106+
107+
reference = _resolve_reference_column(frame, reference_column, parameters)
108+
109+
# Numeric view used for mask computation only; the returned frame keeps
110+
# the original values.
111+
numeric = frame.apply(pd.to_numeric, errors="coerce")
112+
y = numeric[production_column]
113+
keep = pd.Series(True, index=numeric.index)
114+
115+
negative_mask = y < 0
116+
diagnostics.identified_negative_count = int(negative_mask.sum())
117+
keep &= ~negative_mask
118+
119+
if reference is not None:
120+
reference_series = numeric[reference]
121+
reference_threshold = _positive_reference_threshold(reference_series[keep], parameters)
122+
123+
zero_with_solar_mask = (y <= 0) & (reference_series > reference_threshold)
124+
diagnostics.identified_zero_with_solar_count = int((keep & zero_with_solar_mask).sum())
125+
keep &= ~zero_with_solar_mask
126+
127+
ratio_candidates = (
128+
keep
129+
& np.isfinite(y)
130+
& np.isfinite(reference_series)
131+
& (y > 0)
132+
& (reference_series > reference_threshold)
133+
)
134+
ratios = y[ratio_candidates] / reference_series[ratio_candidates]
135+
ratio_outliers = _robust_ratio_outlier_mask(ratios, parameters)
136+
diagnostics.identified_ratio_outlier_count = int(ratio_outliers.sum())
137+
keep.loc[ratio_outliers[ratio_outliers].index] = False
138+
139+
retained_count = int(keep.sum())
140+
identified_count = original_count - retained_count
141+
142+
if identified_count == 0:
143+
diagnostics.reason = "no invalid solar observations identified"
144+
return frame, diagnostics
145+
146+
if retained_count < parameters.minimum_retained_rows:
147+
diagnostics.status = "rejected-guardrail"
148+
diagnostics.reason = "too few observations would remain after filtering"
149+
return frame, diagnostics
150+
151+
if retained_count / original_count < parameters.minimum_retained_fraction:
152+
diagnostics.status = "rejected-guardrail"
153+
diagnostics.reason = "too much source data would be removed"
154+
return frame, diagnostics
155+
156+
diagnostics.status = "applied"
157+
diagnostics.retained_observation_count = retained_count
158+
diagnostics.removed_observation_count = identified_count
159+
diagnostics.reason = "solar production source-data filtering applied"
160+
return frame.loc[keep].copy(), diagnostics

openenergyid/mvlr/__init__.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,14 @@
66
IndependentVariableResult,
77
MultiVariableRegressionInput,
88
MultiVariableRegressionResult,
9-
OutlierFilteringDiagnostics,
10-
SourceDataFilteringParameters,
119
ValidationParameters,
1210
)
13-
from .source_data_filtering import clean_regression_frame, clean_solar_source_frame
1411

1512
__all__ = [
1613
"find_best_mvlr",
17-
"clean_regression_frame",
18-
"clean_solar_source_frame",
1914
"IndependentVariableInput",
2015
"MultiVariableRegressionInput",
2116
"MultiVariableRegressionResult",
22-
"OutlierFilteringDiagnostics",
23-
"SourceDataFilteringParameters",
2417
"ValidationParameters",
2518
"IndependentVariableResult",
2619
]

openenergyid/mvlr/main.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,16 @@
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, clean_solar_source_frame
76

87

98
def find_best_mvlr(
109
data: MultiVariableRegressionInput,
1110
) -> MultiVariableRegressionResult:
1211
"""Cycle through multiple granularities and return the best model."""
1312
best_rsquared = 0
14-
best_filtering = None
1513
for granularity in data.granularities:
1614
frame = data.data_frame()
17-
frame, solar_filtering = clean_solar_source_frame(
18-
frame,
19-
data.dependent_variable,
20-
data.source_data_filtering,
21-
)
2215
frame = resample_input_data(data=frame, granularity=granularity)
23-
frame, finite_filtering = clean_regression_frame(
24-
frame,
25-
data.dependent_variable,
26-
data.source_data_filtering,
27-
)
28-
filtering = solar_filtering if solar_filtering.applied else finite_filtering
29-
best_filtering = filtering
3016
mvlr = MultiVariableLinearRegression(
3117
data=frame,
3218
y=data.dependent_variable,
@@ -41,17 +27,9 @@ def find_best_mvlr(
4127
max_f_pvalue=data.validation_parameters.f_pvalue,
4228
max_pvalues=data.validation_parameters.pvalues,
4329
):
44-
result = MultiVariableRegressionResult.from_mvlr(mvlr)
45-
result.outlier_filtering = filtering
46-
return result
30+
return MultiVariableRegressionResult.from_mvlr(mvlr)
4731
best_rsquared = max(best_rsquared, mvlr.fit.rsquared_adj)
48-
detail = (
32+
raise ValueError(
4933
f"No valid model found. Best R²: {best_rsquared:.3f} "
5034
f"(need ≥{data.validation_parameters.rsquared})"
5135
)
52-
if best_filtering and best_filtering.applied:
53-
detail += (
54-
f"; outlier filtering removed {best_filtering.removed_observation_count}/"
55-
f"{best_filtering.original_observation_count} observations"
56-
)
57-
raise ValueError(detail)

0 commit comments

Comments
 (0)