wtclean 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wtclean-0.1.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, wtclean contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
wtclean-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: wtclean
3
+ Version: 0.1.0
4
+ Summary: Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering
5
+ License: BSD-3-Clause
6
+ Keywords: wind-energy,wind-turbine,scada,power-curve,outlier-detection,data-cleaning
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: License :: OSI Approved :: BSD License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pandas
19
+ Requires-Dist: matplotlib
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: keywords
24
+ Dynamic: license
25
+ Dynamic: license-file
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
29
+
30
+ # wtclean
31
+
32
+ 风电机组 SCADA 功率曲线数据清洗工具:用「迭代分箱 + MAD(中位数绝对偏差)」把**正常运行数据**从原始 SCADA 数据中初筛出来。
33
+
34
+ ## 这个包是做什么的
35
+
36
+ 输入**一台风机**的 SCADA 数据(至少要含风速、有功功率两列,通常为 10 分钟统计记录)。
37
+
38
+ `PowerCurveFiltering.process()` 会把数据按行切分成两份返回:
39
+
40
+ - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
41
+ - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
42
+
43
+ 单台风机的整条清洗流水线分三步:
44
+
45
+ 1. **去停机**:风速 ≥ 切入风速、但功率 ≤ 1 kW 的点(风机已停机)→ 剔除。
46
+ 2. **高风低功率粗洗**:风速 ≥ 额定风速、但功率 < `low_power_ratio × 额定功率` 的点(限电、降载、桨距/故障等非正常发电)→ 剔除。
47
+ 3. **迭代 bin+MAD 精洗**:按 `bin_interval` 把风速分箱,对每箱算功率中位数与 MAD,箱内功率落在 `中位数 ± z_coeff × MAD` 带外的点 → 剔除。剔除后**重新分箱再剔**,重复 `filter_cycle` 轮(每轮带会随数据变干净而收窄)。
48
+
49
+ 几个内置的判定约定(避免误杀正常点):
50
+
51
+ - 箱内样本 < 3,或功率离散度为 0(典型如额定功率平台),该箱**不判定、直接保留**;
52
+ - 风速低于切入风速的点默认保留(近零功率属正常待机);
53
+ - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
54
+
55
+ ## 安装
56
+
57
+ ```bash
58
+ git clone <this-repo>
59
+ cd wtclean
60
+ pip install -r requirements.txt
61
+ pip install .
62
+ ```
63
+
64
+ ## 快速开始(单台风机)
65
+
66
+ ```python
67
+ import pandas as pd
68
+ from wtclean import PowerCurveFiltering
69
+
70
+ df = pd.read_csv("data.csv")
71
+ turbine = df[df["Wind_turbine_name"] == "R80721"] # 取单台风机
72
+
73
+ pc_filter = PowerCurveFiltering(
74
+ windspeed_label="Ws_avg", # 风速列名
75
+ power_label="P_avg", # 有功功率列名
76
+ df=turbine, # 单台风机数据
77
+ cut_in_speed=3.5, # 切入风速(厂商参数,必填)
78
+ rated_wind_speed=14.5, # 额定风速(厂商参数,必填)
79
+ rated_power=2050, # 额定功率 kW(厂商参数,必填)
80
+ low_power_ratio=0.9, # 高风低功率粗洗阈值(默认 0.9)
81
+ bin_interval=0.5, # 风速分箱宽度(默认 0.5)
82
+ z_coeff=2.5, # 正常带宽度系数(默认 2.5)
83
+ filter_cycle=3, # 迭代轮数(默认 3)
84
+ return_fig=False, # 是否保存功率曲线图
85
+ image_path="", # 出图时的完整输出文件路径
86
+ )
87
+
88
+ normal_df, abnormal_df = pc_filter.process()
89
+ ```
90
+
91
+ 运行时会逐轮打印删除情况,例如:
92
+
93
+ ```
94
+ iteration 1: removed 3864 (7.21%), remaining 49724
95
+ iteration 2: removed 1655 (3.33%), remaining 48069
96
+ iteration 3: removed 904 (1.88%), remaining 47165
97
+ ```
98
+
99
+ 每轮删除数也会记录在 `pc_filter.removal_history`(list[int]),方便你审计或画收敛曲线。
100
+
101
+ ## 多台风机
102
+
103
+ `PowerCurveFiltering` 一次只处理一台;多台由调用方 `groupby` 后循环调用:
104
+
105
+ ```python
106
+ from wtclean import PowerCurveFiltering, estimate_machine_parameters
107
+
108
+ results = {}
109
+ for name, group in df.groupby("Wind_turbine_name"):
110
+ params = estimate_machine_parameters(group, "Ws_avg", "P_avg") # 先反推厂商参数
111
+ pcf = PowerCurveFiltering(
112
+ "Ws_avg", "P_avg", group,
113
+ cut_in_speed=params["cut_in_speed"],
114
+ rated_wind_speed=params["rated_wind_speed"],
115
+ rated_power=params["rated_power"],
116
+ )
117
+ results[name] = pcf.process() # (normal_df, abnormal_df)
118
+ ```
119
+
120
+ ## 厂商参数不知道怎么办
121
+
122
+ `cut_in_speed` / `rated_wind_speed` / `rated_power` 是**风机机型物理参数,必填**。若拿不到厂商技术参数,可先从 SCADA 数据反推(`estimate_machine_parameters` 会估计全部三个值),再把结果传给构造函数:
123
+
124
+ ```python
125
+ from wtclean import estimate_machine_parameters
126
+
127
+ params = estimate_machine_parameters(turbine, "Ws_avg", "P_avg")
128
+ # -> {"cut_in_speed": 3.78, "rated_wind_speed": 13.59, "rated_power": 1985.3}
129
+ ```
130
+
131
+ > 注意:反推值来自(通常是 10 分钟统计的)SCADA 数据,是"软"值——例如反推的额定风速约 13 m/s,会低于厂商标称的约 14.5 m/s,因为时间平均会把功率曲线拐点往左拉。生产使用前建议与厂商功率曲线表交叉核对;正式项目优先填厂商标称值。
132
+
133
+ ## 参数说明
134
+
135
+ | 参数 | 默认 | 含义 |
136
+ | --- | --- | --- |
137
+ | `windspeed_label` | — | 风速列名(必填)。 |
138
+ | `power_label` | — | 有功功率列名(必填)。 |
139
+ | `df` | — | **单台**风机的 SCADA DataFrame(必填)。 |
140
+ | `cut_in_speed` | — | 切入风速 m/s(厂商参数,必填)。低于该风速默认保留;停机判定从该风速起算。 |
141
+ | `rated_wind_speed` | — | 额定风速 m/s(厂商参数,必填)。风速 ≥ 它时风机应接近额定功率,是"高风低功率粗洗"的起点。 |
142
+ | `rated_power` | — | 额定功率 kW(厂商参数,必填)。高风区各类比例阈值都以此为基准。 |
143
+ | `low_power_ratio` | `0.9` | 高风低功率粗洗阈值:风速 ≥ 额定风速时,功率 < 该比例×额定功率 即剔除。 |
144
+ | `bin_interval` | `0.5` | 风速分箱宽度 m/s。越小分箱越细(样本少的区段统计越不稳),越大越粗。 |
145
+ | `z_coeff` | `2.5` | 正常带宽度 = `中位数 ± z_coeff × MAD`。见下方调参建议。 |
146
+ | `filter_cycle` | `3` | bin+MAD 迭代精洗的轮数上限。见下方调参建议。 |
147
+ | `return_fig` | `False` | 是否保存一张功率曲线清洗结果散点图(蓝=Normal / 橙=Abnormal)。 |
148
+ | `image_path` | `""` | `return_fig=True` 时输出图片的**完整文件路径**(含文件名,如 `./images/turbine_pc.png`);目录不存在会自动创建。 |
149
+
150
+ ## 调参建议(最终决策权在你)
151
+
152
+ **"保留多少 / 洗得多纯"没有绝对正确的值**,取决于你拿到 `normal_df` 后要干什么——拿去给高阶模型精洗可以放宽一点(尽量保点),指望初筛结果直接用就得收紧。每次调参都是"保点 vs 纯净"的权衡,建议结合输出图与逐轮打印来判断。以下给出方向和量级参考。
153
+
154
+ ### `z_coeff`——正常带多宽(影响最大,决定"删多少")
155
+
156
+ 含义:功率偏离该箱中位数多少个 MAD 算正常。**MAD 用原始值、未乘 1.4826**,所以不能直接当"σ 倍数"读;换算成高斯直觉(原始 MAD ≈ 0.675σ):
157
+
158
+ | z_coeff | 正常带约 | 高斯下保留比例 | 档位 |
159
+ | --- | --- | --- | --- |
160
+ | `2.0` | ±1.35σ | ~82% | 激进(洗得纯,易误删) |
161
+ | `2.5`(默认) | ±1.69σ | ~91% | 居中 |
162
+ | `3.0` | ±2.02σ | ~96% | 偏宽松 |
163
+ | `4.0` | ±2.70σ | ~99% | 很宽松(基本只剔粗洗) |
164
+
165
+ - **越大 → 带越宽 → 保留越多**(正常点误删少,但漏进 `normal_df` 的真异常变多);
166
+ - **越小 → 带越窄 → 删得越多**(`normal_df` 更纯,但可能误删边界正常点)。
167
+
168
+ 实测参考(La Haute Borne,MM82,`filter_cycle=3`):`z_coeff=2.5` 时 abnormal 约 13–15%;`z_coeff=4.0` 时骤降到约 3%(此时 MAD 层每轮只删 ~1%,第 2、3 轮基本空转)。也就是说 `z_coeff=4` 已接近"纯粗洗"。
169
+
170
+ 建议:后续要做 GAM/sigmoid/时间维度精洗时,可把 `z_coeff` 放到 3~4 先把明显离群点去掉;若想让这一层初筛就尽量干净、愿意接受少部分正常点损失,用 2~2.5。
171
+
172
+ ### `filter_cycle`——迭代几轮
173
+
174
+ 机制:第 1 轮删得最多(清掉最明显的离群点),之后每轮重新分箱、带变窄,**删除量快速递减**——后面几轮主要是在不断收窄正常带,**可能开始误删边界正常点**。
175
+
176
+ - 实测各数据集上,多数风机的删除量到第 3~5 轮已降到每轮 <1%(可看逐轮打印确认)。
177
+ - 想要**更保守、尽量保留正常点**:`2` 就够,甚至 `1`(只粗洗 + 单轮 MAD);
178
+ - 想**更彻底**(如训练样本允许损失一部分):`5`,但要警惕过度清洗;
179
+ - 反正每轮都会打印删除量与比例,看到某轮删除已经趋近 0,就说明再加轮次意义不大。
180
+
181
+ ### 其它参数
182
+
183
+ - **`low_power_ratio`**:只影响额定风速以上的"限电/降载"粗洗。想多剔除降载工况就调小(如 0.85),想更保险保留就调大。注意别设太低,否则额定平台上的轻微降载会漏掉。
184
+ - **`bin_interval`**:高风速区样本稀的话可适当调大(如 1.0)提高该区段 MAD 稳定性;低风速区样本极多时调小能更精细。默认 0.5 对多数 10 分钟数据够用。
185
+ - **`cut_in_speed`/`rated_wind_speed`/`rated_power`**:这三个是物理参数,**不要拿来当清洗旋钮调**。设错会直接让粗洗规则失效(比如额定风速设太低会把正常爬坡段误当"高风低功率"整段删掉)。拿不准就反推,再和厂商表核对。
186
+
187
+ ## 出图(`return_fig=True`)
188
+
189
+ 会画一张风速-功率散点图:蓝色 = Normal(`normal_df`),橙色 = Abnormal(`abnormal_df`),图例已标注。图片保存到 `image_path` 指定的完整文件路径。建议每次调参都出一张图,肉眼确认"边界处"是否删得合理。
190
+
191
+ ## 测试
192
+
193
+ ```bash
194
+ python -m unittest discover -s test -v
195
+ ```
196
+
197
+ 当前测试覆盖:正常/异常切分、重复索引不膨胀、粗洗(停机、高风低功率)、非法参数与空数据校验、出图、迭代删除记录。
@@ -0,0 +1,168 @@
1
+ # wtclean
2
+
3
+ 风电机组 SCADA 功率曲线数据清洗工具:用「迭代分箱 + MAD(中位数绝对偏差)」把**正常运行数据**从原始 SCADA 数据中初筛出来。
4
+
5
+ ## 这个包是做什么的
6
+
7
+ 输入**一台风机**的 SCADA 数据(至少要含风速、有功功率两列,通常为 10 分钟统计记录)。
8
+
9
+ `PowerCurveFiltering.process()` 会把数据按行切分成两份返回:
10
+
11
+ - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
12
+ - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
13
+
14
+ 单台风机的整条清洗流水线分三步:
15
+
16
+ 1. **去停机**:风速 ≥ 切入风速、但功率 ≤ 1 kW 的点(风机已停机)→ 剔除。
17
+ 2. **高风低功率粗洗**:风速 ≥ 额定风速、但功率 < `low_power_ratio × 额定功率` 的点(限电、降载、桨距/故障等非正常发电)→ 剔除。
18
+ 3. **迭代 bin+MAD 精洗**:按 `bin_interval` 把风速分箱,对每箱算功率中位数与 MAD,箱内功率落在 `中位数 ± z_coeff × MAD` 带外的点 → 剔除。剔除后**重新分箱再剔**,重复 `filter_cycle` 轮(每轮带会随数据变干净而收窄)。
19
+
20
+ 几个内置的判定约定(避免误杀正常点):
21
+
22
+ - 箱内样本 < 3,或功率离散度为 0(典型如额定功率平台),该箱**不判定、直接保留**;
23
+ - 风速低于切入风速的点默认保留(近零功率属正常待机);
24
+ - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ git clone <this-repo>
30
+ cd wtclean
31
+ pip install -r requirements.txt
32
+ pip install .
33
+ ```
34
+
35
+ ## 快速开始(单台风机)
36
+
37
+ ```python
38
+ import pandas as pd
39
+ from wtclean import PowerCurveFiltering
40
+
41
+ df = pd.read_csv("data.csv")
42
+ turbine = df[df["Wind_turbine_name"] == "R80721"] # 取单台风机
43
+
44
+ pc_filter = PowerCurveFiltering(
45
+ windspeed_label="Ws_avg", # 风速列名
46
+ power_label="P_avg", # 有功功率列名
47
+ df=turbine, # 单台风机数据
48
+ cut_in_speed=3.5, # 切入风速(厂商参数,必填)
49
+ rated_wind_speed=14.5, # 额定风速(厂商参数,必填)
50
+ rated_power=2050, # 额定功率 kW(厂商参数,必填)
51
+ low_power_ratio=0.9, # 高风低功率粗洗阈值(默认 0.9)
52
+ bin_interval=0.5, # 风速分箱宽度(默认 0.5)
53
+ z_coeff=2.5, # 正常带宽度系数(默认 2.5)
54
+ filter_cycle=3, # 迭代轮数(默认 3)
55
+ return_fig=False, # 是否保存功率曲线图
56
+ image_path="", # 出图时的完整输出文件路径
57
+ )
58
+
59
+ normal_df, abnormal_df = pc_filter.process()
60
+ ```
61
+
62
+ 运行时会逐轮打印删除情况,例如:
63
+
64
+ ```
65
+ iteration 1: removed 3864 (7.21%), remaining 49724
66
+ iteration 2: removed 1655 (3.33%), remaining 48069
67
+ iteration 3: removed 904 (1.88%), remaining 47165
68
+ ```
69
+
70
+ 每轮删除数也会记录在 `pc_filter.removal_history`(list[int]),方便你审计或画收敛曲线。
71
+
72
+ ## 多台风机
73
+
74
+ `PowerCurveFiltering` 一次只处理一台;多台由调用方 `groupby` 后循环调用:
75
+
76
+ ```python
77
+ from wtclean import PowerCurveFiltering, estimate_machine_parameters
78
+
79
+ results = {}
80
+ for name, group in df.groupby("Wind_turbine_name"):
81
+ params = estimate_machine_parameters(group, "Ws_avg", "P_avg") # 先反推厂商参数
82
+ pcf = PowerCurveFiltering(
83
+ "Ws_avg", "P_avg", group,
84
+ cut_in_speed=params["cut_in_speed"],
85
+ rated_wind_speed=params["rated_wind_speed"],
86
+ rated_power=params["rated_power"],
87
+ )
88
+ results[name] = pcf.process() # (normal_df, abnormal_df)
89
+ ```
90
+
91
+ ## 厂商参数不知道怎么办
92
+
93
+ `cut_in_speed` / `rated_wind_speed` / `rated_power` 是**风机机型物理参数,必填**。若拿不到厂商技术参数,可先从 SCADA 数据反推(`estimate_machine_parameters` 会估计全部三个值),再把结果传给构造函数:
94
+
95
+ ```python
96
+ from wtclean import estimate_machine_parameters
97
+
98
+ params = estimate_machine_parameters(turbine, "Ws_avg", "P_avg")
99
+ # -> {"cut_in_speed": 3.78, "rated_wind_speed": 13.59, "rated_power": 1985.3}
100
+ ```
101
+
102
+ > 注意:反推值来自(通常是 10 分钟统计的)SCADA 数据,是"软"值——例如反推的额定风速约 13 m/s,会低于厂商标称的约 14.5 m/s,因为时间平均会把功率曲线拐点往左拉。生产使用前建议与厂商功率曲线表交叉核对;正式项目优先填厂商标称值。
103
+
104
+ ## 参数说明
105
+
106
+ | 参数 | 默认 | 含义 |
107
+ | --- | --- | --- |
108
+ | `windspeed_label` | — | 风速列名(必填)。 |
109
+ | `power_label` | — | 有功功率列名(必填)。 |
110
+ | `df` | — | **单台**风机的 SCADA DataFrame(必填)。 |
111
+ | `cut_in_speed` | — | 切入风速 m/s(厂商参数,必填)。低于该风速默认保留;停机判定从该风速起算。 |
112
+ | `rated_wind_speed` | — | 额定风速 m/s(厂商参数,必填)。风速 ≥ 它时风机应接近额定功率,是"高风低功率粗洗"的起点。 |
113
+ | `rated_power` | — | 额定功率 kW(厂商参数,必填)。高风区各类比例阈值都以此为基准。 |
114
+ | `low_power_ratio` | `0.9` | 高风低功率粗洗阈值:风速 ≥ 额定风速时,功率 < 该比例×额定功率 即剔除。 |
115
+ | `bin_interval` | `0.5` | 风速分箱宽度 m/s。越小分箱越细(样本少的区段统计越不稳),越大越粗。 |
116
+ | `z_coeff` | `2.5` | 正常带宽度 = `中位数 ± z_coeff × MAD`。见下方调参建议。 |
117
+ | `filter_cycle` | `3` | bin+MAD 迭代精洗的轮数上限。见下方调参建议。 |
118
+ | `return_fig` | `False` | 是否保存一张功率曲线清洗结果散点图(蓝=Normal / 橙=Abnormal)。 |
119
+ | `image_path` | `""` | `return_fig=True` 时输出图片的**完整文件路径**(含文件名,如 `./images/turbine_pc.png`);目录不存在会自动创建。 |
120
+
121
+ ## 调参建议(最终决策权在你)
122
+
123
+ **"保留多少 / 洗得多纯"没有绝对正确的值**,取决于你拿到 `normal_df` 后要干什么——拿去给高阶模型精洗可以放宽一点(尽量保点),指望初筛结果直接用就得收紧。每次调参都是"保点 vs 纯净"的权衡,建议结合输出图与逐轮打印来判断。以下给出方向和量级参考。
124
+
125
+ ### `z_coeff`——正常带多宽(影响最大,决定"删多少")
126
+
127
+ 含义:功率偏离该箱中位数多少个 MAD 算正常。**MAD 用原始值、未乘 1.4826**,所以不能直接当"σ 倍数"读;换算成高斯直觉(原始 MAD ≈ 0.675σ):
128
+
129
+ | z_coeff | 正常带约 | 高斯下保留比例 | 档位 |
130
+ | --- | --- | --- | --- |
131
+ | `2.0` | ±1.35σ | ~82% | 激进(洗得纯,易误删) |
132
+ | `2.5`(默认) | ±1.69σ | ~91% | 居中 |
133
+ | `3.0` | ±2.02σ | ~96% | 偏宽松 |
134
+ | `4.0` | ±2.70σ | ~99% | 很宽松(基本只剔粗洗) |
135
+
136
+ - **越大 → 带越宽 → 保留越多**(正常点误删少,但漏进 `normal_df` 的真异常变多);
137
+ - **越小 → 带越窄 → 删得越多**(`normal_df` 更纯,但可能误删边界正常点)。
138
+
139
+ 实测参考(La Haute Borne,MM82,`filter_cycle=3`):`z_coeff=2.5` 时 abnormal 约 13–15%;`z_coeff=4.0` 时骤降到约 3%(此时 MAD 层每轮只删 ~1%,第 2、3 轮基本空转)。也就是说 `z_coeff=4` 已接近"纯粗洗"。
140
+
141
+ 建议:后续要做 GAM/sigmoid/时间维度精洗时,可把 `z_coeff` 放到 3~4 先把明显离群点去掉;若想让这一层初筛就尽量干净、愿意接受少部分正常点损失,用 2~2.5。
142
+
143
+ ### `filter_cycle`——迭代几轮
144
+
145
+ 机制:第 1 轮删得最多(清掉最明显的离群点),之后每轮重新分箱、带变窄,**删除量快速递减**——后面几轮主要是在不断收窄正常带,**可能开始误删边界正常点**。
146
+
147
+ - 实测各数据集上,多数风机的删除量到第 3~5 轮已降到每轮 <1%(可看逐轮打印确认)。
148
+ - 想要**更保守、尽量保留正常点**:`2` 就够,甚至 `1`(只粗洗 + 单轮 MAD);
149
+ - 想**更彻底**(如训练样本允许损失一部分):`5`,但要警惕过度清洗;
150
+ - 反正每轮都会打印删除量与比例,看到某轮删除已经趋近 0,就说明再加轮次意义不大。
151
+
152
+ ### 其它参数
153
+
154
+ - **`low_power_ratio`**:只影响额定风速以上的"限电/降载"粗洗。想多剔除降载工况就调小(如 0.85),想更保险保留就调大。注意别设太低,否则额定平台上的轻微降载会漏掉。
155
+ - **`bin_interval`**:高风速区样本稀的话可适当调大(如 1.0)提高该区段 MAD 稳定性;低风速区样本极多时调小能更精细。默认 0.5 对多数 10 分钟数据够用。
156
+ - **`cut_in_speed`/`rated_wind_speed`/`rated_power`**:这三个是物理参数,**不要拿来当清洗旋钮调**。设错会直接让粗洗规则失效(比如额定风速设太低会把正常爬坡段误当"高风低功率"整段删掉)。拿不准就反推,再和厂商表核对。
157
+
158
+ ## 出图(`return_fig=True`)
159
+
160
+ 会画一张风速-功率散点图:蓝色 = Normal(`normal_df`),橙色 = Abnormal(`abnormal_df`),图例已标注。图片保存到 `image_path` 指定的完整文件路径。建议每次调参都出一张图,肉眼确认"边界处"是否删得合理。
161
+
162
+ ## 测试
163
+
164
+ ```bash
165
+ python -m unittest discover -s test -v
166
+ ```
167
+
168
+ 当前测试覆盖:正常/异常切分、重复索引不膨胀、粗洗(停机、高风低功率)、非法参数与空数据校验、出图、迭代删除记录。
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
wtclean-0.1.0/setup.py ADDED
@@ -0,0 +1,34 @@
1
+ import setuptools
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setuptools.setup(
7
+ name="wtclean",
8
+ version="0.1.0",
9
+ description="Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering",
10
+ long_description=long_description,
11
+ long_description_content_type="text/markdown",
12
+ license="BSD-3-Clause",
13
+ license_files=["LICENSE"],
14
+ classifiers=[
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "License :: OSI Approved :: BSD License",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: Scientific/Engineering",
22
+ ],
23
+ packages=setuptools.find_packages(exclude=["test", "examples"]),
24
+ python_requires=">=3.10",
25
+ install_requires=["numpy", "pandas", "matplotlib"],
26
+ keywords=[
27
+ "wind-energy",
28
+ "wind-turbine",
29
+ "scada",
30
+ "power-curve",
31
+ "outlier-detection",
32
+ "data-cleaning",
33
+ ],
34
+ )
@@ -0,0 +1,118 @@
1
+ """PowerCurveFiltering 单元测试(unittest,无 pytest 依赖)。"""
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from wtclean import PowerCurveFiltering
11
+
12
+ CUT_IN = 3.0
13
+ RATED_WS = 12.0
14
+ RATED_PWR = 2000.0
15
+
16
+
17
+ def _clean_df(n=2000, seed=0):
18
+ """构造一条理想功率曲线(切入~额定线性爬升,额定以上平台)+ 小噪声。"""
19
+ rng = np.random.default_rng(seed)
20
+ ws = rng.uniform(CUT_IN, 25.0, n)
21
+ power = np.where(
22
+ ws < RATED_WS,
23
+ RATED_PWR * (ws - CUT_IN) / (RATED_WS - CUT_IN),
24
+ RATED_PWR,
25
+ )
26
+ power += rng.normal(0, 20, n)
27
+ return pd.DataFrame({"ws": ws, "pw": power})
28
+
29
+
30
+ def _pcf(df, **kwargs):
31
+ return PowerCurveFiltering(
32
+ windspeed_label="ws",
33
+ power_label="pw",
34
+ df=df,
35
+ cut_in_speed=CUT_IN,
36
+ rated_wind_speed=RATED_WS,
37
+ rated_power=RATED_PWR,
38
+ **kwargs,
39
+ )
40
+
41
+
42
+ class TestPowerCurveFiltering(unittest.TestCase):
43
+ def test_partition_and_outlier_detection(self):
44
+ df = _clean_df()
45
+ df["is_outlier"] = False
46
+ # 注入两个明确异常:高风低功率、高风停机
47
+ df.loc[len(df)] = {"ws": 20.0, "pw": 100.0, "is_outlier": True}
48
+ df.loc[len(df)] = {"ws": 15.0, "pw": 0.0, "is_outlier": True}
49
+
50
+ normal_df, abnormal_df = _pcf(df).process()
51
+
52
+ self.assertEqual(len(normal_df) + len(abnormal_df), len(df))
53
+ self.assertTrue(set(normal_df.index).isdisjoint(set(abnormal_df.index)))
54
+
55
+ outlier_idx = set(df.index[df["is_outlier"]].tolist())
56
+ self.assertTrue(outlier_idx.issubset(set(abnormal_df.index)))
57
+ self.assertTrue(outlier_idx.isdisjoint(set(normal_df.index)))
58
+
59
+ def test_duplicate_index_no_expansion(self):
60
+ df = _clean_df(n=500)
61
+ # 人为制造重复 index,旧实现 loc[list] 会膨胀
62
+ df.index = np.arange(len(df)) // 2
63
+
64
+ normal_df, abnormal_df = _pcf(df).process()
65
+
66
+ self.assertEqual(len(normal_df) + len(abnormal_df), len(df))
67
+
68
+ def test_high_wind_low_power_removed(self):
69
+ df = _clean_df()
70
+ df.loc[len(df)] = {"ws": 20.0, "pw": 0.5 * RATED_PWR}
71
+ _, abnormal_df = _pcf(df).process()
72
+ self.assertIn(len(df) - 1, abnormal_df.index.tolist())
73
+
74
+ def test_downtime_removed(self):
75
+ df = _clean_df()
76
+ df.loc[len(df)] = {"ws": 10.0, "pw": 0.0}
77
+ _, abnormal_df = _pcf(df).process()
78
+ self.assertIn(len(df) - 1, abnormal_df.index.tolist())
79
+
80
+ def test_return_fig_saves_file(self):
81
+ df = _clean_df(n=300)
82
+ with tempfile.TemporaryDirectory() as tmp:
83
+ out = os.path.join(tmp, "nested", "dir", "turbine_pc.png")
84
+ _pcf(df, return_fig=True, image_path=out).process()
85
+ self.assertTrue(os.path.exists(out))
86
+
87
+ def test_invalid_params(self):
88
+ df = _clean_df(n=100)
89
+
90
+ with self.assertRaises(ValueError):
91
+ PowerCurveFiltering("bad_col", "pw", df, CUT_IN, RATED_WS, RATED_PWR)
92
+ with self.assertRaises(ValueError):
93
+ PowerCurveFiltering("ws", "pw", df, 0, RATED_WS, RATED_PWR)
94
+ with self.assertRaises(ValueError):
95
+ PowerCurveFiltering("ws", "pw", df, CUT_IN, CUT_IN, RATED_PWR)
96
+ with self.assertRaises(ValueError):
97
+ PowerCurveFiltering(
98
+ "ws", "pw", df, CUT_IN, RATED_WS, RATED_PWR, low_power_ratio=1.5
99
+ )
100
+ with self.assertRaises(ValueError):
101
+ PowerCurveFiltering(
102
+ "ws", "pw", df, CUT_IN, RATED_WS, RATED_PWR, bin_interval=0
103
+ )
104
+
105
+ def test_empty_df(self):
106
+ df = _clean_df(n=10).iloc[0:0]
107
+ with self.assertRaises(ValueError):
108
+ _pcf(df)
109
+
110
+ def test_removal_history_recorded(self):
111
+ df = _clean_df(n=500)
112
+ pcf = _pcf(df, filter_cycle=3)
113
+ pcf.process()
114
+ self.assertEqual(len(pcf.removal_history), 3)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ unittest.main()
@@ -0,0 +1,9 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from wtclean.modules.power_curve_filtering import PowerCurveFiltering
4
+ from wtclean.utils.machine_parameter_estimation import estimate_machine_parameters
5
+
6
+ __all__ = [
7
+ "PowerCurveFiltering",
8
+ "estimate_machine_parameters",
9
+ ]
@@ -0,0 +1,3 @@
1
+ from wtclean.modules.power_curve_filtering import PowerCurveFiltering
2
+
3
+ __all__ = ["PowerCurveFiltering"]
@@ -0,0 +1,230 @@
1
+ """
2
+ 该模块对 SCADA 数据应用迭代 bin + MAD(中位数绝对偏差)过滤,
3
+ 在风速-功率曲线上把正常运行点与异常点分开。
4
+ """
5
+
6
+ import os
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from wtclean.utils.binning_function import binning_func
13
+
14
+
15
+ class PowerCurveFiltering:
16
+ """对风速-功率曲线做迭代 bin + MAD 过滤。
17
+
18
+ Attributes:
19
+ windspeed_label: 风速列名。
20
+ power_label: 有功功率列名。
21
+ df: 单台风机的 SCADA DataFrame。
22
+ cut_in_speed: 切入风速(厂商机型参数,必填)。
23
+ rated_wind_speed: 额定风速,高于此风速功率应接近额定功率(厂商机型参数,必填)。
24
+ rated_power: 风机额定功率(厂商机型参数,必填)。
25
+ low_power_ratio: 额定功率的比例,高风区功率低于该比例即被粗洗。
26
+ bin_interval: 风速分箱宽度。
27
+ z_coeff: bin MAD 的倍数,用于界定正常带。
28
+ filter_cycle: 迭代过滤轮数。
29
+ return_fig: 是否保存功率曲线图。
30
+ image_path: 保存图片的目录。
31
+
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ windspeed_label: str,
37
+ power_label: str,
38
+ df: pd.DataFrame,
39
+ cut_in_speed: float,
40
+ rated_wind_speed: float,
41
+ rated_power: float,
42
+ low_power_ratio: float = 0.9,
43
+ bin_interval: float = 0.5,
44
+ z_coeff: float = 2.5,
45
+ filter_cycle: int = 3,
46
+ return_fig: bool = False,
47
+ image_path: os.PathLike | str = "",
48
+ ) -> None:
49
+
50
+ for col in (windspeed_label, power_label):
51
+ if col not in df.columns:
52
+ raise ValueError(f"DataFrame 缺少列: {col}")
53
+ if df.empty:
54
+ raise ValueError("DataFrame 为空")
55
+
56
+ if cut_in_speed <= 0:
57
+ raise ValueError(f"cut_in_speed must be > 0, got {cut_in_speed}")
58
+ if rated_wind_speed <= cut_in_speed:
59
+ raise ValueError(
60
+ f"rated_wind_speed must be > cut_in_speed, got "
61
+ f"cut_in_speed={cut_in_speed}, rated_wind_speed={rated_wind_speed}"
62
+ )
63
+ if rated_power <= 0:
64
+ raise ValueError(f"rated_power must be > 0, got {rated_power}")
65
+ if not 0 < low_power_ratio <= 1:
66
+ raise ValueError(f"low_power_ratio must be in (0, 1], got {low_power_ratio}")
67
+ if bin_interval <= 0:
68
+ raise ValueError(f"bin_interval must be > 0, got {bin_interval}")
69
+ if z_coeff <= 0:
70
+ raise ValueError(f"z_coeff must be > 0, got {z_coeff}")
71
+ if filter_cycle < 1:
72
+ raise ValueError(f"filter_cycle must be >= 1, got {filter_cycle}")
73
+
74
+ self.windspeed_label = windspeed_label
75
+ self.power_label = power_label
76
+ self.df = df
77
+ self.cut_in_speed = cut_in_speed
78
+ self.rated_wind_speed = rated_wind_speed
79
+ self.rated_power = rated_power
80
+ self.low_power_ratio = low_power_ratio
81
+ self.bin_interval = bin_interval
82
+ self.z_coeff = z_coeff
83
+ self.filter_cycle = filter_cycle
84
+ self.return_fig = return_fig
85
+ self.image_path = image_path
86
+
87
+ def remove_downtime_events(self, df: pd.DataFrame) -> pd.DataFrame:
88
+ """剔除停机事件(风速达到切入风速及以上但功率接近零)。"""
89
+ return df[
90
+ ~(
91
+ (df[self.power_label] <= 1)
92
+ & (df[self.windspeed_label] >= self.cut_in_speed)
93
+ )
94
+ ]
95
+
96
+ def remove_high_wind_low_power(self, df: pd.DataFrame) -> pd.DataFrame:
97
+ """粗洗高风低功率点。
98
+
99
+ 达到额定风速及以上时风机本应接近额定功率;该区域内功率远低于
100
+ ``low_power_ratio * rated_power`` 的点视为非正常工况(限电、降载、桨距/故障),
101
+ 为功率曲线基线建模而被剔除。这是粗洗,不是故障检测。
102
+ """
103
+ return df[
104
+ ~(
105
+ (df[self.power_label] < self.low_power_ratio * self.rated_power)
106
+ & (df[self.windspeed_label] >= self.rated_wind_speed)
107
+ )
108
+ ]
109
+
110
+ def secondary_filter(self, df: pd.DataFrame) -> list:
111
+ """使用 bin + MAD 阈值(``z_coeff``)过滤单台风机数据。
112
+
113
+ Returns:
114
+ 该台风机保留(正常)数据点的 ``_row_id`` 列表。
115
+ """
116
+ no_dt_per_turbine_df = df.copy()
117
+
118
+ no_dt_per_turbine_df.loc[:, "windspeed_bin"] = np.floor(
119
+ no_dt_per_turbine_df[self.windspeed_label] / self.bin_interval
120
+ ).astype("Int64")
121
+
122
+ if self.filter_cycle < 1:
123
+ self.filter_cycle = 1
124
+
125
+ self.removal_history: list[int] = []
126
+ prev = len(no_dt_per_turbine_df)
127
+
128
+ for i in range(int(self.filter_cycle)):
129
+ binned_turb_df = binning_func(
130
+ turbine_data=no_dt_per_turbine_df,
131
+ windspeed_label=self.windspeed_label,
132
+ power_label=self.power_label,
133
+ bin_interval=self.bin_interval,
134
+ )
135
+
136
+ bins_cols = ["windspeed_bin_median", "pwr_bin_median", "pwr_bin_mad", "n"]
137
+ if set(no_dt_per_turbine_df.columns).issuperset(set(bins_cols)):
138
+ no_dt_per_turbine_df.drop(bins_cols, axis=1, inplace=True)
139
+
140
+ no_dt_per_turbine_df = pd.merge(
141
+ no_dt_per_turbine_df, binned_turb_df, how="left", on="windspeed_bin"
142
+ )
143
+
144
+ no_dt_per_turbine_df.loc[:, "pwr_low_thresh"] = (
145
+ no_dt_per_turbine_df["pwr_bin_median"]
146
+ - self.z_coeff * no_dt_per_turbine_df["pwr_bin_mad"]
147
+ ).clip(lower=0)
148
+
149
+ no_dt_per_turbine_df.loc[:, "pwr_high_thresh"] = (
150
+ no_dt_per_turbine_df["pwr_bin_median"]
151
+ + self.z_coeff * no_dt_per_turbine_df["pwr_bin_mad"]
152
+ )
153
+
154
+ in_band = (
155
+ no_dt_per_turbine_df[self.power_label]
156
+ > no_dt_per_turbine_df["pwr_low_thresh"]
157
+ ) & (
158
+ no_dt_per_turbine_df[self.power_label]
159
+ < no_dt_per_turbine_df["pwr_high_thresh"]
160
+ )
161
+ below_cutin = no_dt_per_turbine_df[self.windspeed_label] < self.cut_in_speed
162
+ unknown = no_dt_per_turbine_df["pwr_bin_mad"].isna()
163
+
164
+ no_dt_per_turbine_df = no_dt_per_turbine_df[in_band | below_cutin | unknown]
165
+
166
+ removed = prev - len(no_dt_per_turbine_df)
167
+ self.removal_history.append(removed)
168
+ print(
169
+ f"iteration {i + 1}: removed {removed} "
170
+ f"({removed / prev:.2%}), remaining {len(no_dt_per_turbine_df)}"
171
+ )
172
+ prev = len(no_dt_per_turbine_df)
173
+
174
+ return no_dt_per_turbine_df["_row_id"].tolist()
175
+
176
+ def process(self) -> tuple[pd.DataFrame, pd.DataFrame]:
177
+ """运行完整过滤流程,返回正常与异常两个子集。
178
+
179
+ Returns:
180
+ 元组 ``(normal_df, abnormal_df)``,按行划分原始 ``df``。
181
+ """
182
+ work = self.df.copy()
183
+ work["_row_id"] = np.arange(len(work))
184
+
185
+ no_dt_df = self.remove_downtime_events(work)
186
+
187
+ # 从剩余的非停机数据中剔除高风低功率点
188
+ no_dt_df = self.remove_high_wind_low_power(no_dt_df)
189
+
190
+ normal_id_list = self.secondary_filter(no_dt_df)
191
+
192
+ keep = work["_row_id"].isin(set(normal_id_list))
193
+
194
+ assert keep.sum() == len(normal_id_list)
195
+
196
+ normal_df = work[keep].drop(columns="_row_id")
197
+ abnormal_df = work[~keep].drop(columns="_row_id")
198
+
199
+ if self.return_fig:
200
+ self.normal_df = normal_df.copy()
201
+ self.abnormal_df = abnormal_df.copy()
202
+
203
+ self.normal_df.loc[:, "Abnormal"] = "No"
204
+ self.abnormal_df.loc[:, "Abnormal"] = "Yes"
205
+
206
+ self.processed_data = pd.concat([self.normal_df, self.abnormal_df])
207
+
208
+ if self.image_path and not os.path.exists(os.path.dirname(self.image_path)):
209
+ os.makedirs(os.path.dirname(self.image_path), exist_ok=True)
210
+
211
+ fig, ax = plt.subplots(figsize=(18, 6))
212
+ for flag, color, label in (("No", "blue", "Normal"), ("Yes", "orange", "Abnormal")):
213
+ sub = self.processed_data[self.processed_data["Abnormal"] == flag]
214
+ ax.scatter(
215
+ sub[self.windspeed_label],
216
+ sub[self.power_label],
217
+ s=6,
218
+ c=color,
219
+ label=label,
220
+ )
221
+ ax.set_title("Operational power curve", fontsize=16)
222
+ ax.legend()
223
+ ax.set_xlabel("Wind Speed", fontsize=14)
224
+ ax.set_ylabel("Power", fontsize=14)
225
+ ax.tick_params(labelsize=14)
226
+
227
+ fig.savefig(self.image_path)
228
+ plt.close(fig)
229
+
230
+ return normal_df, abnormal_df
@@ -0,0 +1,4 @@
1
+ from wtclean.utils.binning_function import binning_func
2
+ from wtclean.utils.machine_parameter_estimation import estimate_machine_parameters
3
+
4
+ __all__ = ["binning_func", "estimate_machine_parameters"]
@@ -0,0 +1,129 @@
1
+ """
2
+ Function for calculating the median power and median absolute deviation (MAD) of power
3
+ for each wind speed bin of a given turbine.
4
+ """
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ def binning_func(
11
+ turbine_data: pd.DataFrame,
12
+ windspeed_label: str,
13
+ power_label: str,
14
+ bin_interval: float = 0.5,
15
+ ) -> pd.DataFrame:
16
+ """Computes per-bin statistics (sample count, median wind speed, median power, MAD).
17
+
18
+ Bins wind speed into integer groups of width ``bin_interval`` using
19
+ ``floor(ws / bin_interval)``, which decouples the bin count from ``bin_interval`` and
20
+ covers all wind speeds without out-of-range drops.
21
+
22
+ Note:
23
+ The MAD is intentionally kept raw (no 1.4826 scaling); ``z_coeff`` in
24
+ ``PowerCurveFiltering`` is therefore an empirical MAD multiplier, not a
25
+ standard-deviation-equivalent z-score.
26
+
27
+ Note:
28
+ Bins with fewer than 3 samples, or whose power has zero spread (MAD == 0), get
29
+ ``pwr_bin_mad = NaN`` so the filtering layer treats them as "undecidable" rather
30
+ than flagging every point (e.g. the rated-power plateau).
31
+
32
+ Args:
33
+ turbine_data: DataFrame containing the ``windspeed_label`` and ``power_label`` columns.
34
+ windspeed_label: Column name of the wind speed.
35
+ power_label: Column name of the active power.
36
+ bin_interval: Wind speed bin width, default 0.5. Must be positive.
37
+
38
+ Returns:
39
+ DataFrame with columns ``windspeed_bin``, ``n``, ``windspeed_bin_median``,
40
+ ``pwr_bin_median``, and ``pwr_bin_mad``.
41
+
42
+ Raises:
43
+ ValueError: If ``bin_interval`` is not positive.
44
+ """
45
+ if bin_interval <= 0:
46
+ raise ValueError("bin_interval must be > 0")
47
+
48
+ ws = turbine_data[windspeed_label]
49
+
50
+ # NaN-safe integer bin label: floor(ws / bin_interval), NA preserved via Int64.
51
+ bin_label = np.floor(ws / bin_interval).astype("Int64")
52
+
53
+ binned = (
54
+ turbine_data.assign(windspeed_bin=bin_label)
55
+ .groupby("windspeed_bin", sort=True)
56
+ .agg(
57
+ n=(power_label, "size"),
58
+ windspeed_bin_median=(windspeed_label, "median"),
59
+ pwr_bin_median=(power_label, "median"),
60
+ pwr_bin_mad=(power_label, lambda x: (x - x.median()).abs().median()),
61
+ )
62
+ .reset_index()
63
+ )
64
+
65
+ # Undecidable bins: too few samples, or zero spread (e.g. rated-power plateau).
66
+ binned.loc[(binned["n"] < 3) | (binned["pwr_bin_mad"] == 0), "pwr_bin_mad"] = np.nan
67
+
68
+ return binned[
69
+ ["windspeed_bin", "n", "windspeed_bin_median", "pwr_bin_median", "pwr_bin_mad"]
70
+ ]
71
+
72
+
73
+ def test_binning_func() -> None:
74
+ """Self-check for ``binning_func`` against hand-computed expectations."""
75
+
76
+ df = pd.DataFrame(
77
+ {
78
+ "ws": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 1.0, 1.1, np.nan],
79
+ "pw": [10, 20, 30, 40, 100, 100, 100, 50, 60, 5],
80
+ }
81
+ )
82
+
83
+ out = binning_func(df, "ws", "pw", bin_interval=0.5)
84
+
85
+ # 输出列与顺序
86
+ assert list(out.columns) == [
87
+ "windspeed_bin",
88
+ "n",
89
+ "windspeed_bin_median",
90
+ "pwr_bin_median",
91
+ "pwr_bin_mad",
92
+ ]
93
+
94
+ # bin 0: ws in [0.0, 0.5) -> n=4, 风速中位 0.25, 功率中位 25, MAD 10
95
+ b0 = out[out["windspeed_bin"] == 0].iloc[0]
96
+ assert b0["n"] == 4
97
+ assert b0["windspeed_bin_median"] == 0.25
98
+ assert b0["pwr_bin_median"] == 25
99
+ assert b0["pwr_bin_mad"] == 10
100
+
101
+ # bin 1: 功率恒定 -> MAD == 0 -> 置 NaN(不判定)
102
+ b1 = out[out["windspeed_bin"] == 1].iloc[0]
103
+ assert b1["n"] == 3
104
+ assert b1["pwr_bin_median"] == 100
105
+ assert pd.isna(b1["pwr_bin_mad"])
106
+
107
+ # bin 2: n=2(<3)-> MAD 置 NaN(不判定)
108
+ b2 = out[out["windspeed_bin"] == 2].iloc[0]
109
+ assert b2["n"] == 2
110
+ assert b2["pwr_bin_median"] == 55
111
+ assert pd.isna(b2["pwr_bin_mad"])
112
+
113
+ # NaN 风速的行被排除在统计之外(n 总和 = 9,而非 10)
114
+ assert out["n"].sum() == 9
115
+
116
+ # bin_interval <= 0 抛出 ValueError
117
+ for bad in (0, -0.5):
118
+ try:
119
+ binning_func(df, "ws", "pw", bin_interval=bad)
120
+ except ValueError:
121
+ pass
122
+ else:
123
+ raise AssertionError(f"bin_interval={bad} 应抛出 ValueError")
124
+
125
+ print("binning_func 自测通过:所有断言均满足")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ test_binning_func()
@@ -0,0 +1,154 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from wtclean.utils.binning_function import binning_func
5
+
6
+
7
+ def estimate_machine_parameters(
8
+ df: pd.DataFrame,
9
+ windspeed_label: str,
10
+ power_label: str,
11
+ bin_interval: float = 0.5,
12
+ min_bin_samples: int = 20,
13
+ plateau_ratio: float = 0.97,
14
+ rated_power_ratio: float = 0.95,
15
+ cutin_power_fraction: float = 0.01,
16
+ consecutive_bins: int = 2,
17
+ ) -> dict[str, float]:
18
+ """从单台风机的 SCADA 风速-功率数据估计切入风速、额定风速与额定功率(三个全部估计)。
19
+
20
+ Args:
21
+ df: 含 ``windspeed_label`` 与 ``power_label`` 列的**单台风机** SCADA DataFrame。
22
+ windspeed_label: 风速列名。
23
+ power_label: 有功功率列名。
24
+ bin_interval: 风速分箱宽度,默认 0.5。
25
+ min_bin_samples: 参与统计的最小 bin 样本数,低于该值的 bin 被丢弃(抗稀疏)。
26
+ plateau_ratio: 判定"额定平台"的比例阈值:bin 中位功率达到全风速
27
+ ``plateau_ratio * 峰值中位功率`` 即视为平台点。
28
+ rated_power_ratio: 额定风速的判定标准:bin 中位功率首次达到
29
+ ``rated_power_ratio * 额定功率`` 所对应的风速。
30
+ cutin_power_fraction: 切入风速的判定标准:bin 中位功率首次达到
31
+ ``cutin_power_fraction * 额定功率`` 所对应的风速(观测口径的有效出力起始风速,
32
+ 非厂商切入风速定义)。
33
+ consecutive_bins: 达标判定的连续 bin 数(≥1),且要求这些 bin 在风速上物理连续
34
+ (相邻 bin 中位风速间隔 ≤ ``bin_interval * 1.1``),避免单个噪声 bin 造成误判。
35
+
36
+ Returns:
37
+ ``{"cut_in_speed": ..., "rated_wind_speed": ..., "rated_power": ...}``,
38
+ 三项全部由数据估计得出。
39
+
40
+ Raises:
41
+ ValueError: 列缺失、数据为空、或数据无法支撑估计(如额定平台无法判定、
42
+ 无法找到连续达标的风速段)。
43
+
44
+ Example:
45
+ >>> params = estimate_machine_parameters(g, "Ws_avg", "P_avg")
46
+ >>> params # {"cut_in_speed": 3.77, "rated_wind_speed": 12.72, "rated_power": 1985.3}
47
+ """
48
+ for col in (windspeed_label, power_label):
49
+ if col not in df.columns:
50
+ raise ValueError(f"DataFrame 缺少列: {col}")
51
+ if df.empty:
52
+ raise ValueError("DataFrame 为空,无法估计参数")
53
+ if bin_interval <= 0:
54
+ raise ValueError(f"bin_interval must be > 0, got {bin_interval}")
55
+ if not 0 < plateau_ratio <= 1:
56
+ raise ValueError(f"plateau_ratio must be in (0, 1], got {plateau_ratio}")
57
+ if not 0 < rated_power_ratio <= 1:
58
+ raise ValueError(
59
+ f"rated_power_ratio must be in (0, 1], got {rated_power_ratio}"
60
+ )
61
+ if not 0 < cutin_power_fraction <= 1:
62
+ raise ValueError(
63
+ f"cutin_power_fraction must be in (0, 1], got {cutin_power_fraction}"
64
+ )
65
+ if consecutive_bins < 1:
66
+ raise ValueError(f"consecutive_bins must be >= 1, got {consecutive_bins}")
67
+
68
+ binned = binning_func(df, windspeed_label, power_label, bin_interval)
69
+ binned = binned[binned["n"] >= min_bin_samples].sort_values("windspeed_bin_median")
70
+ if len(binned) < 3:
71
+ raise ValueError(
72
+ f"样本过少:满足 n>={min_bin_samples} 的风速 bin 仅 {len(binned)} 个,无法估计参数"
73
+ )
74
+
75
+ xs = binned["windspeed_bin_median"].to_numpy(dtype=float)
76
+ ys = binned["pwr_bin_median"].to_numpy(dtype=float)
77
+
78
+ def _sustained_cross(target: float) -> float | None:
79
+ """寻找中位功率曲线首次在风速上连续达到 target 的位置。
80
+
81
+ 连续条件同时要求:
82
+ - 连续 ``consecutive_bins`` 个 bin 的功率均 >= target;
83
+ - 这些 bin 在风速轴上物理连续(相邻中位风速间隔 ≤ bin_interval * 1.1)。
84
+
85
+ 若找不到满足条件的风速段,返回 None。
86
+ """
87
+ cond = ys >= target
88
+ n = len(cond)
89
+
90
+ for i in range(n):
91
+ end = i + consecutive_bins
92
+ if end > n:
93
+ break
94
+
95
+ # 检查窗口内风速是否物理连续
96
+ if not np.all(np.diff(xs[i:end]) <= bin_interval * 1.1):
97
+ continue
98
+
99
+ # 检查窗口内功率是否全部达标
100
+ if not cond[i:end].all():
101
+ continue
102
+
103
+ # 找到首个连续达标段,计算交叉风速
104
+ if i == 0:
105
+ return float(xs[0])
106
+
107
+ x0, y0 = xs[i - 1], ys[i - 1]
108
+ x1, y1 = xs[i], ys[i]
109
+
110
+ # 前一个 bin 与当前 bin 间隔过大时,不做跨间隙插值,直接取当前 bin 中位风速
111
+ if x1 - x0 > bin_interval * 1.1:
112
+ return float(x1)
113
+
114
+ if y1 == y0:
115
+ return float(x1)
116
+
117
+ # 线性插值得到目标功率对应的风速
118
+ return float(x0 + (target - y0) / (y1 - y0) * (x1 - x0))
119
+
120
+ return None
121
+
122
+ # 额定功率:最高平台(>= plateau_ratio * 峰值)上 bin 中位功率的中位数。
123
+ peak = float(np.nanmax(ys))
124
+ plateau = ys >= plateau_ratio * peak
125
+ if not plateau.any():
126
+ raise ValueError("无法从数据中判定额定功率平台")
127
+ rated_power = float(np.median(ys[plateau]))
128
+
129
+ # 额定风速:中位功率首次连续达到 rated_power_ratio * 额定功率。
130
+ crossing = _sustained_cross(rated_power_ratio * rated_power)
131
+ if crossing is None:
132
+ raise ValueError(
133
+ f"无法从功率曲线中估计 rated_wind_speed:"
134
+ f"没有连续 {consecutive_bins} 个有效 bin 达到 "
135
+ f"{rated_power_ratio:.0%} 额定功率"
136
+ )
137
+ rated_wind_speed = crossing
138
+
139
+ # 切入风速:中位功率首次连续达到 cutin_power_fraction * 额定功率。
140
+ # 设置一个最小阈值 1.0 kW,避免绝对零值附近噪声的影响。
141
+ crossing = _sustained_cross(max(cutin_power_fraction * rated_power, 1.0))
142
+ if crossing is None:
143
+ raise ValueError(
144
+ f"无法从功率曲线中估计 cut_in_speed:"
145
+ f"没有连续 {consecutive_bins} 个有效 bin 达到 "
146
+ f"{cutin_power_fraction:.0%} 额定功率"
147
+ )
148
+ cut_in_speed = crossing
149
+
150
+ return {
151
+ "cut_in_speed": round(cut_in_speed, 2),
152
+ "rated_wind_speed": round(rated_wind_speed, 2),
153
+ "rated_power": round(rated_power, 2),
154
+ }
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: wtclean
3
+ Version: 0.1.0
4
+ Summary: Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering
5
+ License: BSD-3-Clause
6
+ Keywords: wind-energy,wind-turbine,scada,power-curve,outlier-detection,data-cleaning
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: License :: OSI Approved :: BSD License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pandas
19
+ Requires-Dist: matplotlib
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: keywords
24
+ Dynamic: license
25
+ Dynamic: license-file
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
29
+
30
+ # wtclean
31
+
32
+ 风电机组 SCADA 功率曲线数据清洗工具:用「迭代分箱 + MAD(中位数绝对偏差)」把**正常运行数据**从原始 SCADA 数据中初筛出来。
33
+
34
+ ## 这个包是做什么的
35
+
36
+ 输入**一台风机**的 SCADA 数据(至少要含风速、有功功率两列,通常为 10 分钟统计记录)。
37
+
38
+ `PowerCurveFiltering.process()` 会把数据按行切分成两份返回:
39
+
40
+ - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
41
+ - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
42
+
43
+ 单台风机的整条清洗流水线分三步:
44
+
45
+ 1. **去停机**:风速 ≥ 切入风速、但功率 ≤ 1 kW 的点(风机已停机)→ 剔除。
46
+ 2. **高风低功率粗洗**:风速 ≥ 额定风速、但功率 < `low_power_ratio × 额定功率` 的点(限电、降载、桨距/故障等非正常发电)→ 剔除。
47
+ 3. **迭代 bin+MAD 精洗**:按 `bin_interval` 把风速分箱,对每箱算功率中位数与 MAD,箱内功率落在 `中位数 ± z_coeff × MAD` 带外的点 → 剔除。剔除后**重新分箱再剔**,重复 `filter_cycle` 轮(每轮带会随数据变干净而收窄)。
48
+
49
+ 几个内置的判定约定(避免误杀正常点):
50
+
51
+ - 箱内样本 < 3,或功率离散度为 0(典型如额定功率平台),该箱**不判定、直接保留**;
52
+ - 风速低于切入风速的点默认保留(近零功率属正常待机);
53
+ - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
54
+
55
+ ## 安装
56
+
57
+ ```bash
58
+ git clone <this-repo>
59
+ cd wtclean
60
+ pip install -r requirements.txt
61
+ pip install .
62
+ ```
63
+
64
+ ## 快速开始(单台风机)
65
+
66
+ ```python
67
+ import pandas as pd
68
+ from wtclean import PowerCurveFiltering
69
+
70
+ df = pd.read_csv("data.csv")
71
+ turbine = df[df["Wind_turbine_name"] == "R80721"] # 取单台风机
72
+
73
+ pc_filter = PowerCurveFiltering(
74
+ windspeed_label="Ws_avg", # 风速列名
75
+ power_label="P_avg", # 有功功率列名
76
+ df=turbine, # 单台风机数据
77
+ cut_in_speed=3.5, # 切入风速(厂商参数,必填)
78
+ rated_wind_speed=14.5, # 额定风速(厂商参数,必填)
79
+ rated_power=2050, # 额定功率 kW(厂商参数,必填)
80
+ low_power_ratio=0.9, # 高风低功率粗洗阈值(默认 0.9)
81
+ bin_interval=0.5, # 风速分箱宽度(默认 0.5)
82
+ z_coeff=2.5, # 正常带宽度系数(默认 2.5)
83
+ filter_cycle=3, # 迭代轮数(默认 3)
84
+ return_fig=False, # 是否保存功率曲线图
85
+ image_path="", # 出图时的完整输出文件路径
86
+ )
87
+
88
+ normal_df, abnormal_df = pc_filter.process()
89
+ ```
90
+
91
+ 运行时会逐轮打印删除情况,例如:
92
+
93
+ ```
94
+ iteration 1: removed 3864 (7.21%), remaining 49724
95
+ iteration 2: removed 1655 (3.33%), remaining 48069
96
+ iteration 3: removed 904 (1.88%), remaining 47165
97
+ ```
98
+
99
+ 每轮删除数也会记录在 `pc_filter.removal_history`(list[int]),方便你审计或画收敛曲线。
100
+
101
+ ## 多台风机
102
+
103
+ `PowerCurveFiltering` 一次只处理一台;多台由调用方 `groupby` 后循环调用:
104
+
105
+ ```python
106
+ from wtclean import PowerCurveFiltering, estimate_machine_parameters
107
+
108
+ results = {}
109
+ for name, group in df.groupby("Wind_turbine_name"):
110
+ params = estimate_machine_parameters(group, "Ws_avg", "P_avg") # 先反推厂商参数
111
+ pcf = PowerCurveFiltering(
112
+ "Ws_avg", "P_avg", group,
113
+ cut_in_speed=params["cut_in_speed"],
114
+ rated_wind_speed=params["rated_wind_speed"],
115
+ rated_power=params["rated_power"],
116
+ )
117
+ results[name] = pcf.process() # (normal_df, abnormal_df)
118
+ ```
119
+
120
+ ## 厂商参数不知道怎么办
121
+
122
+ `cut_in_speed` / `rated_wind_speed` / `rated_power` 是**风机机型物理参数,必填**。若拿不到厂商技术参数,可先从 SCADA 数据反推(`estimate_machine_parameters` 会估计全部三个值),再把结果传给构造函数:
123
+
124
+ ```python
125
+ from wtclean import estimate_machine_parameters
126
+
127
+ params = estimate_machine_parameters(turbine, "Ws_avg", "P_avg")
128
+ # -> {"cut_in_speed": 3.78, "rated_wind_speed": 13.59, "rated_power": 1985.3}
129
+ ```
130
+
131
+ > 注意:反推值来自(通常是 10 分钟统计的)SCADA 数据,是"软"值——例如反推的额定风速约 13 m/s,会低于厂商标称的约 14.5 m/s,因为时间平均会把功率曲线拐点往左拉。生产使用前建议与厂商功率曲线表交叉核对;正式项目优先填厂商标称值。
132
+
133
+ ## 参数说明
134
+
135
+ | 参数 | 默认 | 含义 |
136
+ | --- | --- | --- |
137
+ | `windspeed_label` | — | 风速列名(必填)。 |
138
+ | `power_label` | — | 有功功率列名(必填)。 |
139
+ | `df` | — | **单台**风机的 SCADA DataFrame(必填)。 |
140
+ | `cut_in_speed` | — | 切入风速 m/s(厂商参数,必填)。低于该风速默认保留;停机判定从该风速起算。 |
141
+ | `rated_wind_speed` | — | 额定风速 m/s(厂商参数,必填)。风速 ≥ 它时风机应接近额定功率,是"高风低功率粗洗"的起点。 |
142
+ | `rated_power` | — | 额定功率 kW(厂商参数,必填)。高风区各类比例阈值都以此为基准。 |
143
+ | `low_power_ratio` | `0.9` | 高风低功率粗洗阈值:风速 ≥ 额定风速时,功率 < 该比例×额定功率 即剔除。 |
144
+ | `bin_interval` | `0.5` | 风速分箱宽度 m/s。越小分箱越细(样本少的区段统计越不稳),越大越粗。 |
145
+ | `z_coeff` | `2.5` | 正常带宽度 = `中位数 ± z_coeff × MAD`。见下方调参建议。 |
146
+ | `filter_cycle` | `3` | bin+MAD 迭代精洗的轮数上限。见下方调参建议。 |
147
+ | `return_fig` | `False` | 是否保存一张功率曲线清洗结果散点图(蓝=Normal / 橙=Abnormal)。 |
148
+ | `image_path` | `""` | `return_fig=True` 时输出图片的**完整文件路径**(含文件名,如 `./images/turbine_pc.png`);目录不存在会自动创建。 |
149
+
150
+ ## 调参建议(最终决策权在你)
151
+
152
+ **"保留多少 / 洗得多纯"没有绝对正确的值**,取决于你拿到 `normal_df` 后要干什么——拿去给高阶模型精洗可以放宽一点(尽量保点),指望初筛结果直接用就得收紧。每次调参都是"保点 vs 纯净"的权衡,建议结合输出图与逐轮打印来判断。以下给出方向和量级参考。
153
+
154
+ ### `z_coeff`——正常带多宽(影响最大,决定"删多少")
155
+
156
+ 含义:功率偏离该箱中位数多少个 MAD 算正常。**MAD 用原始值、未乘 1.4826**,所以不能直接当"σ 倍数"读;换算成高斯直觉(原始 MAD ≈ 0.675σ):
157
+
158
+ | z_coeff | 正常带约 | 高斯下保留比例 | 档位 |
159
+ | --- | --- | --- | --- |
160
+ | `2.0` | ±1.35σ | ~82% | 激进(洗得纯,易误删) |
161
+ | `2.5`(默认) | ±1.69σ | ~91% | 居中 |
162
+ | `3.0` | ±2.02σ | ~96% | 偏宽松 |
163
+ | `4.0` | ±2.70σ | ~99% | 很宽松(基本只剔粗洗) |
164
+
165
+ - **越大 → 带越宽 → 保留越多**(正常点误删少,但漏进 `normal_df` 的真异常变多);
166
+ - **越小 → 带越窄 → 删得越多**(`normal_df` 更纯,但可能误删边界正常点)。
167
+
168
+ 实测参考(La Haute Borne,MM82,`filter_cycle=3`):`z_coeff=2.5` 时 abnormal 约 13–15%;`z_coeff=4.0` 时骤降到约 3%(此时 MAD 层每轮只删 ~1%,第 2、3 轮基本空转)。也就是说 `z_coeff=4` 已接近"纯粗洗"。
169
+
170
+ 建议:后续要做 GAM/sigmoid/时间维度精洗时,可把 `z_coeff` 放到 3~4 先把明显离群点去掉;若想让这一层初筛就尽量干净、愿意接受少部分正常点损失,用 2~2.5。
171
+
172
+ ### `filter_cycle`——迭代几轮
173
+
174
+ 机制:第 1 轮删得最多(清掉最明显的离群点),之后每轮重新分箱、带变窄,**删除量快速递减**——后面几轮主要是在不断收窄正常带,**可能开始误删边界正常点**。
175
+
176
+ - 实测各数据集上,多数风机的删除量到第 3~5 轮已降到每轮 <1%(可看逐轮打印确认)。
177
+ - 想要**更保守、尽量保留正常点**:`2` 就够,甚至 `1`(只粗洗 + 单轮 MAD);
178
+ - 想**更彻底**(如训练样本允许损失一部分):`5`,但要警惕过度清洗;
179
+ - 反正每轮都会打印删除量与比例,看到某轮删除已经趋近 0,就说明再加轮次意义不大。
180
+
181
+ ### 其它参数
182
+
183
+ - **`low_power_ratio`**:只影响额定风速以上的"限电/降载"粗洗。想多剔除降载工况就调小(如 0.85),想更保险保留就调大。注意别设太低,否则额定平台上的轻微降载会漏掉。
184
+ - **`bin_interval`**:高风速区样本稀的话可适当调大(如 1.0)提高该区段 MAD 稳定性;低风速区样本极多时调小能更精细。默认 0.5 对多数 10 分钟数据够用。
185
+ - **`cut_in_speed`/`rated_wind_speed`/`rated_power`**:这三个是物理参数,**不要拿来当清洗旋钮调**。设错会直接让粗洗规则失效(比如额定风速设太低会把正常爬坡段误当"高风低功率"整段删掉)。拿不准就反推,再和厂商表核对。
186
+
187
+ ## 出图(`return_fig=True`)
188
+
189
+ 会画一张风速-功率散点图:蓝色 = Normal(`normal_df`),橙色 = Abnormal(`abnormal_df`),图例已标注。图片保存到 `image_path` 指定的完整文件路径。建议每次调参都出一张图,肉眼确认"边界处"是否删得合理。
190
+
191
+ ## 测试
192
+
193
+ ```bash
194
+ python -m unittest discover -s test -v
195
+ ```
196
+
197
+ 当前测试覆盖:正常/异常切分、重复索引不膨胀、粗洗(停机、高风低功率)、非法参数与空数据校验、出图、迭代删除记录。
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ test/test_power_curve_filtering.py
5
+ wtclean/__init__.py
6
+ wtclean.egg-info/PKG-INFO
7
+ wtclean.egg-info/SOURCES.txt
8
+ wtclean.egg-info/dependency_links.txt
9
+ wtclean.egg-info/requires.txt
10
+ wtclean.egg-info/top_level.txt
11
+ wtclean/modules/__init__.py
12
+ wtclean/modules/power_curve_filtering.py
13
+ wtclean/utils/__init__.py
14
+ wtclean/utils/binning_function.py
15
+ wtclean/utils/machine_parameter_estimation.py
@@ -0,0 +1,3 @@
1
+ numpy
2
+ pandas
3
+ matplotlib
@@ -0,0 +1 @@
1
+ wtclean