diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index 88d4b0d..42257dd 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -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& values, double mean) { return std::sqrt(sum / static_cast(values.size())); } +double MedianAbsoluteDeviation(const std::vector& values, double median) { + if (values.empty()) { + return 0.0; + } + std::vector 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 FilterByWindBinIqr(const std::vector& rows, std::vector 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 interval_rows; std::vector values; @@ -399,6 +423,248 @@ std::vector FilterByWindBinIqr(const std::vector& rows, return result; } +std::vector BuildMedianCurveBins(const std::vector& 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 bins; + for (double start = curve_min; start < curve_max; start += wind_speed_step) { + const double end = start + wind_speed_step; + std::vector 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& 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& 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 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 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 FilterHighWindLowPower(const std::vector& rows, + const EstimatedParams& params, + int& removed_count, + std::vector& removed_points) { + removed_count = 0; + if (rows.empty() || params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) { + return rows; + } + + std::vector 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 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 FilterCurveResidualOutliers(const std::vector& rows, + const CalculationOptions& options, + const EstimatedParams& params, + int& removed_count, + std::vector& 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 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 interval_rows; + std::vector 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 FilterStrictRatedPlateau(const std::vector& rows, + const EstimatedParams& params, + int& removed_count, + std::vector& 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 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 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 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(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())); diff --git a/docs/接口文档.md b/docs/接口文档.md index eefbf8a..9f017ba 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -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} diff --git a/docs/风机历史数据无效值清洗与功率计算设计.md b/docs/风机历史数据无效值清洗与功率计算设计.md index 2ddea73..2b400ba 100644 --- a/docs/风机历史数据无效值清洗与功率计算设计.md +++ b/docs/风机历史数据无效值清洗与功率计算设计.md @@ -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 + ## 后续实施建议 建议将现有两个脚本整理为一个可配置处理流程: diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index 0e4d7d4..921745f 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -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; diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index efedf9b..c5a0bc3 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -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), @@ -360,9 +363,9 @@ export default function HomePage() { setReading(true); setError(''); - setResult(null); - setSelectedFan(''); - setShowFiltered(false); + setResult(null); + setSelectedFan(''); + setShowFiltered(false); try { const parsedFiles = []; for (const file of selectedFiles) { @@ -387,8 +390,8 @@ export default function HomePage() { let jobId = ''; setSubmitting(true); setError(''); - setResult(null); - setShowFiltered(false); + setResult(null); + setShowFiltered(false); setProgress('正在准备标准化数据'); try { @@ -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() { + {selectedEstimatedParams && ( +
+ + 自动平台功率: + {Number(selectedEstimatedParams.rated_power || 0).toFixed(0)} + {' kW'} + + + 平台起始风速: + {Number(selectedEstimatedParams.rated_wind_speed || 0).toFixed(1)} + {' m/s'} + + 来源:{selectedEstimatedParams.source || 'fallback'} +
+ )}