优化: 增强高风速功率曲线清洗
- 自动估算每台风机平台功率和平台起始风速 - 增加高风速低功率、曲线残差和严格平台过滤 - 前端展示估算参数并更新接口与设计文档
This commit is contained in:
@@ -61,6 +61,18 @@ struct CalculationOptions {
|
|||||||
double generator_speed_k = 0.9;
|
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) {
|
std::string Trim(const std::string& value) {
|
||||||
const auto begin = value.find_first_not_of(" \t\r\n");
|
const auto begin = value.find_first_not_of(" \t\r\n");
|
||||||
if (begin == std::string::npos) {
|
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()));
|
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 ParseOptions(const json& body) {
|
||||||
CalculationOptions options;
|
CalculationOptions options;
|
||||||
if (!body.contains("options") || !body["options"].is_object()) {
|
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;
|
std::vector<ValidRow> result;
|
||||||
result.reserve(rows.size());
|
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) {
|
interval += options.cleaning_wind_speed_step) {
|
||||||
std::vector<ValidRow> interval_rows;
|
std::vector<ValidRow> interval_rows;
|
||||||
std::vector<double> values;
|
std::vector<double> values;
|
||||||
@@ -399,6 +423,248 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
|
|||||||
return result;
|
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) {
|
std::string DatePart(const std::string& time_text) {
|
||||||
if (time_text.size() >= 10) {
|
if (time_text.size() >= 10) {
|
||||||
return time_text.substr(0, 10);
|
return time_text.substr(0, 10);
|
||||||
@@ -608,6 +874,7 @@ void WindPowerController::FinishJob(
|
|||||||
json bins = json::object();
|
json bins = json::object();
|
||||||
json scatter_points = json::object();
|
json scatter_points = json::object();
|
||||||
json filtered_points = json::object();
|
json filtered_points = json::object();
|
||||||
|
json estimated_params = json::object();
|
||||||
|
|
||||||
std::vector<std::string> fan_ids;
|
std::vector<std::string> fan_ids;
|
||||||
fan_ids.reserve(rows_by_fan.size());
|
fan_ids.reserve(rows_by_fan.size());
|
||||||
@@ -619,6 +886,9 @@ void WindPowerController::FinishJob(
|
|||||||
int limit_power_count = 0;
|
int limit_power_count = 0;
|
||||||
int tip_speed_ratio_outlier_count = 0;
|
int tip_speed_ratio_outlier_count = 0;
|
||||||
int speed_power_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;
|
int cleaned_rows_count = 0;
|
||||||
for (const auto& fan_id : fan_ids) {
|
for (const auto& fan_id : fan_ids) {
|
||||||
fans.push_back(fan_id);
|
fans.push_back(fan_id);
|
||||||
@@ -651,6 +921,36 @@ void WindPowerController::FinishJob(
|
|||||||
fan_removed_points,
|
fan_removed_points,
|
||||||
"speed_power_outlier");
|
"speed_power_outlier");
|
||||||
speed_power_outlier_count += removed;
|
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());
|
cleaned_rows_count += static_cast<int>(fan_rows.size());
|
||||||
|
|
||||||
json fan_scatter = json::array();
|
json fan_scatter = json::array();
|
||||||
@@ -709,6 +1009,9 @@ void WindPowerController::FinishJob(
|
|||||||
invalid_reasons["limit_power"] = limit_power_count;
|
invalid_reasons["limit_power"] = limit_power_count;
|
||||||
invalid_reasons["tip_speed_ratio_outlier"] = tip_speed_ratio_outlier_count;
|
invalid_reasons["tip_speed_ratio_outlier"] = tip_speed_ratio_outlier_count;
|
||||||
invalid_reasons["speed_power_outlier"] = speed_power_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;
|
const int invalid_total = raw_rows - cleaned_rows_count;
|
||||||
|
|
||||||
@@ -720,6 +1023,9 @@ void WindPowerController::FinishJob(
|
|||||||
summary["limit_power_rows"] = limit_power_count;
|
summary["limit_power_rows"] = limit_power_count;
|
||||||
summary["tip_speed_ratio_outlier_rows"] = tip_speed_ratio_outlier_count;
|
summary["tip_speed_ratio_outlier_rows"] = tip_speed_ratio_outlier_count;
|
||||||
summary["speed_power_outlier_rows"] = speed_power_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["invalid_reasons"] = CounterJson(invalid_reasons);
|
||||||
summary["fan_count"] = fans.size();
|
summary["fan_count"] = fans.size();
|
||||||
|
|
||||||
@@ -730,6 +1036,7 @@ void WindPowerController::FinishJob(
|
|||||||
data["bins"] = bins;
|
data["bins"] = bins;
|
||||||
data["scatter_points"] = scatter_points;
|
data["scatter_points"] = scatter_points;
|
||||||
data["filtered_points"] = filtered_points;
|
data["filtered_points"] = filtered_points;
|
||||||
|
data["estimated_params"] = estimated_params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs::remove_all(JobDir(job_id.value()));
|
fs::remove_all(JobDir(job_id.value()));
|
||||||
|
|||||||
+20
-4
@@ -131,8 +131,6 @@
|
|||||||
{
|
{
|
||||||
"job_id": "job_123",
|
"job_id": "job_123",
|
||||||
"options": {
|
"options": {
|
||||||
"rated_power": 4800,
|
|
||||||
"rated_wind_speed": 18,
|
|
||||||
"power_step": 5,
|
"power_step": 5,
|
||||||
"cleaning_wind_speed_step": 0.25,
|
"cleaning_wind_speed_step": 0.25,
|
||||||
"curve_wind_speed_step": 0.5,
|
"curve_wind_speed_step": 0.5,
|
||||||
@@ -160,11 +158,17 @@
|
|||||||
"limit_power_rows": 0,
|
"limit_power_rows": 0,
|
||||||
"tip_speed_ratio_outlier_rows": 0,
|
"tip_speed_ratio_outlier_rows": 0,
|
||||||
"speed_power_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_reasons": {
|
||||||
"invalid_active_power": 13889,
|
"invalid_active_power": 13889,
|
||||||
"limit_power": 0,
|
"limit_power": 0,
|
||||||
"tip_speed_ratio_outlier": 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
|
"fan_count": 1
|
||||||
},
|
},
|
||||||
@@ -204,6 +208,13 @@
|
|||||||
"reason": "speed_power_outlier"
|
"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 清洗。
|
- 叶尖速比使用参考脚本公式 `generator_speed * 3.14 * 162 * 78 * 30 / wind_speed`,按风速分箱做 IQR 清洗。
|
||||||
- 风速-功率关系按同一风速分箱和 IQR 参数清洗。
|
- 风速-功率关系按同一风速分箱和 IQR 参数清洗。
|
||||||
|
- 每台风机在 IQR 清洗后自动估算平台功率和平台起始风速,返回到 `estimated_params`。
|
||||||
|
- 高风速平台区明显低于平台功率的点剔除为 `high_wind_low_power`。
|
||||||
|
- 基于分箱中位功率曲线的残差异常点剔除为 `curve_residual_outlier`。
|
||||||
|
- 严格额定平台区残留偏低点剔除为 `rated_plateau_low_power`。
|
||||||
- 最终曲线按 `curve_wind_speed_step` 左开右闭分箱,区间非空即输出平均功率点。
|
- 最终曲线按 `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}
|
### DELETE /api/wind/jobs/{job_id}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
- 实测功率优先,已有 `有功功率(kW)` 时不使用理论风功率公式反推时刻功率。
|
- 实测功率优先,已有 `有功功率(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. 时刻功率
|
### 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 rpm` | 判断是否发电 |
|
||||||
| 功率上限 | `额定功率 * 1.10` | 防止异常高功率 |
|
| 功率上限 | `额定功率 * 1.10` | 防止异常高功率 |
|
||||||
| 风速上限 | `35 m/s` | 防止异常风速 |
|
| 风速上限 | `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`,额定功率配置应与该量级一致。
|
- 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
|
||||||
|
|
||||||
## 后续实施建议
|
## 后续实施建议
|
||||||
|
|
||||||
建议将现有两个脚本整理为一个可配置处理流程:
|
建议将现有两个脚本整理为一个可配置处理流程:
|
||||||
|
|||||||
@@ -334,6 +334,22 @@ select:focus {
|
|||||||
white-space: nowrap;
|
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 {
|
.tableWrap {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -348,6 +348,9 @@ export default function HomePage() {
|
|||||||
const selectedFiltered = selectedFan && result?.filtered_points
|
const selectedFiltered = selectedFan && result?.filtered_points
|
||||||
? result.filtered_points[selectedFan] || []
|
? result.filtered_points[selectedFan] || []
|
||||||
: [];
|
: [];
|
||||||
|
const selectedEstimatedParams = selectedFan && result?.estimated_params
|
||||||
|
? result.estimated_params[selectedFan]
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalRows = useMemo(
|
const totalRows = useMemo(
|
||||||
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
||||||
@@ -417,8 +420,6 @@ export default function HomePage() {
|
|||||||
const calculation = await finishWindJob({
|
const calculation = await finishWindJob({
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
options: {
|
options: {
|
||||||
rated_power: 4800,
|
|
||||||
rated_wind_speed: 18,
|
|
||||||
power_step: 5,
|
power_step: 5,
|
||||||
cleaning_wind_speed_step: 0.25,
|
cleaning_wind_speed_step: 0.25,
|
||||||
curve_wind_speed_step: 0.5,
|
curve_wind_speed_step: 0.5,
|
||||||
@@ -589,6 +590,21 @@ export default function HomePage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<PowerCurveChart
|
||||||
points={selectedPoints}
|
points={selectedPoints}
|
||||||
scatterPoints={selectedScatter}
|
scatterPoints={selectedScatter}
|
||||||
|
|||||||
Reference in New Issue
Block a user