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 +28 -0
- wtclean-0.1.0/PKG-INFO +197 -0
- wtclean-0.1.0/README.md +168 -0
- wtclean-0.1.0/setup.cfg +4 -0
- wtclean-0.1.0/setup.py +34 -0
- wtclean-0.1.0/test/test_power_curve_filtering.py +118 -0
- wtclean-0.1.0/wtclean/__init__.py +9 -0
- wtclean-0.1.0/wtclean/modules/__init__.py +3 -0
- wtclean-0.1.0/wtclean/modules/power_curve_filtering.py +230 -0
- wtclean-0.1.0/wtclean/utils/__init__.py +4 -0
- wtclean-0.1.0/wtclean/utils/binning_function.py +129 -0
- wtclean-0.1.0/wtclean/utils/machine_parameter_estimation.py +154 -0
- wtclean-0.1.0/wtclean.egg-info/PKG-INFO +197 -0
- wtclean-0.1.0/wtclean.egg-info/SOURCES.txt +15 -0
- wtclean-0.1.0/wtclean.egg-info/dependency_links.txt +1 -0
- wtclean-0.1.0/wtclean.egg-info/requires.txt +3 -0
- wtclean-0.1.0/wtclean.egg-info/top_level.txt +1 -0
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
|
+
当前测试覆盖:正常/异常切分、重复索引不膨胀、粗洗(停机、高风低功率)、非法参数与空数据校验、出图、迭代删除记录。
|
wtclean-0.1.0/README.md
ADDED
|
@@ -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
|
+
当前测试覆盖:正常/异常切分、重复索引不膨胀、粗洗(停机、高风低功率)、非法参数与空数据校验、出图、迭代删除记录。
|
wtclean-0.1.0/setup.cfg
ADDED
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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wtclean
|