Skip to content

Commit e51617f

Browse files
authored
feat: standalone solar source-data cleaning module (#52)
* fix: filter invalid solar MVLR observations * test: cover generic MVLR non-finite filtering * feat: configure MVLR source-data filtering * fix: preserve aggregate totals during MVLR cleanup * fix: make MVLR source filtering opt-in * 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. * feat: add GitHub CLI feature to devcontainer configuration * fix: filter non-finite solar production explicitly NaN/inf production values (e.g. non-numeric source values coerced by pd.to_numeric) slipped past the negative and zero-with-solar masks unnoticed, since NaN comparisons evaluate to False, and reached the fit. Drop them explicitly first and report identifiedNonFiniteCount in the diagnostics. Claude-Session: https://claude.ai/code/session_018cGmYvR5m83ibccnCbpJvN --------- Co-authored-by: Jan Pecinovsky <=>
1 parent 3312fb9 commit e51617f

11 files changed

Lines changed: 534 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"features": {
3+
"ghcr.io/devcontainers/features/github-cli:1": {
4+
"version": "1.1.0",
5+
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
6+
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
7+
}
8+
}
9+
}

.devcontainer/devcontainer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"--security-opt",
88
"apparmor=unconfined"
99
],
10-
"features": {},
10+
"features": {
11+
"ghcr.io/devcontainers/features/github-cli:1": {}
12+
},
1113
"postCreateCommand": "zsh -l .devcontainer/post-install.sh",
1214
// "postStartCommand": "",
1315
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=cached",

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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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_non_finite_count: int = Field(0, alias="identifiedNonFiniteCount")
72+
identified_negative_count: int = Field(0, alias="identifiedNegativeCount")
73+
identified_zero_with_solar_count: int = Field(0, alias="identifiedZeroWithSolarCount")
74+
identified_ratio_outlier_count: int = Field(0, alias="identifiedRatioOutlierCount")
75+
reason: str | None = None
76+
77+
model_config = ConfigDict(populate_by_name=True)

openenergyid/cleaning/solar.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
# NaN/inf production (e.g. non-numeric values coerced above) would slip
116+
# past the comparison masks below unnoticed, so drop it explicitly first.
117+
non_finite_mask = ~np.isfinite(y)
118+
diagnostics.identified_non_finite_count = int(non_finite_mask.sum())
119+
keep &= ~non_finite_mask
120+
121+
negative_mask = y < 0
122+
diagnostics.identified_negative_count = int(negative_mask.sum())
123+
keep &= ~negative_mask
124+
125+
if reference is not None:
126+
reference_series = numeric[reference]
127+
reference_threshold = _positive_reference_threshold(reference_series[keep], parameters)
128+
129+
zero_with_solar_mask = (y <= 0) & (reference_series > reference_threshold)
130+
diagnostics.identified_zero_with_solar_count = int((keep & zero_with_solar_mask).sum())
131+
keep &= ~zero_with_solar_mask
132+
133+
ratio_candidates = (
134+
keep
135+
& np.isfinite(y)
136+
& np.isfinite(reference_series)
137+
& (y > 0)
138+
& (reference_series > reference_threshold)
139+
)
140+
ratios = y[ratio_candidates] / reference_series[ratio_candidates]
141+
ratio_outliers = _robust_ratio_outlier_mask(ratios, parameters)
142+
diagnostics.identified_ratio_outlier_count = int(ratio_outliers.sum())
143+
keep.loc[ratio_outliers[ratio_outliers].index] = False
144+
145+
retained_count = int(keep.sum())
146+
identified_count = original_count - retained_count
147+
148+
if identified_count == 0:
149+
diagnostics.reason = "no invalid solar observations identified"
150+
return frame, diagnostics
151+
152+
if retained_count < parameters.minimum_retained_rows:
153+
diagnostics.status = "rejected-guardrail"
154+
diagnostics.reason = "too few observations would remain after filtering"
155+
return frame, diagnostics
156+
157+
if retained_count / original_count < parameters.minimum_retained_fraction:
158+
diagnostics.status = "rejected-guardrail"
159+
diagnostics.reason = "too much source data would be removed"
160+
return frame, diagnostics
161+
162+
diagnostics.status = "applied"
163+
diagnostics.retained_observation_count = retained_count
164+
diagnostics.removed_observation_count = identified_count
165+
diagnostics.reason = "solar production source-data filtering applied"
166+
return frame.loc[keep].copy(), diagnostics

openenergyid/mvlr/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ def find_best_mvlr(
3030
return MultiVariableRegressionResult.from_mvlr(mvlr)
3131
best_rsquared = max(best_rsquared, mvlr.fit.rsquared_adj)
3232
raise ValueError(
33-
f"No valid model found. Best R²: {best_rsquared:.3f} (need ≥{data.validation_parameters.rsquared})"
33+
f"No valid model found. Best R²: {best_rsquared:.3f} "
34+
f"(need ≥{data.validation_parameters.rsquared})"
3435
)

openenergyid/mvlr/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class MultiVariableRegressionInput(BaseModel):
7171
granularities: list[Granularity]
7272
allow_negative_predictions: bool = Field(alias="allowNegativePredictions", default=False)
7373
validation_parameters: ValidationParameters = Field(
74-
alias="validationParameters", default=ValidationParameters()
74+
alias="validationParameters", default_factory=ValidationParameters
7575
)
7676
single_use_exog_prefixes: list[str] | None = Field(
7777
# default=["HDD", "CDD", "FDD"],

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" },

tests/cleaning/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)