优化: 增强高风速功率曲线清洗

- 自动估算每台风机平台功率和平台起始风速
- 增加高风速低功率、曲线残差和严格平台过滤
- 前端展示估算参数并更新接口与设计文档
This commit is contained in:
cloud
2026-07-14 16:10:29 +08:00
parent 50b8111fd9
commit ce58c35c34
5 changed files with 462 additions and 13 deletions
+308 -1
View File
@@ -61,6 +61,18 @@ struct CalculationOptions {
double generator_speed_k = 0.9;
};
struct EstimatedParams {
double rated_power = 0.0;
double rated_wind_speed = 0.0;
std::string source = "fallback";
};
struct CurveBin {
double wind_speed = 0.0;
double median_power = 0.0;
size_t sample_count = 0;
};
std::string Trim(const std::string& value) {
const auto begin = value.find_first_not_of(" \t\r\n");
if (begin == std::string::npos) {
@@ -230,6 +242,18 @@ double StdDev(const std::vector<double>& values, double mean) {
return std::sqrt(sum / static_cast<double>(values.size()));
}
double MedianAbsoluteDeviation(const std::vector<double>& values, double median) {
if (values.empty()) {
return 0.0;
}
std::vector<double> deviations;
deviations.reserve(values.size());
for (double value : values) {
deviations.push_back(std::abs(value - median));
}
return Quantile(deviations, 0.5);
}
CalculationOptions ParseOptions(const json& body) {
CalculationOptions options;
if (!body.contains("options") || !body["options"].is_object()) {
@@ -359,7 +383,7 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
std::vector<ValidRow> result;
result.reserve(rows.size());
for (double interval = min_wind; interval < max_wind;
for (double interval = min_wind; interval <= max_wind;
interval += options.cleaning_wind_speed_step) {
std::vector<ValidRow> interval_rows;
std::vector<double> values;
@@ -399,6 +423,248 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
return result;
}
std::vector<CurveBin> BuildMedianCurveBins(const std::vector<ValidRow>& rows,
double wind_speed_step) {
if (rows.empty()) {
return {};
}
const double curve_min = 1.0 - wind_speed_step * 0.5;
const double curve_max = 25.0 + wind_speed_step * 0.5;
std::vector<CurveBin> bins;
for (double start = curve_min; start < curve_max; start += wind_speed_step) {
const double end = start + wind_speed_step;
std::vector<double> values;
for (const auto& row : rows) {
if (row.wind_speed > start && row.wind_speed <= end) {
values.push_back(row.active_power);
}
}
if (!values.empty()) {
CurveBin bin;
bin.wind_speed = (start + end) / 2.0;
bin.median_power = Quantile(values, 0.5);
bin.sample_count = values.size();
bins.push_back(bin);
}
}
return bins;
}
double InterpolateMedianPower(const std::vector<CurveBin>& bins, double wind_speed) {
if (bins.empty()) {
return 0.0;
}
if (wind_speed <= bins.front().wind_speed) {
return bins.front().median_power;
}
if (wind_speed >= bins.back().wind_speed) {
return bins.back().median_power;
}
for (size_t i = 1; i < bins.size(); ++i) {
if (wind_speed <= bins[i].wind_speed) {
const auto& left = bins[i - 1];
const auto& right = bins[i];
const double span = right.wind_speed - left.wind_speed;
if (span <= 0.0) {
return left.median_power;
}
const double ratio = (wind_speed - left.wind_speed) / span;
return left.median_power + (right.median_power - left.median_power) * ratio;
}
}
return bins.back().median_power;
}
EstimatedParams EstimateRatedParams(const std::vector<ValidRow>& rows,
const CalculationOptions& options) {
EstimatedParams params;
params.rated_power = options.rated_power;
params.rated_wind_speed = options.rated_wind_speed;
if (rows.size() < 20) {
return params;
}
std::vector<double> powers;
powers.reserve(rows.size());
for (const auto& row : rows) {
powers.push_back(row.active_power);
}
const double p90 = Quantile(powers, 0.90);
std::vector<double> platform_candidates;
for (double power : powers) {
if (power >= p90 * 0.85) {
platform_candidates.push_back(power);
}
}
if (platform_candidates.size() >= 10) {
params.rated_power = Quantile(platform_candidates, 0.5);
params.source = "auto";
}
const auto bins = BuildMedianCurveBins(rows, options.curve_wind_speed_step);
if (params.source == "auto") {
for (const auto& bin : bins) {
if (bin.sample_count >= 4 && bin.median_power >= params.rated_power * 0.95) {
params.rated_wind_speed = bin.wind_speed;
return params;
}
}
}
params.source = params.source == "auto" ? "auto_power_fallback_wind" : "fallback";
return params;
}
std::vector<ValidRow> FilterHighWindLowPower(const std::vector<ValidRow>& rows,
const EstimatedParams& params,
int& removed_count,
std::vector<RemovedPoint>& removed_points) {
removed_count = 0;
if (rows.empty() || params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) {
return rows;
}
std::vector<double> platform_powers;
for (const auto& row : rows) {
if (row.wind_speed >= params.rated_wind_speed) {
platform_powers.push_back(row.active_power);
}
}
if (platform_powers.size() < 8) {
return rows;
}
const double median = Quantile(platform_powers, 0.5);
const double mad_sigma = MedianAbsoluteDeviation(platform_powers, median) * 1.4826;
const double robust_margin = std::max(4.0 * mad_sigma, median * 0.08);
const double lower_limit = std::max(params.rated_power * 0.85, median - robust_margin);
std::vector<ValidRow> result;
result.reserve(rows.size());
for (const auto& row : rows) {
if (row.wind_speed >= params.rated_wind_speed &&
row.active_power < lower_limit) {
++removed_count;
removed_points.push_back(RemovedPoint{row, "high_wind_low_power"});
} else {
result.push_back(row);
}
}
return result;
}
std::vector<ValidRow> FilterCurveResidualOutliers(const std::vector<ValidRow>& rows,
const CalculationOptions& options,
const EstimatedParams& params,
int& removed_count,
std::vector<RemovedPoint>& removed_points) {
removed_count = 0;
if (rows.size() < 20) {
return rows;
}
const auto median_curve = BuildMedianCurveBins(rows, options.curve_wind_speed_step);
if (median_curve.size() < 4) {
return rows;
}
std::vector<ValidRow> result;
result.reserve(rows.size());
double min_wind = rows.front().wind_speed;
double max_wind = min_wind;
for (const auto& row : rows) {
min_wind = std::min(min_wind, row.wind_speed);
max_wind = std::max(max_wind, row.wind_speed);
}
for (double start = min_wind - options.curve_wind_speed_step;
start <= max_wind;
start += options.curve_wind_speed_step) {
const double end = start + options.curve_wind_speed_step;
std::vector<ValidRow> interval_rows;
std::vector<double> residuals;
for (const auto& row : rows) {
if (row.wind_speed > start && row.wind_speed <= end) {
const double expected = InterpolateMedianPower(median_curve, row.wind_speed);
interval_rows.push_back(row);
residuals.push_back(row.active_power - expected);
}
}
if (interval_rows.empty()) {
continue;
}
if (interval_rows.size() < 8) {
result.insert(result.end(), interval_rows.begin(), interval_rows.end());
continue;
}
const double median = Quantile(residuals, 0.5);
const double mad_sigma = MedianAbsoluteDeviation(residuals, median) * 1.4826;
const double rated_power = params.rated_power > 0.0 ? params.rated_power : options.rated_power;
const double lower_margin = std::max(4.0 * mad_sigma, rated_power * 0.10);
const double upper_margin = std::max(4.0 * mad_sigma, rated_power * 0.16);
const double lower = median - lower_margin;
const double upper = median + upper_margin;
for (size_t i = 0; i < interval_rows.size(); ++i) {
if (residuals[i] >= lower && residuals[i] <= upper) {
result.push_back(interval_rows[i]);
} else {
++removed_count;
removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"});
}
}
}
return result;
}
std::vector<ValidRow> FilterStrictRatedPlateau(const std::vector<ValidRow>& rows,
const EstimatedParams& params,
int& removed_count,
std::vector<RemovedPoint>& removed_points) {
removed_count = 0;
if (rows.empty() || params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) {
return rows;
}
const double strict_start_wind_speed = params.rated_wind_speed + 0.5;
std::vector<double> plateau_powers;
for (const auto& row : rows) {
if (row.wind_speed >= strict_start_wind_speed) {
plateau_powers.push_back(row.active_power);
}
}
if (plateau_powers.size() < 6) {
return rows;
}
const double median = Quantile(plateau_powers, 0.5);
const double mad_sigma = MedianAbsoluteDeviation(plateau_powers, median) * 1.4826;
const double lower_limit = std::max(
params.rated_power * 0.92,
median - std::max(2.5 * mad_sigma, median * 0.04));
std::vector<ValidRow> result;
result.reserve(rows.size());
for (const auto& row : rows) {
if (row.wind_speed >= strict_start_wind_speed &&
row.active_power < lower_limit) {
++removed_count;
removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"});
} else {
result.push_back(row);
}
}
return result;
}
std::string DatePart(const std::string& time_text) {
if (time_text.size() >= 10) {
return time_text.substr(0, 10);
@@ -608,6 +874,7 @@ void WindPowerController::FinishJob(
json bins = json::object();
json scatter_points = json::object();
json filtered_points = json::object();
json estimated_params = json::object();
std::vector<std::string> fan_ids;
fan_ids.reserve(rows_by_fan.size());
@@ -619,6 +886,9 @@ void WindPowerController::FinishJob(
int limit_power_count = 0;
int tip_speed_ratio_outlier_count = 0;
int speed_power_outlier_count = 0;
int high_wind_low_power_count = 0;
int curve_residual_outlier_count = 0;
int rated_plateau_low_power_count = 0;
int cleaned_rows_count = 0;
for (const auto& fan_id : fan_ids) {
fans.push_back(fan_id);
@@ -651,6 +921,36 @@ void WindPowerController::FinishJob(
fan_removed_points,
"speed_power_outlier");
speed_power_outlier_count += removed;
const auto fan_estimated_params = EstimateRatedParams(fan_rows, options);
json params_json;
params_json["rated_power"] = fan_estimated_params.rated_power;
params_json["rated_wind_speed"] = fan_estimated_params.rated_wind_speed;
params_json["source"] = fan_estimated_params.source;
estimated_params[fan_id] = params_json;
fan_rows = FilterHighWindLowPower(
fan_rows,
fan_estimated_params,
removed,
fan_removed_points);
high_wind_low_power_count += removed;
fan_rows = FilterCurveResidualOutliers(
fan_rows,
options,
fan_estimated_params,
removed,
fan_removed_points);
curve_residual_outlier_count += removed;
fan_rows = FilterStrictRatedPlateau(
fan_rows,
fan_estimated_params,
removed,
fan_removed_points);
rated_plateau_low_power_count += removed;
cleaned_rows_count += static_cast<int>(fan_rows.size());
json fan_scatter = json::array();
@@ -709,6 +1009,9 @@ void WindPowerController::FinishJob(
invalid_reasons["limit_power"] = limit_power_count;
invalid_reasons["tip_speed_ratio_outlier"] = tip_speed_ratio_outlier_count;
invalid_reasons["speed_power_outlier"] = speed_power_outlier_count;
invalid_reasons["high_wind_low_power"] = high_wind_low_power_count;
invalid_reasons["curve_residual_outlier"] = curve_residual_outlier_count;
invalid_reasons["rated_plateau_low_power"] = rated_plateau_low_power_count;
const int invalid_total = raw_rows - cleaned_rows_count;
@@ -720,6 +1023,9 @@ void WindPowerController::FinishJob(
summary["limit_power_rows"] = limit_power_count;
summary["tip_speed_ratio_outlier_rows"] = tip_speed_ratio_outlier_count;
summary["speed_power_outlier_rows"] = speed_power_outlier_count;
summary["high_wind_low_power_rows"] = high_wind_low_power_count;
summary["curve_residual_outlier_rows"] = curve_residual_outlier_count;
summary["rated_plateau_low_power_rows"] = rated_plateau_low_power_count;
summary["invalid_reasons"] = CounterJson(invalid_reasons);
summary["fan_count"] = fans.size();
@@ -730,6 +1036,7 @@ void WindPowerController::FinishJob(
data["bins"] = bins;
data["scatter_points"] = scatter_points;
data["filtered_points"] = filtered_points;
data["estimated_params"] = estimated_params;
try {
fs::remove_all(JobDir(job_id.value()));
+20 -4
View File
@@ -131,8 +131,6 @@
{
"job_id": "job_123",
"options": {
"rated_power": 4800,
"rated_wind_speed": 18,
"power_step": 5,
"cleaning_wind_speed_step": 0.25,
"curve_wind_speed_step": 0.5,
@@ -160,11 +158,17 @@
"limit_power_rows": 0,
"tip_speed_ratio_outlier_rows": 0,
"speed_power_outlier_rows": 0,
"high_wind_low_power_rows": 0,
"curve_residual_outlier_rows": 0,
"rated_plateau_low_power_rows": 0,
"invalid_reasons": {
"invalid_active_power": 13889,
"limit_power": 0,
"tip_speed_ratio_outlier": 0,
"speed_power_outlier": 0
"speed_power_outlier": 0,
"high_wind_low_power": 0,
"curve_residual_outlier": 0,
"rated_plateau_low_power": 0
},
"fan_count": 1
},
@@ -204,6 +208,13 @@
"reason": "speed_power_outlier"
}
]
},
"estimated_params": {
"01#": {
"rated_power": 3120.5,
"rated_wind_speed": 10.75,
"source": "auto"
}
}
}
}
@@ -220,8 +231,13 @@
- 限功率识别按参考脚本执行:按功率分箱、按日期分组,组内风速跨度大于阈值时剔除。
- 叶尖速比使用参考脚本公式 `generator_speed * 3.14 * 162 * 78 * 30 / wind_speed`,按风速分箱做 IQR 清洗。
- 风速-功率关系按同一风速分箱和 IQR 参数清洗。
- 每台风机在 IQR 清洗后自动估算平台功率和平台起始风速,返回到 `estimated_params`
- 高风速平台区明显低于平台功率的点剔除为 `high_wind_low_power`
- 基于分箱中位功率曲线的残差异常点剔除为 `curve_residual_outlier`
- 严格额定平台区残留偏低点剔除为 `rated_plateau_low_power`
- 最终曲线按 `curve_wind_speed_step` 左开右闭分箱,区间非空即输出平均功率点。
- `scatter_points` 返回清洗后保留点,`filtered_points` 返回限功率和 IQR 阶段滤除的点。
- `scatter_points` 返回清洗后保留点,`filtered_points` 返回所有过滤阶段滤除的点和原因
- `estimated_params.source``auto``auto_power_fallback_wind``fallback`
### DELETE /api/wind/jobs/{job_id}
@@ -30,7 +30,8 @@
- 实测功率优先,已有 `有功功率(kW)` 时不使用理论风功率公式反推时刻功率。
- 清洗规则分层执行,基础无效值直接剔除,疑似限功率和统计离群点应可追溯标记。
- 每个风速区间必须输出样本数,样本不足的区间不能作为正式功率曲线点。
- 额定功率、额定风速、叶轮直径、齿轮箱速比等机型参数必须配置化,不能写死为旧脚本中的 `4800``6700`
- 额定功率和平台起始风速优先从每台风机的清洗后数据中自动估算,不能写死为旧脚本中的 `4800``6700`
- 自动清洗以可解释规则为主,先不引入 DBSCAN、RANSAC 或机器学习依赖。
## 无效值清洗方案
@@ -158,6 +159,80 @@ IQR = Q3 - Q1
高风速额定平台区建议额外处理。额定风速以上,功率应接近额定功率;如果出现大量高风速低功率点,应优先判断为限功率、停机、故障或弃风,不应简单参与正常功率曲线均值。
### 8. 自动额定参数估算
在完成基础清洗、限功率识别、叶尖速比 IQR 和风速-功率 IQR 后,系统按风机独立估算平台参数:
```text
estimated_rated_power = 高功率稳定区域的稳健平台功率
estimated_rated_wind_speed = 分箱中位功率首次达到 0.95 * estimated_rated_power 的风速
```
估算规则:
- 先取有功功率的高分位区域,使用该区域的中位数估算平台功率,避免单个尖峰影响。
- 再按 `0.5 m/s` 风速分箱计算中位功率曲线,寻找首次进入 `95%` 平台功率的风速。
- 若样本不足或无法形成平台,则回退到后端默认参数,并标记 `source = fallback`
- 响应中按风机返回 `estimated_params`,用于前端展示和人工复核。
### 9. 高风速平台区二次过滤
自动估算平台参数后,对额定平台区执行二次过滤:
```text
平台区 = 风速 >= estimated_rated_wind_speed
平台功率中位数 = median(平台区有功功率)
MAD_sigma = 1.4826 * MAD(平台区有功功率)
下限 = max(0.85 * estimated_rated_power, 平台功率中位数 - max(4 * MAD_sigma, 0.08 * 平台功率中位数))
```
平台区内低于下限的数据标记为:
```text
high_wind_low_power
```
该规则专门处理高风速区仍明显低于平台功率的散点,避免这些点拉低正式功率曲线。
### 10. 鲁棒曲线残差过滤
平台区二次过滤后,系统再构建一条分箱中位功率基准曲线:
```text
1. 按风速分箱计算中位功率。
2. 对每个散点按风速插值得到期望功率。
3. 计算残差 = 实测功率 - 期望功率。
4. 在每个风速段内使用 MAD 自适应阈值剔除异常残差。
```
被剔除的数据标记为:
```text
curve_residual_outlier
```
该规则用于补充单纯 IQR 的不足,特别是异常点数量较多导致四分位范围被拉宽的场景。
### 11. 严格额定平台过滤
对已经进入稳定额定平台的高风速段,再执行更严格的低功率过滤:
```text
严格平台区 = 风速 >= estimated_rated_wind_speed + 0.5
下限 = max(
0.92 * estimated_rated_power,
平台功率中位数 - max(2.5 * MAD_sigma, 0.04 * 平台功率中位数)
)
```
严格平台区内低于下限的数据标记为:
```text
rated_plateau_low_power
```
该规则只作用于平台区,不作用于爬坡过渡段,用于进一步清除额定风速后残留的偏低散点。
## 风机功率计算方案
### 1. 时刻功率
@@ -264,6 +339,10 @@ P = 0.5 * ρ * A * Cp * v^3
-> 标记疑似限功率
-> 可选:叶尖速比异常过滤
-> 风速-功率分箱离群过滤
-> 自动估算平台功率和平台起始风速
-> 高风速平台区二次过滤
-> 鲁棒曲线残差过滤
-> 严格额定平台过滤
-> 输出清洗后明细
-> 输出风速-功率曲线表
-> 输出功率曲线图
@@ -281,6 +360,10 @@ P = 0.5 * ρ * A * Cp * v^3
| 发电机最小转速 | `1 rpm` | 判断是否发电 |
| 功率上限 | `额定功率 * 1.10` | 防止异常高功率 |
| 风速上限 | `35 m/s` | 防止异常风速 |
| 平台功率下限 | `max(85%额定功率, MAD 自适应下限)` | 剔除高风速低功率异常 |
| 残差过滤下限 | `max(4*MAD_sigma, 10%额定功率)` | 剔除低于主曲线的异常点 |
| 残差过滤上限 | `max(4*MAD_sigma, 16%额定功率)` | 剔除高于主曲线的异常点 |
| 严格平台下限 | `max(92%额定功率, 2.5*MAD 自适应下限)` | 剔除平台区残留偏低点 |
机型配置建议包含:
@@ -309,6 +392,8 @@ P = 0.5 * ρ * A * Cp * v^3
- 功率曲线应随风速整体上升,并在额定风速附近进入平台。
- 01 号风机最大实测功率约 `3307 kW`,额定功率配置应与该量级一致。
- 高风速低功率点应被标记为疑似限功率、停机或异常,而不是直接拉低正式曲线。
- 高风速平台区的保留散点应集中在自动估算的平台功率附近。
- `filtered_points` 中应能看到 `high_wind_low_power``curve_residual_outlier``rated_plateau_low_power`
- 样本数不足的风速区间应在结果中明确标记。
### 人工复核
@@ -324,9 +409,18 @@ P = 0.5 * ρ * A * Cp * v^3
| 额定功率配置错误 | 功率上限过滤和平台判断失真 | 按每台机组实测最大功率和机型资料确认 |
| 把累计限功率时间误当状态量 | 大量正常数据被误删 | 使用相邻增量和功率形态综合判断 |
| 分箱样本数过少 | 曲线点随机波动大 | 输出样本数和置信度,低样本不参与正式曲线 |
| 平台参数估算失败 | 高风速过滤失效或误判 | 回退默认值并在 `estimated_params.source` 中标记 |
| 高风速异常点占比过高 | 平台中位数被拉低 | 平台过滤后再用残差过滤补充识别 |
| 叶尖速比公式错误 | 清洗结果偏差 | 使用标准公式,并配置叶轮半径和齿轮箱速比 |
| 时间间隔异常 | 发电量计算偏差 | 使用相邻时间差,并对异常间隔标记 |
## 参考资料
- POT-DBSCAN 风功率数据预处理方法:https://www.techscience.com/energy/v118n3/41894/html
- Adaptive DBSCAN 功率曲线异常点清洗:https://pure.ewha.ac.kr/en/publications/a-study-on-wind-power-curve-modeling-using-adaptive-dbscan/
- RANSAC 自适应阈值风功率数据清洗:https://www.nature.com/articles/s41598-025-89177-9
- 基于图像形态学的功率曲线异常点识别:https://arxiv.org/abs/2307.08539
## 后续实施建议
建议将现有两个脚本整理为一个可配置处理流程:
+16
View File
@@ -334,6 +334,22 @@ select:focus {
white-space: nowrap;
}
.paramStrip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -4px 0 12px;
}
.paramStrip span {
border: 1px solid #2a2a3a;
border-radius: 6px;
background: #191927;
color: #cbd5e1;
padding: 5px 8px;
font-size: 12px;
}
.tableWrap {
max-height: 420px;
overflow: auto;
+18 -2
View File
@@ -348,6 +348,9 @@ export default function HomePage() {
const selectedFiltered = selectedFan && result?.filtered_points
? result.filtered_points[selectedFan] || []
: [];
const selectedEstimatedParams = selectedFan && result?.estimated_params
? result.estimated_params[selectedFan]
: null;
const totalRows = useMemo(
() => files.reduce((sum, file) => sum + file.row_count, 0),
@@ -417,8 +420,6 @@ export default function HomePage() {
const calculation = await finishWindJob({
job_id: jobId,
options: {
rated_power: 4800,
rated_wind_speed: 18,
power_step: 5,
cleaning_wind_speed_step: 0.25,
curve_wind_speed_step: 0.5,
@@ -589,6 +590,21 @@ export default function HomePage() {
</select>
</div>
</div>
{selectedEstimatedParams && (
<div className="paramStrip">
<span>
自动平台功率
{Number(selectedEstimatedParams.rated_power || 0).toFixed(0)}
{' kW'}
</span>
<span>
平台起始风速
{Number(selectedEstimatedParams.rated_wind_speed || 0).toFixed(1)}
{' m/s'}
</span>
<span>来源{selectedEstimatedParams.source || 'fallback'}</span>
</div>
)}
<PowerCurveChart
points={selectedPoints}
scatterPoints={selectedScatter}