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.
- wtclean-0.2.8/MANIFEST.in +3 -0
- {wtclean-0.2.6/wtclean.egg-info → wtclean-0.2.8}/PKG-INFO +17 -36
- {wtclean-0.2.6 → wtclean-0.2.8}/README.md +11 -30
- wtclean-0.2.8/requirements.txt +5 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/setup.py +11 -3
- {wtclean-0.2.6 → wtclean-0.2.8}/test/test_daily_anomaly_detection.py +21 -1
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/daily_anomaly_detection.py +46 -97
- {wtclean-0.2.6 → wtclean-0.2.8/wtclean.egg-info}/PKG-INFO +17 -36
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/SOURCES.txt +2 -0
- wtclean-0.2.8/wtclean.egg-info/requires.txt +5 -0
- wtclean-0.2.6/wtclean.egg-info/requires.txt +0 -5
- {wtclean-0.2.6 → wtclean-0.2.8}/LICENSE +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/setup.cfg +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/test/test_filters.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/test/test_power_curve.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/test/test_power_curve_filtering.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/__init__.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/__init__.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/modules/power_curve_filtering.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/__init__.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/functions.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/parametric_forms.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/power_curve/parametric_optimize.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/__init__.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/binning_function.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/filters.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean/utils/machine_parameter_estimation.py +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/dependency_links.txt +0 -0
- {wtclean-0.2.6 → wtclean-0.2.8}/wtclean.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wtclean
|
|
3
|
-
Version: 0.2.
|
|
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
|
-
> 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常"
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
113
|
-
5.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
> 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常"
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
82
|
-
5.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
89
|
+
- 每天样本数也按 `median ± 3×MAD` 过滤;若某些天只有个别点,这些天会整体进入 `abnormal_df`。这符合"下游只用 `normal_df`、宁可多删"的默认取舍。
|
|
109
90
|
|
|
110
91
|
## 安装
|
|
111
92
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
1
3
|
import setuptools
|
|
2
4
|
|
|
3
|
-
|
|
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.
|
|
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=[
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
120
|
-
|
|
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
|
-
"""
|
|
105
|
+
"""运行天级检测,返回 ``(normal_df, abnormal_df)``。
|
|
129
106
|
|
|
130
107
|
Returns:
|
|
131
|
-
按行划分原始 ``df`` 的正常 /
|
|
132
|
-
``self.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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.
|
|
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
|
-
> 想识别**限电/降载/停机成片出现、单点看却落在正常带内**的"隐藏异常"
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
113
|
-
5.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
120
|
+
- 每天样本数也按 `median ± 3×MAD` 过滤;若某些天只有个别点,这些天会整体进入 `abnormal_df`。这符合"下游只用 `normal_df`、宁可多删"的默认取舍。
|
|
140
121
|
|
|
141
122
|
## 安装
|
|
142
123
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|