wtclean 0.2.6__tar.gz → 0.2.8__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.

Potentially problematic release.


This version of wtclean might be problematic. Click here for more details.

Files changed (29) hide show
  1. wtclean-0.2.8/MANIFEST.in +3 -0
  2. {wtclean-0.2.6/wtclean.egg-info → wtclean-0.2.8}/PKG-INFO +17 -36
  3. {wtclean-0.2.6 → wtclean-0.2.8}/README.md +11 -30
  4. wtclean-0.2.8/requirements.txt +5 -0
  5. {wtclean-0.2.6 → wtclean-0.2.8}/setup.py +11 -3
  6. {wtclean-0.2.6 → wtclean-0.2.8}/test/test_daily_anomaly_detection.py +21 -1
  7. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/daily_anomaly_detection.py +46 -97
  8. {wtclean-0.2.6 → wtclean-0.2.8/wtclean.egg-info}/PKG-INFO +17 -36
  9. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/SOURCES.txt +2 -0
  10. wtclean-0.2.8/wtclean.egg-info/requires.txt +5 -0
  11. wtclean-0.2.6/wtclean.egg-info/requires.txt +0 -5
  12. {wtclean-0.2.6 → wtclean-0.2.8}/LICENSE +0 -0
  13. {wtclean-0.2.6 → wtclean-0.2.8}/setup.cfg +0 -0
  14. {wtclean-0.2.6 → wtclean-0.2.8}/test/test_filters.py +0 -0
  15. {wtclean-0.2.6 → wtclean-0.2.8}/test/test_power_curve.py +0 -0
  16. {wtclean-0.2.6 → wtclean-0.2.8}/test/test_power_curve_filtering.py +0 -0
  17. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/__init__.py +0 -0
  18. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/__init__.py +0 -0
  19. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/power_curve_filtering.py +0 -0
  20. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/__init__.py +0 -0
  21. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/functions.py +0 -0
  22. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/parametric_forms.py +0 -0
  23. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/parametric_optimize.py +0 -0
  24. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/__init__.py +0 -0
  25. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/binning_function.py +0 -0
  26. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/filters.py +0 -0
  27. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/machine_parameter_estimation.py +0 -0
  28. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/dependency_links.txt +0 -0
  29. {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/top_level.txt +0 -0
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ include requirements.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wtclean
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering
5
5
  License: BSD-3-Clause
6
6
  Keywords: wind-energy,wind-turbine,scada,power-curve,outlier-detection,data-cleaning
@@ -14,11 +14,11 @@ Classifier: Topic :: Scientific/Engineering
14
14
  Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: numpy
18
- Requires-Dist: pandas
19
- Requires-Dist: matplotlib
20
- Requires-Dist: scipy
21
- Requires-Dist: pygam
17
+ Requires-Dist: numpy==2.2.6
18
+ Requires-Dist: pandas==2.3.2
19
+ Requires-Dist: matplotlib==3.10.0
20
+ Requires-Dist: scipy==1.16.1
21
+ Requires-Dist: pygam==0.12.0
22
22
  Dynamic: classifier
23
23
  Dynamic: description
24
24
  Dynamic: description-content-type
@@ -42,7 +42,7 @@ Dynamic: summary
42
42
  - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
43
43
  - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
44
44
 
45
- > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",以及逐点打上 `normal/abnormal` **标签并区分异常类型**,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
45
+ > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",并把异常天整体剔除、输出 `normal/abnormal` 两个子集,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
46
46
 
47
47
  单台风机的整条清洗流水线分三步:
48
48
 
@@ -56,10 +56,6 @@ Dynamic: summary
56
56
  - 风速低于切入风速的点默认保留(近零功率属正常待机);
57
57
  - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
58
58
 
59
- ## DailyAnomalyDetector:时间维度异常检测(v2)
60
-
61
- 核心思路(来自 2020 国家电投"绿动未来"大赛 top1 方案):**降维 + 对比**。先把单台风机的正常点压成一条基准功率曲线,再按天(或未来按小时)把每天的运行段压缩成"中位残差",与整机曲线对比——某天整体偏移明显,就把该天判为异常,并给逐点打标签。它比纯 bin+MAD 能抓到**水平聚集 / 限电 / 降载**这类单点统计标不出的异常。
62
-
63
59
  ### 快速开始
64
60
 
65
61
  ```python
@@ -83,35 +79,26 @@ detector = DailyAnomalyDetector(
83
79
 
84
80
  normal_df, abnormal_df = detector.detect()
85
81
 
86
- detector.labels_ # pd.Series:每行一个标签
87
82
  detector.abnormal_days_ # set:被判异常的天(日期)
88
- detector.removal_counts_ # dict:各类异常点数统计
83
+ detector.normal_days_ # set:被判正常的天(日期)
89
84
  ```
90
85
 
91
- ### 输出标签含义
86
+ ### 输出含义
92
87
 
93
- `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠)。每个点的异常类型看 `detector.labels_`:
88
+ `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠):
94
89
 
95
- | 标签 | 含义 |
96
- | --- | --- |
97
- | `normal` | 正常 |
98
- | `nan` | 缺失:风速或功率为 NaN |
99
- | `range` | 越界:功率/风速为负,或功率 > 1.2×额定功率(切出风速以上功率非零也算) |
100
- | `downtime` | 停机:切入风速以上、功率却近零(≤ `downtime_power`) |
101
- | `curtailment` | 限电:额定风速以上、功率 < `low_power_ratio`×额定功率(高风低功率) |
102
- | `temporal` | 时间维度异常:整天的运行段整体偏离基准曲线(限电/降载/停机方向) |
103
- | `residual` | 逐点残差异常:单点相对基准曲线的残差超出该风速 bin 的 `z_coeff`×MAD 带 |
90
+ - **`normal_df`**:正常天内的正常点(粗洗异常点不会进入)。
91
+ - **`abnormal_df`**:其余所有点,包括粗洗异常点、被天级判定标出的异常天、以及样本数异常的天。
104
92
 
105
- 优先级:粗洗四类(nan/range/downtime/curtailment)> `temporal` > `residual` > `normal`,即时间维度与残差只作用于尚未被粗洗标异常的点。
93
+ 天级判定只用一个固定规则:`median ± 3×MAD`(稳健 3σ),不再暴露额外的逐点残差参数。
106
94
 
107
95
  ### 检测流程
108
96
 
109
97
  1. **粗洗掩码**:`nan` / `range` / `downtime` / `curtailment` 直接判异常,不参与后续建模。
110
98
  2. **整机基准曲线**:用 `base_method`(默认 `gam` 单调 GAM,也可 `logistic_5_parametric` / `iec`)在粗洗后的点上拟合。
111
99
  3. **残差**:`resid = power - base(wind_speed)`。
112
- 4. **天级判定**:每天"切入风速以上"点的中位残差 = 该天系统性偏移;跨天用 `median + day_z_coeff×MAD` 自适应阈值标异常天(默认只标"低于曲线"方向)。
113
- 5. **逐点残差 bin-MAD 精洗**:按 `bin_width` 风速分箱,对残差做 `z_coeff×MAD` 带外判定(异方差)。
114
- 6. 输出 `(normal_df, abnormal_df)`。
100
+ 4. **天级判定**:按天聚合中位残差和样本数;先用 `median ± 3×MAD` 过滤样本数异常的天,再对剩余天的中位残差用 `median ± 3×MAD` 判正常天/异常天。
101
+ 5. 输出 `(normal_df, abnormal_df)`。
115
102
 
116
103
  ### 参数说明
117
104
 
@@ -123,20 +110,14 @@ detector.removal_counts_ # dict:各类异常点数统计
123
110
  | `cut_out_speed` | `None` | 切出风速 m/s;给定后切出风速以上功率归零视为正常停机,未给则把高风零功率当停机异常。 |
124
111
  | `low_power_ratio` | `0.9` | 限电粗洗阈值:风速 ≥ 额定风速时功率 < 该比例×额定功率即判限电。 |
125
112
  | `downtime_power` | `1.0` | 功率 ≤ 该值(kW)且风速在切入以上视为停机。 |
126
- | `bin_width` | `0.5` | 残差分箱宽度 m/s。 |
127
- | `min_day_points` | `10` | 参与天级判定的最少样本数(每天约 144 点,正常数据下几乎不触发)。 |
128
- | `min_bin_points` | `3` | 残差 bin 参与判定的最少样本数(同上,兜底用)。 |
129
- | `z_coeff` | `3.5` | 逐点残差 MAD 倍数,控制残差精洗的**误杀率**:越大保留越多、误杀越少。 |
130
- | `day_z_coeff` | `2.5` | 天级分数 MAD 倍数,控制"整天判异常"的松紧;真实数据上天级偏移无天然分界、标出天数随该值平滑变化,建议按站点校准。 |
131
113
  | `base_method` | `"gam"` | 基准曲线后端:`gam`(默认,单调 GAM)/ `logistic_5_parametric` / `iec`。 |
132
114
  | `**base_kwargs` | — | 透传给 `fit_power_curve` 的额外参数。 |
133
115
 
134
116
  ### 调参建议
135
117
 
136
- - **`z_coeff`**:控制逐点残差精洗的误杀率。合成数据 + 真实数据(La Haute Borne R80721)一致显示 `2.5` 偏激进(残差点占比约 9.6%),故默认 `3.5`(约 4%);若更看重"少动正常点"可放宽到 `4.0`(约 3%)。
137
- - **`day_z_coeff`**:**不是稳健旋钮**,需按站点校准。合成数据(正常天无系统偏移)上 2.0–6.0 结果一致,但真实数据(La Haute Borne R80721)上天级中位残差呈重尾连续分布(中心 0 kW、MAD 12 kW、尾部 -30~-68 kW),标出的异常天数随它平滑变化:`2.0→38 天`、`2.5→23 天`、`3.0→14 天`、`4.0→2 天`。建议结合已知干净时段或可视化 QC 选定,不要指望换个站点仍适用。
118
+ - **天级阈值固定为 `median ± 3×MAD`**,不再暴露额外参数;需要更松/更紧时直接改 `detect()` 里的 `k = 3.0`。
138
119
  - **`base_method`**:默认 `gam` 带单调约束 + 网格搜样条数,无需调;`logistic_5_parametric` 的默认 bounds 假设 1.2–1.8 MW,其它机型需显式传 bounds。
139
- - 其它参数(`bin_width`/`min_day_points`/`min_bin_points`)对结果不敏感,正常数据量下可保持默认。
120
+ - 每天样本数也按 `median ± 3×MAD` 过滤;若某些天只有个别点,这些天会整体进入 `abnormal_df`。这符合"下游只用 `normal_df`、宁可多删"的默认取舍。
140
121
 
141
122
  ## 安装
142
123
 
@@ -11,7 +11,7 @@
11
11
  - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
12
12
  - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
13
13
 
14
- > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",以及逐点打上 `normal/abnormal` **标签并区分异常类型**,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
14
+ > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",并把异常天整体剔除、输出 `normal/abnormal` 两个子集,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
15
15
 
16
16
  单台风机的整条清洗流水线分三步:
17
17
 
@@ -25,10 +25,6 @@
25
25
  - 风速低于切入风速的点默认保留(近零功率属正常待机);
26
26
  - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
27
27
 
28
- ## DailyAnomalyDetector:时间维度异常检测(v2)
29
-
30
- 核心思路(来自 2020 国家电投"绿动未来"大赛 top1 方案):**降维 + 对比**。先把单台风机的正常点压成一条基准功率曲线,再按天(或未来按小时)把每天的运行段压缩成"中位残差",与整机曲线对比——某天整体偏移明显,就把该天判为异常,并给逐点打标签。它比纯 bin+MAD 能抓到**水平聚集 / 限电 / 降载**这类单点统计标不出的异常。
31
-
32
28
  ### 快速开始
33
29
 
34
30
  ```python
@@ -52,35 +48,26 @@ detector = DailyAnomalyDetector(
52
48
 
53
49
  normal_df, abnormal_df = detector.detect()
54
50
 
55
- detector.labels_ # pd.Series:每行一个标签
56
51
  detector.abnormal_days_ # set:被判异常的天(日期)
57
- detector.removal_counts_ # dict:各类异常点数统计
52
+ detector.normal_days_ # set:被判正常的天(日期)
58
53
  ```
59
54
 
60
- ### 输出标签含义
55
+ ### 输出含义
61
56
 
62
- `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠)。每个点的异常类型看 `detector.labels_`:
57
+ `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠):
63
58
 
64
- | 标签 | 含义 |
65
- | --- | --- |
66
- | `normal` | 正常 |
67
- | `nan` | 缺失:风速或功率为 NaN |
68
- | `range` | 越界:功率/风速为负,或功率 > 1.2×额定功率(切出风速以上功率非零也算) |
69
- | `downtime` | 停机:切入风速以上、功率却近零(≤ `downtime_power`) |
70
- | `curtailment` | 限电:额定风速以上、功率 < `low_power_ratio`×额定功率(高风低功率) |
71
- | `temporal` | 时间维度异常:整天的运行段整体偏离基准曲线(限电/降载/停机方向) |
72
- | `residual` | 逐点残差异常:单点相对基准曲线的残差超出该风速 bin 的 `z_coeff`×MAD 带 |
59
+ - **`normal_df`**:正常天内的正常点(粗洗异常点不会进入)。
60
+ - **`abnormal_df`**:其余所有点,包括粗洗异常点、被天级判定标出的异常天、以及样本数异常的天。
73
61
 
74
- 优先级:粗洗四类(nan/range/downtime/curtailment)> `temporal` > `residual` > `normal`,即时间维度与残差只作用于尚未被粗洗标异常的点。
62
+ 天级判定只用一个固定规则:`median ± 3×MAD`(稳健 3σ),不再暴露额外的逐点残差参数。
75
63
 
76
64
  ### 检测流程
77
65
 
78
66
  1. **粗洗掩码**:`nan` / `range` / `downtime` / `curtailment` 直接判异常,不参与后续建模。
79
67
  2. **整机基准曲线**:用 `base_method`(默认 `gam` 单调 GAM,也可 `logistic_5_parametric` / `iec`)在粗洗后的点上拟合。
80
68
  3. **残差**:`resid = power - base(wind_speed)`。
81
- 4. **天级判定**:每天"切入风速以上"点的中位残差 = 该天系统性偏移;跨天用 `median + day_z_coeff×MAD` 自适应阈值标异常天(默认只标"低于曲线"方向)。
82
- 5. **逐点残差 bin-MAD 精洗**:按 `bin_width` 风速分箱,对残差做 `z_coeff×MAD` 带外判定(异方差)。
83
- 6. 输出 `(normal_df, abnormal_df)`。
69
+ 4. **天级判定**:按天聚合中位残差和样本数;先用 `median ± 3×MAD` 过滤样本数异常的天,再对剩余天的中位残差用 `median ± 3×MAD` 判正常天/异常天。
70
+ 5. 输出 `(normal_df, abnormal_df)`。
84
71
 
85
72
  ### 参数说明
86
73
 
@@ -92,20 +79,14 @@ detector.removal_counts_ # dict:各类异常点数统计
92
79
  | `cut_out_speed` | `None` | 切出风速 m/s;给定后切出风速以上功率归零视为正常停机,未给则把高风零功率当停机异常。 |
93
80
  | `low_power_ratio` | `0.9` | 限电粗洗阈值:风速 ≥ 额定风速时功率 < 该比例×额定功率即判限电。 |
94
81
  | `downtime_power` | `1.0` | 功率 ≤ 该值(kW)且风速在切入以上视为停机。 |
95
- | `bin_width` | `0.5` | 残差分箱宽度 m/s。 |
96
- | `min_day_points` | `10` | 参与天级判定的最少样本数(每天约 144 点,正常数据下几乎不触发)。 |
97
- | `min_bin_points` | `3` | 残差 bin 参与判定的最少样本数(同上,兜底用)。 |
98
- | `z_coeff` | `3.5` | 逐点残差 MAD 倍数,控制残差精洗的**误杀率**:越大保留越多、误杀越少。 |
99
- | `day_z_coeff` | `2.5` | 天级分数 MAD 倍数,控制"整天判异常"的松紧;真实数据上天级偏移无天然分界、标出天数随该值平滑变化,建议按站点校准。 |
100
82
  | `base_method` | `"gam"` | 基准曲线后端:`gam`(默认,单调 GAM)/ `logistic_5_parametric` / `iec`。 |
101
83
  | `**base_kwargs` | — | 透传给 `fit_power_curve` 的额外参数。 |
102
84
 
103
85
  ### 调参建议
104
86
 
105
- - **`z_coeff`**:控制逐点残差精洗的误杀率。合成数据 + 真实数据(La Haute Borne R80721)一致显示 `2.5` 偏激进(残差点占比约 9.6%),故默认 `3.5`(约 4%);若更看重"少动正常点"可放宽到 `4.0`(约 3%)。
106
- - **`day_z_coeff`**:**不是稳健旋钮**,需按站点校准。合成数据(正常天无系统偏移)上 2.0–6.0 结果一致,但真实数据(La Haute Borne R80721)上天级中位残差呈重尾连续分布(中心 0 kW、MAD 12 kW、尾部 -30~-68 kW),标出的异常天数随它平滑变化:`2.0→38 天`、`2.5→23 天`、`3.0→14 天`、`4.0→2 天`。建议结合已知干净时段或可视化 QC 选定,不要指望换个站点仍适用。
87
+ - **天级阈值固定为 `median ± 3×MAD`**,不再暴露额外参数;需要更松/更紧时直接改 `detect()` 里的 `k = 3.0`。
107
88
  - **`base_method`**:默认 `gam` 带单调约束 + 网格搜样条数,无需调;`logistic_5_parametric` 的默认 bounds 假设 1.2–1.8 MW,其它机型需显式传 bounds。
108
- - 其它参数(`bin_width`/`min_day_points`/`min_bin_points`)对结果不敏感,正常数据量下可保持默认。
89
+ - 每天样本数也按 `median ± 3×MAD` 过滤;若某些天只有个别点,这些天会整体进入 `abnormal_df`。这符合"下游只用 `normal_df`、宁可多删"的默认取舍。
109
90
 
110
91
  ## 安装
111
92
 
@@ -0,0 +1,5 @@
1
+ numpy==2.2.6
2
+ pandas==2.3.2
3
+ matplotlib==3.10.0
4
+ scipy==1.16.1
5
+ pygam==0.12.0
@@ -1,11 +1,15 @@
1
+ import os
2
+
1
3
  import setuptools
2
4
 
3
- with open("README.md", "r", encoding="utf-8") as fh:
5
+ HERE = os.path.dirname(os.path.abspath(__file__))
6
+
7
+ with open(os.path.join(HERE, "README.md"), "r", encoding="utf-8") as fh:
4
8
  long_description = fh.read()
5
9
 
6
10
  setuptools.setup(
7
11
  name="wtclean",
8
- version="0.2.6",
12
+ version="0.2.8",
9
13
  description="Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering",
10
14
  long_description=long_description,
11
15
  long_description_content_type="text/markdown",
@@ -22,7 +26,11 @@ setuptools.setup(
22
26
  ],
23
27
  packages=setuptools.find_packages(exclude=["test", "examples"]),
24
28
  python_requires=">=3.10",
25
- install_requires=["numpy", "pandas", "matplotlib", "scipy", "pygam"],
29
+ install_requires=[
30
+ line.strip()
31
+ for line in open(os.path.join(HERE, "requirements.txt"), "r", encoding="utf-8")
32
+ if line.strip() and not line.strip().startswith("#")
33
+ ],
26
34
  keywords=[
27
35
  "wind-energy",
28
36
  "wind-turbine",
@@ -84,7 +84,27 @@ class TestDailyAnomalyDetector(unittest.TestCase):
84
84
  with self.assertRaises(ValueError):
85
85
  _det(df, low_power_ratio=1.5)
86
86
  with self.assertRaises(ValueError):
87
- _det(df, bin_width=0)
87
+ DailyAnomalyDetector(
88
+ "ws", "pw", "ts", df,
89
+ cut_in_speed=0.0, rated_wind_speed=RATED_WS, rated_power=RATED_PWR
90
+ )
91
+
92
+ def test_output_columns(self):
93
+ df, _ = _synth()
94
+ normal, abnormal = _det(df).detect()
95
+ self.assertEqual(list(normal.columns), list(df.columns))
96
+ self.assertEqual(list(abnormal.columns), list(df.columns))
97
+ for frame in (normal, abnormal):
98
+ for col in ("_row_id", "_date", "_resid"):
99
+ self.assertNotIn(col, frame.columns)
100
+
101
+ def test_single_day_not_all_abnormal(self):
102
+ df, _ = _synth(days=1)
103
+ det = _det(df)
104
+ normal, abnormal = det.detect()
105
+ self.assertGreater(len(normal), 0)
106
+ self.assertEqual(len(normal) + len(abnormal), len(df))
107
+ self.assertEqual(det.abnormal_days_, set())
88
108
 
89
109
  def test_auto_estimate_params(self):
90
110
  df, _ = _synth()
@@ -3,7 +3,7 @@
3
3
 
4
4
  对单台风机:先用整机数据拟合基准功率曲线,再按天统计该天的点相对基准曲线的
5
5
  系统性偏移,从而把"水平聚集 / 限电 / 降载"这类单点统计方法标不出的异常天揪出来,
6
- 并把异常天内的点整体标记为异常。最终仍输出逐点 normal / abnormal 标签。
6
+ 并把异常天内的点整体标记为异常。最终输出 normal / abnormal 两个数据子集。
7
7
  """
8
8
 
9
9
  import numpy as np
@@ -25,14 +25,8 @@ class DailyAnomalyDetector:
25
25
  rated_wind_speed: 额定风速;缺省自动反推。
26
26
  rated_power: 额定功率;缺省自动反推。
27
27
  cut_out_speed: 切出风速(可选);给定后切出风速以上功率归零视为正常停机。
28
- low_power_ratio: 高风区功率低于该比例即视为限电/高风低功率异常。
28
+ low_power_ratio: 风速达到额定风速后,功率低于该比例×额定功率即视为限电异常。
29
29
  downtime_power: 功率低于该值且风速在切入以上视为停机异常。
30
- bin_width: 残差分箱宽度。
31
- min_day_points: 参与天级判定的最少样本数。
32
- min_bin_points: 残差 bin 参与判定的最少样本数。
33
- z_coeff: 逐点残差 MAD 倍数(控制逐点残差精洗的误杀率,越大保留越多)。
34
- day_z_coeff: 天级分数 MAD 倍数(控制"整天系统性偏移"判定的松紧;真实数据上天级
35
- 偏移呈重尾连续分布、无天然分界,标出天数随该值平滑变化,建议按站点校准)。
36
30
  base_method: 基准曲线后端(gam / logistic / iec)。
37
31
  """
38
32
 
@@ -42,19 +36,12 @@ class DailyAnomalyDetector:
42
36
  power_label: str,
43
37
  timestamp_label: str,
44
38
  df: pd.DataFrame,
45
- #
46
39
  cut_in_speed: float | None = None,
47
40
  rated_wind_speed: float | None = None,
48
41
  rated_power: float | None = None,
49
42
  cut_out_speed: float | None = None,
50
- #
51
43
  low_power_ratio: float = 0.9,
52
44
  downtime_power: float = 1.0,
53
- bin_width: float = 0.5,
54
- min_day_points: int = 10,
55
- min_bin_points: int = 3,
56
- z_coeff: float = 3.5,
57
- day_z_coeff: float = 2.5,
58
45
  base_method: str = "gam",
59
46
  **base_kwargs,
60
47
  ) -> None:
@@ -102,34 +89,24 @@ class DailyAnomalyDetector:
102
89
  raise ValueError(
103
90
  f"low_power_ratio must be in (0, 1], got {low_power_ratio}"
104
91
  )
105
- if bin_width <= 0:
106
- raise ValueError(f"bin_width must be > 0, got {bin_width}")
107
- if z_coeff <= 0 or day_z_coeff <= 0:
108
- raise ValueError("z_coeff and day_z_coeff must be > 0")
109
- if min_day_points < 1 or min_bin_points < 1:
110
- raise ValueError("min_day_points and min_bin_points must be >= 1")
111
92
 
112
93
  self.windspeed_label = windspeed_label
113
94
  self.power_label = power_label
114
95
  self.timestamp_label = timestamp_label
115
96
  self.df = df
116
97
  self.cut_out_speed = cut_out_speed
117
- self.low_power_ratio = low_power_ratio
118
98
  self.downtime_power = downtime_power
119
- self.bin_width = bin_width
120
- self.min_day_points = min_day_points
121
- self.min_bin_points = min_bin_points
122
- self.z_coeff = z_coeff
123
- self.day_z_coeff = day_z_coeff
99
+ self.low_power_ratio = low_power_ratio
100
+
124
101
  self.base_method = base_method
125
102
  self.base_kwargs = base_kwargs
126
103
 
127
104
  def detect(self) -> tuple[pd.DataFrame, pd.DataFrame]:
128
- """运行天维度 + 残差检测,返回 ``(normal_df, abnormal_df)``。
105
+ """运行天级检测,返回 ``(normal_df, abnormal_df)``。
129
106
 
130
107
  Returns:
131
- 按行划分原始 ``df`` 的正常 / 异常两个子集;异常类型可通过
132
- ``self.labels_`` 与 ``self.abnormal_days_`` 查看。
108
+ 按行划分原始 ``df`` 的正常 / 异常两个子集;被判异常的天可通过
109
+ ``self.abnormal_days_`` 查看。
133
110
  """
134
111
  work = self.df.copy()
135
112
  work["_row_id"] = np.arange(len(work))
@@ -173,6 +150,11 @@ class DailyAnomalyDetector:
173
150
  analysis_mask = (~nan_mask) & (~range_mask) & (~downtime_mask) & (~curtail_mask)
174
151
  analysis_mask &= ws <= cut_out_eff
175
152
 
153
+ if not analysis_mask.any():
154
+ self.normal_days_ = set()
155
+ self.abnormal_days_ = set(work["_date"].dropna())
156
+ return self.df.iloc[0:0].copy(), self.df.copy()
157
+
176
158
  # ---- 整机基准曲线 ----
177
159
  self.base_curve_ = fit_power_curve(
178
160
  ws[analysis_mask],
@@ -181,75 +163,42 @@ class DailyAnomalyDetector:
181
163
  **self.base_kwargs,
182
164
  )
183
165
 
184
- resid = np.full_like(pw, np.nan, dtype=float)
185
- finite = np.isfinite(ws) & np.isfinite(pw)
186
- resid[finite] = pw[finite] - self.base_curve_(ws[finite])
187
- work["_resid"] = resid
188
-
189
- # 逐点标签:normal=正常;其余为各粗洗异常类型(对应上面的掩码)
190
- labels = pd.Series("normal", index=work.index, dtype=object)
191
- labels[nan_mask] = "nan" # 缺失:风速/功率为 NaN
192
- labels[range_mask] = "range" # 越界:数值超出物理合理范围
193
- labels[downtime_mask] = "downtime" # 停机:切入风速以上却功率近零
194
- labels[curtail_mask] = "curtailment" # 限电:额定风速以上功率远低于额定
195
-
196
- clean = work[analysis_mask].copy()
197
-
198
- # ---- 天级判定:该天运行段(切入以上)的中位残差 = 系统性偏移 ----
199
- op = clean[clean[self.windspeed_label] >= self.cut_in_speed]
200
- day_stats = op.groupby("_date")["_resid"].agg(median="median", n="size")
201
- day_stats = day_stats[day_stats["n"] >= self.min_day_points]
202
-
203
- self.day_scores_ = day_stats
204
- if len(day_stats) >= 3:
205
- center = day_stats["median"].median()
206
- mad = (day_stats["median"] - center).abs().median()
207
- lo = center - self.day_z_coeff * mad
208
- abnormal_days = set(day_stats.index[day_stats["median"] < lo].tolist())
209
- else:
210
- lo = np.nan
211
- abnormal_days = set()
212
-
213
- self.abnormal_days_ = abnormal_days
214
- self.day_threshold_ = lo
215
-
216
- # ---- 逐点残差 bin-MAD 精洗(异方差)----
217
- clean = clean.assign(
218
- _bin=np.floor(clean[self.windspeed_label] / self.bin_width).astype("Int64")
166
+ clean_df = work[analysis_mask].copy()
167
+ clean_df["_resid"] = clean_df[self.power_label] - self.base_curve_(
168
+ clean_df[self.windspeed_label]
219
169
  )
220
- bin_stats = (
221
- clean.groupby("_bin")["_resid"]
222
- .agg(n="size", med="median", mad=lambda x: (x - x.median()).abs().median())
223
- .reset_index()
170
+
171
+ # ---- 天级判定:每天中位残差 + 每天点数都用 median ± 3×MAD ----
172
+ k = 3.0
173
+ raw_day_stats = clean_df.groupby("_date")["_resid"].agg(
174
+ median="median", n="size"
224
175
  )
225
- bin_stats.loc[
226
- (bin_stats["n"] < self.min_bin_points) | (bin_stats["mad"] == 0), "mad"
227
- ] = np.nan
228
- clean = clean.merge(bin_stats[["_bin", "med", "mad"]], on="_bin", how="left")
229
-
230
- resid_outlier = (
231
- (clean["_resid"] - clean["med"]).abs() > self.z_coeff * clean["mad"]
232
- ) & clean["mad"].notna()
233
-
234
- resid_outlier_ids = clean.loc[resid_outlier, "_row_id"].tolist()
235
- temporal_ids = clean.loc[clean["_date"].isin(abnormal_days), "_row_id"].tolist()
236
-
237
- # temporal:时间维度异常——整天的运行段整体偏离基准曲线(限电/降载/停机方向),
238
- # 该天内的点整体标为异常(单点看可能落在正常带内,只有整条天曲线对比才现形)
239
- labels.loc[work["_row_id"].isin(set(temporal_ids))] = "temporal"
240
- # residual:逐点残差异常——单个点相对基准曲线的残差超出该风速 bin 的
241
- # z_coeff×MAD 带外(异方差离群点),仅在尚未被标记的点上生效
242
- labels.loc[
243
- work["_row_id"].isin(set(resid_outlier_ids)) & (labels == "normal")
244
- ] = "residual"
245
-
246
- self.labels_ = labels
247
- self.removal_counts_ = labels[labels != "normal"].value_counts().to_dict()
248
-
249
- normal_df = work[labels == "normal"].drop(
250
- columns=["_row_id", "_date", "_resid"]
176
+
177
+ median_n = raw_day_stats["n"].median()
178
+ mad_n = (raw_day_stats["n"] - median_n).abs().median()
179
+ count_mask = raw_day_stats["n"].between(
180
+ median_n - k * mad_n, median_n + k * mad_n
251
181
  )
252
- abnormal_df = work[labels != "normal"].drop(
253
- columns=["_row_id", "_date", "_resid"]
182
+ day_stats = raw_day_stats.loc[count_mask]
183
+
184
+ if day_stats.empty:
185
+ self.normal_days_ = set()
186
+ self.abnormal_days_ = set(work["_date"].dropna())
187
+ return self.df.iloc[0:0].copy(), self.df.copy()
188
+
189
+ median_resid = day_stats["median"].median()
190
+ mad_resid = (day_stats["median"] - median_resid).abs().median()
191
+ normal_mask = day_stats["median"].between(
192
+ median_resid - k * mad_resid, median_resid + k * mad_resid
254
193
  )
194
+ normal_dates = set(day_stats.index[normal_mask])
195
+ self.normal_days_ = normal_dates
196
+ self.abnormal_days_ = set(work["_date"].dropna()) - normal_dates
197
+
198
+ normal_row_ids = clean_df.loc[
199
+ clean_df["_date"].isin(normal_dates), "_row_id"
200
+ ]
201
+ is_normal = work["_row_id"].isin(normal_row_ids)
202
+ normal_df = work.loc[is_normal, self.df.columns].copy()
203
+ abnormal_df = work.loc[~is_normal, self.df.columns].copy()
255
204
  return normal_df, abnormal_df
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wtclean
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Wind turbine SCADA power-curve data cleaning via iterative bin + MAD filtering
5
5
  License: BSD-3-Clause
6
6
  Keywords: wind-energy,wind-turbine,scada,power-curve,outlier-detection,data-cleaning
@@ -14,11 +14,11 @@ Classifier: Topic :: Scientific/Engineering
14
14
  Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: numpy
18
- Requires-Dist: pandas
19
- Requires-Dist: matplotlib
20
- Requires-Dist: scipy
21
- Requires-Dist: pygam
17
+ Requires-Dist: numpy==2.2.6
18
+ Requires-Dist: pandas==2.3.2
19
+ Requires-Dist: matplotlib==3.10.0
20
+ Requires-Dist: scipy==1.16.1
21
+ Requires-Dist: pygam==0.12.0
22
22
  Dynamic: classifier
23
23
  Dynamic: description
24
24
  Dynamic: description-content-type
@@ -42,7 +42,7 @@ Dynamic: summary
42
42
  - **`normal_df`**:风机**正常运行**的数据点——功率落在该风速区间应有的功率带内。**后续做功率曲线建模、工况分析、机器学习等时主要用这一份**。
43
43
  - **`abnormal_df`**:其余所有被剔除的点。⚠️ **它不是"严格异常集"**,而是混了多种情况:停机、故障、限电/降载、桨距控制等**其它工况**、以及真正的传感器/数据异常。想区分异常类型,需要后续用更高阶方法(如 GAM/sigmoid 拟合、时间维度分析)处理。
44
44
 
45
- > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",以及逐点打上 `normal/abnormal` **标签并区分异常类型**,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
45
+ > 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常",并把异常天整体剔除、输出 `normal/abnormal` 两个子集,请用 [DailyAnomalyDetector](#dailyanomalydetector时间维度异常检测v2)(v2,时间维度 + 基准曲线对比)。`PowerCurveFiltering`(v1)是无模型的 bin+MAD 粗洗,适合作为对照或初筛。
46
46
 
47
47
  单台风机的整条清洗流水线分三步:
48
48
 
@@ -56,10 +56,6 @@ Dynamic: summary
56
56
  - 风速低于切入风速的点默认保留(近零功率属正常待机);
57
57
  - 恒定功率的"水平聚集"类异常(如某段时间被固定在某一功率)**bin+MAD 识别不出来**,这是本方法的能力上限,应交给后续更精细的方案。
58
58
 
59
- ## DailyAnomalyDetector:时间维度异常检测(v2)
60
-
61
- 核心思路(来自 2020 国家电投"绿动未来"大赛 top1 方案):**降维 + 对比**。先把单台风机的正常点压成一条基准功率曲线,再按天(或未来按小时)把每天的运行段压缩成"中位残差",与整机曲线对比——某天整体偏移明显,就把该天判为异常,并给逐点打标签。它比纯 bin+MAD 能抓到**水平聚集 / 限电 / 降载**这类单点统计标不出的异常。
62
-
63
59
  ### 快速开始
64
60
 
65
61
  ```python
@@ -83,35 +79,26 @@ detector = DailyAnomalyDetector(
83
79
 
84
80
  normal_df, abnormal_df = detector.detect()
85
81
 
86
- detector.labels_ # pd.Series:每行一个标签
87
82
  detector.abnormal_days_ # set:被判异常的天(日期)
88
- detector.removal_counts_ # dict:各类异常点数统计
83
+ detector.normal_days_ # set:被判正常的天(日期)
89
84
  ```
90
85
 
91
- ### 输出标签含义
86
+ ### 输出含义
92
87
 
93
- `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠)。每个点的异常类型看 `detector.labels_`:
88
+ `detect()` 返回 `(normal_df, abnormal_df)` 两个 DataFrame,按行切分原始 `df`(`normal_df + abnormal_df == 原始 df`,且索引不交叠):
94
89
 
95
- | 标签 | 含义 |
96
- | --- | --- |
97
- | `normal` | 正常 |
98
- | `nan` | 缺失:风速或功率为 NaN |
99
- | `range` | 越界:功率/风速为负,或功率 > 1.2×额定功率(切出风速以上功率非零也算) |
100
- | `downtime` | 停机:切入风速以上、功率却近零(≤ `downtime_power`) |
101
- | `curtailment` | 限电:额定风速以上、功率 < `low_power_ratio`×额定功率(高风低功率) |
102
- | `temporal` | 时间维度异常:整天的运行段整体偏离基准曲线(限电/降载/停机方向) |
103
- | `residual` | 逐点残差异常:单点相对基准曲线的残差超出该风速 bin 的 `z_coeff`×MAD 带 |
90
+ - **`normal_df`**:正常天内的正常点(粗洗异常点不会进入)。
91
+ - **`abnormal_df`**:其余所有点,包括粗洗异常点、被天级判定标出的异常天、以及样本数异常的天。
104
92
 
105
- 优先级:粗洗四类(nan/range/downtime/curtailment)> `temporal` > `residual` > `normal`,即时间维度与残差只作用于尚未被粗洗标异常的点。
93
+ 天级判定只用一个固定规则:`median ± 3×MAD`(稳健 3σ),不再暴露额外的逐点残差参数。
106
94
 
107
95
  ### 检测流程
108
96
 
109
97
  1. **粗洗掩码**:`nan` / `range` / `downtime` / `curtailment` 直接判异常,不参与后续建模。
110
98
  2. **整机基准曲线**:用 `base_method`(默认 `gam` 单调 GAM,也可 `logistic_5_parametric` / `iec`)在粗洗后的点上拟合。
111
99
  3. **残差**:`resid = power - base(wind_speed)`。
112
- 4. **天级判定**:每天"切入风速以上"点的中位残差 = 该天系统性偏移;跨天用 `median + day_z_coeff×MAD` 自适应阈值标异常天(默认只标"低于曲线"方向)。
113
- 5. **逐点残差 bin-MAD 精洗**:按 `bin_width` 风速分箱,对残差做 `z_coeff×MAD` 带外判定(异方差)。
114
- 6. 输出 `(normal_df, abnormal_df)`。
100
+ 4. **天级判定**:按天聚合中位残差和样本数;先用 `median ± 3×MAD` 过滤样本数异常的天,再对剩余天的中位残差用 `median ± 3×MAD` 判正常天/异常天。
101
+ 5. 输出 `(normal_df, abnormal_df)`。
115
102
 
116
103
  ### 参数说明
117
104
 
@@ -123,20 +110,14 @@ detector.removal_counts_ # dict:各类异常点数统计
123
110
  | `cut_out_speed` | `None` | 切出风速 m/s;给定后切出风速以上功率归零视为正常停机,未给则把高风零功率当停机异常。 |
124
111
  | `low_power_ratio` | `0.9` | 限电粗洗阈值:风速 ≥ 额定风速时功率 < 该比例×额定功率即判限电。 |
125
112
  | `downtime_power` | `1.0` | 功率 ≤ 该值(kW)且风速在切入以上视为停机。 |
126
- | `bin_width` | `0.5` | 残差分箱宽度 m/s。 |
127
- | `min_day_points` | `10` | 参与天级判定的最少样本数(每天约 144 点,正常数据下几乎不触发)。 |
128
- | `min_bin_points` | `3` | 残差 bin 参与判定的最少样本数(同上,兜底用)。 |
129
- | `z_coeff` | `3.5` | 逐点残差 MAD 倍数,控制残差精洗的**误杀率**:越大保留越多、误杀越少。 |
130
- | `day_z_coeff` | `2.5` | 天级分数 MAD 倍数,控制"整天判异常"的松紧;真实数据上天级偏移无天然分界、标出天数随该值平滑变化,建议按站点校准。 |
131
113
  | `base_method` | `"gam"` | 基准曲线后端:`gam`(默认,单调 GAM)/ `logistic_5_parametric` / `iec`。 |
132
114
  | `**base_kwargs` | — | 透传给 `fit_power_curve` 的额外参数。 |
133
115
 
134
116
  ### 调参建议
135
117
 
136
- - **`z_coeff`**:控制逐点残差精洗的误杀率。合成数据 + 真实数据(La Haute Borne R80721)一致显示 `2.5` 偏激进(残差点占比约 9.6%),故默认 `3.5`(约 4%);若更看重"少动正常点"可放宽到 `4.0`(约 3%)。
137
- - **`day_z_coeff`**:**不是稳健旋钮**,需按站点校准。合成数据(正常天无系统偏移)上 2.0–6.0 结果一致,但真实数据(La Haute Borne R80721)上天级中位残差呈重尾连续分布(中心 0 kW、MAD 12 kW、尾部 -30~-68 kW),标出的异常天数随它平滑变化:`2.0→38 天`、`2.5→23 天`、`3.0→14 天`、`4.0→2 天`。建议结合已知干净时段或可视化 QC 选定,不要指望换个站点仍适用。
118
+ - **天级阈值固定为 `median ± 3×MAD`**,不再暴露额外参数;需要更松/更紧时直接改 `detect()` 里的 `k = 3.0`。
138
119
  - **`base_method`**:默认 `gam` 带单调约束 + 网格搜样条数,无需调;`logistic_5_parametric` 的默认 bounds 假设 1.2–1.8 MW,其它机型需显式传 bounds。
139
- - 其它参数(`bin_width`/`min_day_points`/`min_bin_points`)对结果不敏感,正常数据量下可保持默认。
120
+ - 每天样本数也按 `median ± 3×MAD` 过滤;若某些天只有个别点,这些天会整体进入 `abnormal_df`。这符合"下游只用 `normal_df`、宁可多删"的默认取舍。
140
121
 
141
122
  ## 安装
142
123
 
@@ -1,5 +1,7 @@
1
1
  LICENSE
2
+ MANIFEST.in
2
3
  README.md
4
+ requirements.txt
3
5
  setup.py
4
6
  test/test_daily_anomaly_detection.py
5
7
  test/test_filters.py
@@ -0,0 +1,5 @@
1
+ numpy==2.2.6
2
+ pandas==2.3.2
3
+ matplotlib==3.10.0
4
+ scipy==1.16.1
5
+ pygam==0.12.0
@@ -1,5 +0,0 @@
1
- numpy
2
- pandas
3
- matplotlib
4
- scipy
5
- pygam
File without changes
File without changes
File without changes
File without changes