0e28826073
- 升级 Drogon 和 Trantor,修复畸形请求导致的连接计数泄漏\n- 增加第三方框架版本校验与自动重建\n- 完善完整报告导出和接口文档
2224 lines
87 KiB
C++
2224 lines
87 KiB
C++
#include "WindPowerController.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iomanip>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <random>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#include <xlsxwriter/format.h>
|
|
#include <xlsxwriter/workbook.h>
|
|
#include <xlsxwriter/worksheet.h>
|
|
#include <drogon/utils/Utilities.h>
|
|
|
|
using json = nlohmann::json;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
constexpr int kErrorInvalidRequest = 1001;
|
|
constexpr int kErrorJobNotFound = 1002;
|
|
constexpr int kErrorServer = 1003;
|
|
constexpr int kErrorJobBusy = 1004;
|
|
constexpr auto kUploadIdleTimeout = std::chrono::minutes(30);
|
|
constexpr auto kCompletedJobRetention = std::chrono::hours(24);
|
|
constexpr const char* kDefaultSchemeId = "scheme_one";
|
|
constexpr const char* kSchemeTwoId = "scheme_two";
|
|
constexpr const char* kSchemeOneName = "方案一";
|
|
constexpr const char* kSchemeTwoName = "方案二";
|
|
constexpr const char* kSchemeOneDefaultDescription = "通用方案";
|
|
constexpr const char* kSchemeTwoDefaultDescription =
|
|
"桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数";
|
|
|
|
struct RawRow {
|
|
std::string time;
|
|
std::string fan_id;
|
|
double wind_speed = 0.0;
|
|
double active_power = 0.0;
|
|
double generator_speed = 0.0;
|
|
};
|
|
|
|
struct ValidRow {
|
|
std::string time;
|
|
std::time_t timestamp = 0;
|
|
std::string date;
|
|
std::string fan_id;
|
|
double wind_speed = 0.0;
|
|
double active_power = 0.0;
|
|
double generator_speed = 0.0;
|
|
double blade_pitch_1 = 0.0;
|
|
double blade_pitch_2 = 0.0;
|
|
double blade_pitch_3 = 0.0;
|
|
double pitch_angle_average = 0.0;
|
|
double wind_speed_600s_average = 0.0;
|
|
double tip_speed_ratio = 0.0;
|
|
};
|
|
|
|
struct RemovedPoint {
|
|
ValidRow row;
|
|
std::string reason;
|
|
};
|
|
|
|
struct CalculationOptions {
|
|
std::string scheme_id = kDefaultSchemeId;
|
|
double rated_power = 4800.0;
|
|
double rated_wind_speed = 14.0;
|
|
double power_step = 5.0;
|
|
double cleaning_wind_speed_step = 0.25;
|
|
double curve_wind_speed_step = 0.5;
|
|
double report_wind_speed_interval = 0.25;
|
|
double wind_speed_change_threshold = 1.0;
|
|
double iqr_lower_multiplier = 1.2;
|
|
double iqr_upper_multiplier = 2.0;
|
|
double minimum_generator_speed = 1.0;
|
|
double generator_speed_k = 0.9;
|
|
double rotor_radius = 78.0;
|
|
double gearbox_ratio = 162.0;
|
|
double grid_connected_speed = 0.0;
|
|
double rated_generator_speed = 0.0;
|
|
bool rated_power_provided = false;
|
|
bool grid_connected_speed_provided = false;
|
|
bool rated_generator_speed_provided = false;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
struct SchemeInfo {
|
|
std::string id;
|
|
std::string name;
|
|
std::string description;
|
|
double scheme_one_rated_power = 4800.0;
|
|
double scheme_one_rated_wind_speed = 14.0;
|
|
double scheme_one_power_step = 5.0;
|
|
double scheme_one_cleaning_wind_speed_step = 0.25;
|
|
double scheme_one_wind_speed_change_threshold = 1.0;
|
|
double scheme_one_iqr_lower_multiplier = 1.2;
|
|
double scheme_one_iqr_upper_multiplier = 2.0;
|
|
double scheme_one_minimum_generator_speed = 1.0;
|
|
double scheme_one_generator_speed_k = 0.9;
|
|
double scheme_one_rotor_radius = 78.0;
|
|
double scheme_one_gearbox_ratio = 162.0;
|
|
double scheme_one_report_wind_speed_interval = 0.25;
|
|
double grid_connected_speed = 1030.0;
|
|
double rated_generator_speed = 1755.0;
|
|
double rated_power = 2000.0;
|
|
double scheme_two_report_wind_speed_interval = 0.25;
|
|
};
|
|
|
|
constexpr double kRatedCornerWindBefore = 0.5;
|
|
constexpr double kRatedCornerWindAfter = 1.2;
|
|
constexpr double kRatedCornerPowerLowerRatio = 0.88;
|
|
constexpr double kRatedCornerPowerUpperRatio = 1.03;
|
|
|
|
std::string Trim(const std::string& value) {
|
|
const auto begin = value.find_first_not_of(" \t\r\n");
|
|
if (begin == std::string::npos) {
|
|
return "";
|
|
}
|
|
const auto end = value.find_last_not_of(" \t\r\n");
|
|
return value.substr(begin, end - begin + 1);
|
|
}
|
|
|
|
bool IsSafeJobId(const std::string& job_id) {
|
|
if (job_id.empty() || job_id.size() > 80) {
|
|
return false;
|
|
}
|
|
return std::all_of(job_id.begin(), job_id.end(), [](unsigned char ch) {
|
|
return std::isalnum(ch) || ch == '_' || ch == '-';
|
|
});
|
|
}
|
|
|
|
fs::path JobsRoot() {
|
|
return fs::path("uploads") / "wind_jobs";
|
|
}
|
|
|
|
fs::path JobDir(const std::string& job_id) {
|
|
return JobsRoot() / job_id;
|
|
}
|
|
|
|
fs::path JobRowsPath(const std::string& job_id) {
|
|
return JobDir(job_id) / "rows.jsonl";
|
|
}
|
|
|
|
fs::path JobRawRowsPath(const std::string& job_id) {
|
|
return JobDir(job_id) / "raw_rows.jsonl";
|
|
}
|
|
|
|
fs::path JobResultPath(const std::string& job_id) {
|
|
return JobDir(job_id) / "result.json";
|
|
}
|
|
|
|
std::mutex g_task_mutex;
|
|
std::string g_active_job_id;
|
|
std::chrono::steady_clock::time_point g_active_since;
|
|
bool g_active_is_upload = false;
|
|
|
|
bool IsTaskBusyFor(const std::string& job_id) {
|
|
std::lock_guard<std::mutex> lock(g_task_mutex);
|
|
return !g_active_job_id.empty() && g_active_job_id != job_id;
|
|
}
|
|
|
|
bool AcquireTask(const std::string& job_id, bool upload) {
|
|
std::lock_guard<std::mutex> lock(g_task_mutex);
|
|
if (!g_active_job_id.empty() && g_active_job_id != job_id) return false;
|
|
g_active_job_id = job_id;
|
|
g_active_is_upload = upload;
|
|
g_active_since = std::chrono::steady_clock::now();
|
|
return true;
|
|
}
|
|
|
|
void TouchTask(const std::string& job_id) {
|
|
std::lock_guard<std::mutex> lock(g_task_mutex);
|
|
if (g_active_job_id == job_id) g_active_since = std::chrono::steady_clock::now();
|
|
}
|
|
|
|
void ReleaseTask(const std::string& job_id) {
|
|
std::lock_guard<std::mutex> lock(g_task_mutex);
|
|
if (g_active_job_id == job_id) {
|
|
g_active_job_id.clear();
|
|
g_active_is_upload = false;
|
|
}
|
|
}
|
|
|
|
class TaskReleaseGuard {
|
|
public:
|
|
explicit TaskReleaseGuard(std::string job_id) : job_id_(std::move(job_id)) {}
|
|
~TaskReleaseGuard() { ReleaseTask(job_id_); }
|
|
private:
|
|
std::string job_id_;
|
|
};
|
|
|
|
void ExpireIdleUploadTask() {
|
|
std::string expired;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_task_mutex);
|
|
if (g_active_is_upload && !g_active_job_id.empty() &&
|
|
std::chrono::steady_clock::now() - g_active_since > kUploadIdleTimeout) {
|
|
expired = g_active_job_id;
|
|
g_active_job_id.clear();
|
|
g_active_is_upload = false;
|
|
}
|
|
}
|
|
if (!expired.empty()) {
|
|
std::error_code ignored;
|
|
fs::remove_all(JobDir(expired), ignored);
|
|
}
|
|
}
|
|
|
|
void CleanupExpiredCompletedJobs() {
|
|
std::error_code error;
|
|
if (!fs::exists(JobsRoot(), error)) return;
|
|
const auto now = fs::file_time_type::clock::now();
|
|
for (const auto& entry : fs::directory_iterator(JobsRoot(), error)) {
|
|
if (error || !entry.is_directory()) continue;
|
|
const auto job_id = entry.path().filename().string();
|
|
if (IsTaskBusyFor(job_id) || !fs::exists(entry.path() / "result.json")) continue;
|
|
const auto modified = fs::last_write_time(entry.path(), error);
|
|
if (!error && now - modified > kCompletedJobRetention) fs::remove_all(entry.path(), error);
|
|
error.clear();
|
|
}
|
|
}
|
|
|
|
std::string ExcelColumnName(size_t index) {
|
|
std::string name;
|
|
for (size_t value = index + 1; value > 0; value = (value - 1) / 26) {
|
|
name.insert(name.begin(), static_cast<char>('A' + (value - 1) % 26));
|
|
}
|
|
return name;
|
|
}
|
|
|
|
void WriteJsonCell(lxw_worksheet* sheet, lxw_row_t row, lxw_col_t column,
|
|
const json& value, lxw_format* format = nullptr) {
|
|
if (value.is_number()) {
|
|
worksheet_write_number(sheet, row, column, value.get<double>(), format);
|
|
} else if (value.is_boolean()) {
|
|
worksheet_write_boolean(sheet, row, column, value.get<bool>(), format);
|
|
} else if (!value.is_null()) {
|
|
const auto text = value.is_string() ? value.get<std::string>() : value.dump();
|
|
worksheet_write_string(sheet, row, column, text.c_str(), format);
|
|
}
|
|
}
|
|
|
|
std::string JsonText(const json& value) {
|
|
if (value.is_string()) return value.get<std::string>();
|
|
if (value.is_number_integer()) return std::to_string(value.get<long long>());
|
|
if (value.is_number_unsigned()) return std::to_string(value.get<unsigned long long>());
|
|
if (value.is_number_float()) {
|
|
std::ostringstream output;
|
|
output << value.get<double>();
|
|
return output.str();
|
|
}
|
|
return value.is_null() ? "" : value.dump();
|
|
}
|
|
|
|
std::string FileNameForFan(const std::string& fan_id) {
|
|
std::string output;
|
|
for (const auto ch : fan_id) output += std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_';
|
|
return output.empty() ? "wind_turbine" : output;
|
|
}
|
|
|
|
fs::path SchemeConfigPath() {
|
|
return fs::path("data") / "wind_schemes.json";
|
|
}
|
|
|
|
fs::path ChartOptionsConfigPath() {
|
|
return fs::path("data") / "wind_chart_options.json";
|
|
}
|
|
|
|
std::string GenerateJobId() {
|
|
const auto now = std::chrono::system_clock::now().time_since_epoch().count();
|
|
std::random_device rd;
|
|
std::mt19937 rng(rd());
|
|
std::uniform_int_distribution<int> dist(0, 15);
|
|
|
|
std::ostringstream oss;
|
|
oss << "job_" << now << "_";
|
|
for (int i = 0; i < 8; ++i) {
|
|
oss << std::hex << dist(rng);
|
|
}
|
|
return oss.str();
|
|
}
|
|
|
|
std::optional<json> ParseBody(const HttpRequestPtr& req, std::string& error) {
|
|
try {
|
|
if (req->getBody().empty()) {
|
|
error = "请求体不能为空";
|
|
return std::nullopt;
|
|
}
|
|
return json::parse(req->getBody());
|
|
} catch (const std::exception&) {
|
|
error = "请求体不是合法 JSON";
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
|
|
std::optional<std::string> GetStringField(const json& body, const std::string& field) {
|
|
if (!body.contains(field)) {
|
|
return std::nullopt;
|
|
}
|
|
if (body[field].is_string()) {
|
|
return Trim(body[field].get<std::string>());
|
|
}
|
|
if (body[field].is_number_integer()) {
|
|
return std::to_string(body[field].get<long long>());
|
|
}
|
|
if (body[field].is_number_float()) {
|
|
std::ostringstream oss;
|
|
oss << body[field].get<double>();
|
|
return oss.str();
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<double> GetNumberField(const json& body, const std::string& field) {
|
|
if (!body.contains(field) || !body[field].is_number()) {
|
|
return std::nullopt;
|
|
}
|
|
return body[field].get<double>();
|
|
}
|
|
|
|
bool IsHexColor(const json& value) {
|
|
if (!value.is_string()) {
|
|
return false;
|
|
}
|
|
const auto color = value.get<std::string>();
|
|
if (color.size() != 7 || color.front() != '#') {
|
|
return false;
|
|
}
|
|
return std::all_of(color.begin() + 1, color.end(), [](unsigned char ch) {
|
|
return std::isxdigit(ch);
|
|
});
|
|
}
|
|
|
|
bool IsOneOf(const json& value, const std::vector<std::string>& allowed) {
|
|
return value.is_string() && std::find(allowed.begin(), allowed.end(), value.get<std::string>()) != allowed.end();
|
|
}
|
|
|
|
bool ValidateChartOptions(const json& options, std::string& error) {
|
|
if (!options.is_object()) {
|
|
error = "图表参数必须是对象";
|
|
return false;
|
|
}
|
|
|
|
const std::array<std::string, 3> text_fields = {"title", "x_axis_label", "y_axis_label"};
|
|
for (const auto& field : text_fields) {
|
|
if (!options.contains(field) || !options[field].is_string() || options[field].get<std::string>().size() > 200) {
|
|
error = "图表文字参数无效";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const std::array<std::string, 4> color_fields = {
|
|
"scatter_color", "text_color", "actual_color", "design_color",
|
|
};
|
|
for (const auto& field : color_fields) {
|
|
if (!options.contains(field) || !IsHexColor(options[field])) {
|
|
error = "图表颜色参数无效";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const std::array<std::string, 5> bool_fields = {
|
|
"show_grid", "show_legend", "show_scatter", "show_filtered", "actual_show_markers",
|
|
};
|
|
for (const auto& field : bool_fields) {
|
|
if (!options.contains(field) || !options[field].is_boolean()) {
|
|
error = "图表开关参数无效";
|
|
return false;
|
|
}
|
|
}
|
|
if (!options.contains("design_show_markers") || !options["design_show_markers"].is_boolean()) {
|
|
error = "图表开关参数无效";
|
|
return false;
|
|
}
|
|
|
|
const auto validate_number = [&](const std::string& field, double minimum, double maximum) {
|
|
return options.contains(field) && options[field].is_number() &&
|
|
std::isfinite(options[field].get<double>()) &&
|
|
options[field].get<double>() >= minimum && options[field].get<double>() <= maximum;
|
|
};
|
|
if (!validate_number("scatter_size", 1.0, 8.0) ||
|
|
!validate_number("scatter_opacity", 0.0, 100.0) ||
|
|
!validate_number("text_scale", 0.75, 1.5) ||
|
|
!validate_number("actual_marker_size", 2.0, 12.0) ||
|
|
!validate_number("design_marker_size", 2.0, 12.0)) {
|
|
error = "图表数值参数无效";
|
|
return false;
|
|
}
|
|
|
|
for (const auto& field : {"x_tick_interval", "y_tick_interval"}) {
|
|
if (!options.contains(field) || !options[field].is_string()) {
|
|
error = "图表刻度参数无效";
|
|
return false;
|
|
}
|
|
const auto value = Trim(options[field].get<std::string>());
|
|
if (!value.empty()) {
|
|
try {
|
|
size_t parsed = 0;
|
|
const double number = std::stod(value, &parsed);
|
|
if (parsed != value.size() || !std::isfinite(number) || number <= 0.0) {
|
|
error = "图表刻度参数无效";
|
|
return false;
|
|
}
|
|
} catch (const std::exception&) {
|
|
error = "图表刻度参数无效";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const std::vector<std::string> line_styles = {"solid", "dashed", "dotted"};
|
|
const std::vector<std::string> marker_shapes = {"circle", "square", "diamond", "triangle", "cross", "x"};
|
|
if (!options.contains("actual_line_style") || !IsOneOf(options["actual_line_style"], line_styles) ||
|
|
!options.contains("design_line_style") || !IsOneOf(options["design_line_style"], line_styles) ||
|
|
!options.contains("actual_marker_shape") || !IsOneOf(options["actual_marker_shape"], marker_shapes) ||
|
|
!options.contains("design_marker_shape") || !IsOneOf(options["design_marker_shape"], marker_shapes)) {
|
|
error = "图表线型或标记参数无效";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::optional<json> LoadChartOptions() {
|
|
const auto config_path = ChartOptionsConfigPath();
|
|
if (!fs::exists(config_path)) {
|
|
return std::nullopt;
|
|
}
|
|
try {
|
|
std::ifstream in(config_path);
|
|
auto options = json::parse(in);
|
|
// Compatible with configurations saved before filtered-point visibility became persistent.
|
|
if (!options.contains("show_filtered")) {
|
|
options["show_filtered"] = false;
|
|
}
|
|
// Migrate the earlier single pixel-size setting to the shared text multiplier.
|
|
if (!options.contains("text_scale")) {
|
|
double scale = 1.0;
|
|
if (options.contains("text_size") && options["text_size"].is_number()) {
|
|
scale = options["text_size"].get<double>() / 18.0;
|
|
}
|
|
options["text_scale"] = std::clamp(scale, 0.75, 1.5);
|
|
}
|
|
options.erase("text_size");
|
|
std::string error;
|
|
if (ValidateChartOptions(options, error)) {
|
|
return std::optional<json>{options};
|
|
}
|
|
} catch (const std::exception&) {
|
|
// Invalid optional chart configuration falls back to browser defaults.
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
bool SaveChartOptionsToFile(const json& options) {
|
|
try {
|
|
const auto config_path = ChartOptionsConfigPath();
|
|
fs::create_directories(config_path.parent_path());
|
|
std::ofstream out(config_path);
|
|
out << options.dump(2);
|
|
return static_cast<bool>(out);
|
|
} catch (const std::exception&) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
std::string NormalizeSchemeId(const std::string& scheme_id) {
|
|
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
|
|
}
|
|
|
|
std::vector<SchemeInfo> DefaultSchemes() {
|
|
SchemeInfo scheme_one;
|
|
scheme_one.id = kDefaultSchemeId;
|
|
scheme_one.name = kSchemeOneName;
|
|
scheme_one.description = kSchemeOneDefaultDescription;
|
|
|
|
SchemeInfo scheme_two;
|
|
scheme_two.id = kSchemeTwoId;
|
|
scheme_two.name = kSchemeTwoName;
|
|
scheme_two.description = kSchemeTwoDefaultDescription;
|
|
return {scheme_one, scheme_two};
|
|
}
|
|
|
|
std::optional<SchemeInfo> FindScheme(const std::vector<SchemeInfo>& schemes,
|
|
const std::string& scheme_id) {
|
|
const std::string normalized_id = NormalizeSchemeId(scheme_id);
|
|
for (const auto& scheme : schemes) {
|
|
if (scheme.id == normalized_id) {
|
|
return scheme;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
json SchemeToJson(const SchemeInfo& scheme) {
|
|
json data;
|
|
data["id"] = scheme.id;
|
|
data["name"] = scheme.name;
|
|
data["description"] = scheme.description;
|
|
if (scheme.id == kDefaultSchemeId) {
|
|
data["parameters"] = {
|
|
{"rated_power", scheme.scheme_one_rated_power},
|
|
{"rated_wind_speed", scheme.scheme_one_rated_wind_speed},
|
|
{"power_step", scheme.scheme_one_power_step},
|
|
{"cleaning_wind_speed_step", scheme.scheme_one_cleaning_wind_speed_step},
|
|
{"wind_speed_change_threshold", scheme.scheme_one_wind_speed_change_threshold},
|
|
{"iqr_lower_multiplier", scheme.scheme_one_iqr_lower_multiplier},
|
|
{"iqr_upper_multiplier", scheme.scheme_one_iqr_upper_multiplier},
|
|
{"minimum_generator_speed", scheme.scheme_one_minimum_generator_speed},
|
|
{"generator_speed_k", scheme.scheme_one_generator_speed_k},
|
|
{"rotor_radius", scheme.scheme_one_rotor_radius},
|
|
{"gearbox_ratio", scheme.scheme_one_gearbox_ratio},
|
|
{"report_wind_speed_interval", scheme.scheme_one_report_wind_speed_interval},
|
|
};
|
|
} else if (scheme.id == kSchemeTwoId) {
|
|
data["parameters"]["grid_connected_speed"] = scheme.grid_connected_speed;
|
|
data["parameters"]["rated_generator_speed"] = scheme.rated_generator_speed;
|
|
data["parameters"]["rated_power"] = scheme.rated_power;
|
|
data["parameters"]["report_wind_speed_interval"] =
|
|
scheme.scheme_two_report_wind_speed_interval;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
std::vector<SchemeInfo> LoadSchemes() {
|
|
auto schemes = DefaultSchemes();
|
|
const auto config_path = SchemeConfigPath();
|
|
if (!fs::exists(config_path)) {
|
|
return schemes;
|
|
}
|
|
|
|
try {
|
|
std::ifstream in(config_path);
|
|
const auto saved = json::parse(in);
|
|
if (!saved.contains("schemes") || !saved["schemes"].is_array()) {
|
|
return schemes;
|
|
}
|
|
|
|
for (auto& scheme : schemes) {
|
|
for (const auto& item : saved["schemes"]) {
|
|
const auto saved_id = GetStringField(item, "id");
|
|
if (!saved_id.has_value() || saved_id.value() != scheme.id) {
|
|
continue;
|
|
}
|
|
const auto description = GetStringField(item, "description");
|
|
if (description.has_value()) {
|
|
scheme.description = description.value();
|
|
}
|
|
if (!item.contains("parameters") || !item["parameters"].is_object()) {
|
|
continue;
|
|
}
|
|
const auto& params = item["parameters"];
|
|
if (scheme.id == kDefaultSchemeId) {
|
|
const auto load_positive = [&](const std::string& field, double& target) {
|
|
if (const auto value = GetNumberField(params, field);
|
|
value.has_value() && std::isfinite(value.value()) && value.value() > 0.0) {
|
|
target = value.value();
|
|
}
|
|
};
|
|
const auto load_non_negative = [&](const std::string& field, double& target) {
|
|
if (const auto value = GetNumberField(params, field);
|
|
value.has_value() && std::isfinite(value.value()) && value.value() >= 0.0) {
|
|
target = value.value();
|
|
}
|
|
};
|
|
load_positive("rated_power", scheme.scheme_one_rated_power);
|
|
load_positive("rated_wind_speed", scheme.scheme_one_rated_wind_speed);
|
|
load_positive("power_step", scheme.scheme_one_power_step);
|
|
load_positive("cleaning_wind_speed_step", scheme.scheme_one_cleaning_wind_speed_step);
|
|
load_non_negative("wind_speed_change_threshold",
|
|
scheme.scheme_one_wind_speed_change_threshold);
|
|
load_non_negative("iqr_lower_multiplier", scheme.scheme_one_iqr_lower_multiplier);
|
|
load_non_negative("iqr_upper_multiplier", scheme.scheme_one_iqr_upper_multiplier);
|
|
load_non_negative("minimum_generator_speed",
|
|
scheme.scheme_one_minimum_generator_speed);
|
|
load_non_negative("generator_speed_k", scheme.scheme_one_generator_speed_k);
|
|
load_positive("rotor_radius", scheme.scheme_one_rotor_radius);
|
|
load_positive("gearbox_ratio", scheme.scheme_one_gearbox_ratio);
|
|
load_positive("report_wind_speed_interval",
|
|
scheme.scheme_one_report_wind_speed_interval);
|
|
} else if (scheme.id == kSchemeTwoId) {
|
|
if (const auto value = GetNumberField(params, "grid_connected_speed");
|
|
value.has_value() && value.value() > 0.0) {
|
|
scheme.grid_connected_speed = value.value();
|
|
}
|
|
if (const auto value = GetNumberField(params, "rated_generator_speed");
|
|
value.has_value() && value.value() > 0.0) {
|
|
scheme.rated_generator_speed = value.value();
|
|
}
|
|
if (const auto value = GetNumberField(params, "rated_power");
|
|
value.has_value() && value.value() > 0.0) {
|
|
scheme.rated_power = value.value();
|
|
}
|
|
if (const auto value = GetNumberField(params, "report_wind_speed_interval");
|
|
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
|
scheme.scheme_two_report_wind_speed_interval = value.value();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (const std::exception&) {
|
|
return schemes;
|
|
}
|
|
return schemes;
|
|
}
|
|
|
|
std::string LoadDefaultSchemeId() {
|
|
const auto config_path = SchemeConfigPath();
|
|
if (!fs::exists(config_path)) {
|
|
return kDefaultSchemeId;
|
|
}
|
|
|
|
try {
|
|
std::ifstream in(config_path);
|
|
const auto saved = json::parse(in);
|
|
const auto default_scheme_id = GetStringField(saved, "default_scheme_id");
|
|
if (default_scheme_id.has_value() &&
|
|
default_scheme_id.value() == NormalizeSchemeId(default_scheme_id.value())) {
|
|
return default_scheme_id.value();
|
|
}
|
|
} catch (const std::exception&) {
|
|
// A malformed optional config must not block the built-in default scheme.
|
|
}
|
|
return kDefaultSchemeId;
|
|
}
|
|
|
|
bool SaveSchemes(const std::vector<SchemeInfo>& schemes,
|
|
const std::string& default_scheme_id) {
|
|
try {
|
|
const auto config_path = SchemeConfigPath();
|
|
fs::create_directories(config_path.parent_path());
|
|
|
|
json data;
|
|
data["default_scheme_id"] = NormalizeSchemeId(default_scheme_id);
|
|
data["schemes"] = json::array();
|
|
for (const auto& scheme : schemes) {
|
|
data["schemes"].push_back(SchemeToJson(scheme));
|
|
}
|
|
|
|
std::ofstream out(config_path);
|
|
out << data.dump(2);
|
|
return true;
|
|
} catch (const std::exception&) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
std::optional<double> GetDoubleField(const json& body, const std::string& field) {
|
|
if (!body.contains(field)) {
|
|
return std::nullopt;
|
|
}
|
|
if (body[field].is_number()) {
|
|
return body[field].get<double>();
|
|
}
|
|
if (body[field].is_string()) {
|
|
try {
|
|
size_t parsed = 0;
|
|
const auto value = std::stod(Trim(body[field].get<std::string>()), &parsed);
|
|
if (parsed == Trim(body[field].get<std::string>()).size()) {
|
|
return value;
|
|
}
|
|
} catch (const std::exception&) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<std::time_t> ParseTime(std::string value) {
|
|
value = Trim(value);
|
|
if (value.empty()) {
|
|
return std::nullopt;
|
|
}
|
|
std::replace(value.begin(), value.end(), 'T', ' ');
|
|
if (!value.empty() && value.back() == 'Z') {
|
|
value.pop_back();
|
|
}
|
|
const auto dot_pos = value.find('.');
|
|
if (dot_pos != std::string::npos) {
|
|
value = value.substr(0, dot_pos);
|
|
}
|
|
if (value.size() == 10) {
|
|
value += " 00:00:00";
|
|
}
|
|
|
|
std::tm tm = {};
|
|
std::istringstream iss(value);
|
|
iss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
|
|
if (iss.fail()) {
|
|
return std::nullopt;
|
|
}
|
|
tm.tm_isdst = -1;
|
|
return std::mktime(&tm);
|
|
}
|
|
|
|
json CounterJson(const std::unordered_map<std::string, int>& counters) {
|
|
json data = json::object();
|
|
for (const auto& item : counters) {
|
|
data[item.first] = item.second;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
double Quantile(std::vector<double> values, double q) {
|
|
if (values.empty()) {
|
|
return 0.0;
|
|
}
|
|
std::sort(values.begin(), values.end());
|
|
const double pos = (static_cast<double>(values.size()) - 1.0) * q;
|
|
const auto low = static_cast<size_t>(std::floor(pos));
|
|
const auto high = static_cast<size_t>(std::ceil(pos));
|
|
if (low == high) {
|
|
return values[low];
|
|
}
|
|
const double weight = pos - static_cast<double>(low);
|
|
return values[low] * (1.0 - weight) + values[high] * weight;
|
|
}
|
|
|
|
double Mean(const std::vector<double>& values) {
|
|
if (values.empty()) {
|
|
return 0.0;
|
|
}
|
|
double sum = 0.0;
|
|
for (double value : values) {
|
|
sum += value;
|
|
}
|
|
return sum / static_cast<double>(values.size());
|
|
}
|
|
|
|
double StdDev(const std::vector<double>& values, double mean) {
|
|
if (values.size() < 2) {
|
|
return 0.0;
|
|
}
|
|
double sum = 0.0;
|
|
for (double value : values) {
|
|
const double diff = value - mean;
|
|
sum += diff * diff;
|
|
}
|
|
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()) {
|
|
return options;
|
|
}
|
|
|
|
const auto& opt = body["options"];
|
|
if (const auto value = GetStringField(opt, "scheme_id"); value.has_value()) {
|
|
options.scheme_id = NormalizeSchemeId(value.value());
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "rated_power");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.rated_power = value.value();
|
|
options.rated_power_provided = true;
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "rated_wind_speed");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.rated_wind_speed = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "power_step");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.power_step = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "cleaning_wind_speed_step");
|
|
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
|
options.cleaning_wind_speed_step = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "curve_wind_speed_step");
|
|
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
|
options.curve_wind_speed_step = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "report_wind_speed_interval");
|
|
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
|
options.report_wind_speed_interval = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "wind_speed_change_threshold");
|
|
value.has_value() && value.value() >= 0.0) {
|
|
options.wind_speed_change_threshold = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "iqr_lower_multiplier");
|
|
value.has_value() && value.value() >= 0.0 && value.value() <= 10.0) {
|
|
options.iqr_lower_multiplier = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "iqr_upper_multiplier");
|
|
value.has_value() && value.value() >= 0.0 && value.value() <= 10.0) {
|
|
options.iqr_upper_multiplier = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "minimum_generator_speed");
|
|
value.has_value() && value.value() >= 0.0) {
|
|
options.minimum_generator_speed = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "generator_speed_k");
|
|
value.has_value() && value.value() >= 0.0) {
|
|
options.generator_speed_k = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "rotor_radius");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.rotor_radius = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "gearbox_ratio");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.gearbox_ratio = value.value();
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "grid_connected_speed");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.grid_connected_speed = value.value();
|
|
options.grid_connected_speed_provided = true;
|
|
}
|
|
if (const auto value = GetDoubleField(opt, "rated_generator_speed");
|
|
value.has_value() && value.value() > 0.0) {
|
|
options.rated_generator_speed = value.value();
|
|
options.rated_generator_speed_provided = true;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
bool IsSchemeTwo(const CalculationOptions& options) {
|
|
return options.scheme_id == kSchemeTwoId;
|
|
}
|
|
|
|
void AddInvalid(std::unordered_map<std::string, int>& counters, const std::string& reason) {
|
|
counters[reason] += 1;
|
|
}
|
|
|
|
void ComputeWindSpeed600sAverage(std::vector<ValidRow>& rows) {
|
|
std::deque<ValidRow> window;
|
|
double wind_speed_sum = 0.0;
|
|
for (auto& row : rows) {
|
|
while (!window.empty() &&
|
|
std::difftime(row.timestamp, window.front().timestamp) > 600.0) {
|
|
wind_speed_sum -= window.front().wind_speed;
|
|
window.pop_front();
|
|
}
|
|
|
|
window.push_back(row);
|
|
wind_speed_sum += row.wind_speed;
|
|
row.wind_speed_600s_average = wind_speed_sum / static_cast<double>(window.size());
|
|
}
|
|
}
|
|
|
|
bool IsRatedTransitionCorner(const ValidRow& row, const EstimatedParams& params) {
|
|
if (params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) {
|
|
return false;
|
|
}
|
|
|
|
return row.wind_speed >= params.rated_wind_speed - kRatedCornerWindBefore &&
|
|
row.wind_speed <= params.rated_wind_speed + kRatedCornerWindAfter &&
|
|
row.active_power >= params.rated_power * kRatedCornerPowerLowerRatio &&
|
|
row.active_power <= params.rated_power * kRatedCornerPowerUpperRatio;
|
|
}
|
|
|
|
std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
|
|
const CalculationOptions& options,
|
|
const EstimatedParams* transition_params,
|
|
int& removed_count,
|
|
std::vector<RemovedPoint>& removed_points) {
|
|
removed_count = 0;
|
|
if (rows.empty()) {
|
|
return {};
|
|
}
|
|
|
|
double min_power = rows.front().active_power;
|
|
for (const auto& row : rows) {
|
|
min_power = std::min(min_power, row.active_power);
|
|
}
|
|
|
|
std::set<size_t> remove_indexes;
|
|
for (double interval = min_power; interval < options.rated_power; interval += options.power_step) {
|
|
std::unordered_map<std::string, std::vector<size_t>> grouped_by_date;
|
|
for (size_t i = 0; i < rows.size(); ++i) {
|
|
const auto& row = rows[i];
|
|
if (row.active_power >= interval &&
|
|
row.active_power < interval + options.power_step) {
|
|
grouped_by_date[row.date].push_back(i);
|
|
}
|
|
}
|
|
|
|
for (const auto& group : grouped_by_date) {
|
|
if (group.second.empty()) {
|
|
continue;
|
|
}
|
|
double min_wind = rows[group.second.front()].wind_speed;
|
|
double max_wind = min_wind;
|
|
for (size_t index : group.second) {
|
|
min_wind = std::min(min_wind, rows[index].wind_speed);
|
|
max_wind = std::max(max_wind, rows[index].wind_speed);
|
|
}
|
|
if (max_wind - min_wind > options.wind_speed_change_threshold) {
|
|
remove_indexes.insert(group.second.begin(), group.second.end());
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<ValidRow> result;
|
|
result.reserve(rows.size());
|
|
for (size_t i = 0; i < rows.size(); ++i) {
|
|
const bool protect_transition =
|
|
transition_params != nullptr && IsRatedTransitionCorner(rows[i], *transition_params);
|
|
if (remove_indexes.count(i) == 0 || protect_transition) {
|
|
result.push_back(rows[i]);
|
|
} else {
|
|
removed_points.push_back(RemovedPoint{rows[i], "limit_power"});
|
|
++removed_count;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::vector<ValidRow> FilterSchemeTwoGridSpeed(const std::vector<ValidRow>& rows,
|
|
const CalculationOptions& options,
|
|
int& removed_count,
|
|
std::vector<RemovedPoint>& removed_points) {
|
|
removed_count = 0;
|
|
if (!IsSchemeTwo(options) || rows.empty()) {
|
|
return rows;
|
|
}
|
|
|
|
std::vector<ValidRow> result;
|
|
result.reserve(rows.size());
|
|
for (const auto& row : rows) {
|
|
if (row.generator_speed <= options.grid_connected_speed) {
|
|
++removed_count;
|
|
removed_points.push_back(RemovedPoint{row, "scheme_two_grid_speed_low"});
|
|
} else {
|
|
result.push_back(row);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::vector<ValidRow> FilterSchemeTwoPitchRules(
|
|
const std::vector<ValidRow>& rows,
|
|
const CalculationOptions& options,
|
|
int& low_power_pitch_wind_count,
|
|
int& low_speed_pitch_count,
|
|
int& low_power_pitch_count,
|
|
std::vector<RemovedPoint>& removed_points) {
|
|
low_power_pitch_wind_count = 0;
|
|
low_speed_pitch_count = 0;
|
|
low_power_pitch_count = 0;
|
|
if (!IsSchemeTwo(options) || rows.empty()) {
|
|
return rows;
|
|
}
|
|
|
|
std::vector<ValidRow> result;
|
|
result.reserve(rows.size());
|
|
for (const auto& row : rows) {
|
|
if (row.active_power < options.rated_power &&
|
|
row.pitch_angle_average > 5.0 &&
|
|
row.wind_speed_600s_average < 15.0) {
|
|
++low_power_pitch_wind_count;
|
|
removed_points.push_back(RemovedPoint{row, "scheme_two_low_power_pitch_wind"});
|
|
continue;
|
|
}
|
|
if (row.generator_speed < options.rated_generator_speed &&
|
|
row.pitch_angle_average > 5.0) {
|
|
++low_speed_pitch_count;
|
|
removed_points.push_back(RemovedPoint{row, "scheme_two_low_speed_pitch"});
|
|
continue;
|
|
}
|
|
if (row.active_power < 1980.0 && row.pitch_angle_average > 2.0) {
|
|
++low_power_pitch_count;
|
|
removed_points.push_back(RemovedPoint{row, "scheme_two_low_power_pitch"});
|
|
continue;
|
|
}
|
|
result.push_back(row);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
template <typename ValueGetter>
|
|
std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
|
|
const CalculationOptions& options,
|
|
ValueGetter value_getter,
|
|
int& removed_count,
|
|
std::vector<RemovedPoint>& removed_points,
|
|
const std::string& reason,
|
|
const EstimatedParams* transition_params = nullptr) {
|
|
removed_count = 0;
|
|
if (rows.empty()) {
|
|
return {};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
std::vector<ValidRow> result;
|
|
result.reserve(rows.size());
|
|
for (double interval = min_wind; interval <= max_wind;
|
|
interval += options.cleaning_wind_speed_step) {
|
|
std::vector<ValidRow> interval_rows;
|
|
std::vector<double> values;
|
|
for (const auto& row : rows) {
|
|
if (row.wind_speed >= interval &&
|
|
row.wind_speed < interval + options.cleaning_wind_speed_step) {
|
|
interval_rows.push_back(row);
|
|
values.push_back(value_getter(row));
|
|
}
|
|
}
|
|
|
|
if (interval_rows.empty()) {
|
|
continue;
|
|
}
|
|
|
|
if (interval_rows.size() >= 4) {
|
|
const double q1 = Quantile(values, 0.25);
|
|
const double q3 = Quantile(values, 0.75);
|
|
const double iqr = q3 - q1;
|
|
const double lower = q1 - options.iqr_lower_multiplier * iqr;
|
|
const double upper = q3 + options.iqr_upper_multiplier * iqr;
|
|
|
|
for (const auto& row : interval_rows) {
|
|
const double value = value_getter(row);
|
|
if (value >= lower && value <= upper) {
|
|
result.push_back(row);
|
|
} else if (transition_params != nullptr &&
|
|
IsRatedTransitionCorner(row, *transition_params)) {
|
|
result.push_back(row);
|
|
} else {
|
|
++removed_count;
|
|
removed_points.push_back(RemovedPoint{row, reason});
|
|
}
|
|
}
|
|
} else {
|
|
result.insert(result.end(), interval_rows.begin(), interval_rows.end());
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const double high_wind_start = params.rated_wind_speed + 1.0;
|
|
std::vector<double> platform_powers;
|
|
for (const auto& row : rows) {
|
|
if (row.wind_speed >= high_wind_start) {
|
|
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 >= high_wind_start &&
|
|
!IsRatedTransitionCorner(row, params) &&
|
|
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;
|
|
double transition_multiplier = 1.0;
|
|
if (params.rated_wind_speed > 0.0) {
|
|
const double interval_center = (start + end) / 2.0;
|
|
if (interval_center >= params.rated_wind_speed - 1.0 &&
|
|
interval_center <= params.rated_wind_speed + 1.5) {
|
|
transition_multiplier = 1.8;
|
|
}
|
|
}
|
|
const double lower_margin =
|
|
std::max(4.0 * mad_sigma, rated_power * 0.10) * transition_multiplier;
|
|
const double upper_margin =
|
|
std::max(4.0 * mad_sigma, rated_power * 0.16) * transition_multiplier;
|
|
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 if (IsRatedTransitionCorner(interval_rows[i], params)) {
|
|
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 + 1.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 &&
|
|
!IsRatedTransitionCorner(row, params) &&
|
|
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);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void WindPowerController::GetSchemes(
|
|
const HttpRequestPtr&,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
json data;
|
|
data["default_scheme_id"] = LoadDefaultSchemeId();
|
|
data["schemes"] = json::array();
|
|
for (const auto& scheme : LoadSchemes()) {
|
|
data["schemes"].push_back(SchemeToJson(scheme));
|
|
}
|
|
SendSuccess(callback, data);
|
|
}
|
|
|
|
void WindPowerController::SaveSchemeDescription(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
|
const std::string& scheme_id) {
|
|
const std::string normalized_id = NormalizeSchemeId(scheme_id);
|
|
if (scheme_id != normalized_id) {
|
|
SendError(callback, kErrorInvalidRequest, "方案不存在");
|
|
return;
|
|
}
|
|
|
|
std::string error;
|
|
const auto body = ParseBody(req, error);
|
|
if (!body.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
|
|
const auto description = GetStringField(body.value(), "description");
|
|
if (!description.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, "方案描述不能为空");
|
|
return;
|
|
}
|
|
if (description.value().size() > 1000) {
|
|
SendError(callback, kErrorInvalidRequest, "方案描述不能超过 1000 个字符");
|
|
return;
|
|
}
|
|
|
|
std::optional<json> scheme_one_parameters;
|
|
std::optional<double> grid_connected_speed;
|
|
std::optional<double> rated_generator_speed;
|
|
std::optional<double> rated_power;
|
|
std::optional<double> report_wind_speed_interval;
|
|
if (normalized_id == kDefaultSchemeId) {
|
|
if (!body.value().contains("parameters") || !body.value()["parameters"].is_object()) {
|
|
SendError(callback, kErrorInvalidRequest, "方案一参数格式错误");
|
|
return;
|
|
}
|
|
const auto& params = body.value()["parameters"];
|
|
const auto valid_positive = [&](const std::string& field) {
|
|
const auto value = GetDoubleField(params, field);
|
|
return value.has_value() && std::isfinite(value.value()) && value.value() > 0.0;
|
|
};
|
|
const auto valid_non_negative = [&](const std::string& field) {
|
|
const auto value = GetDoubleField(params, field);
|
|
return value.has_value() && std::isfinite(value.value()) && value.value() >= 0.0;
|
|
};
|
|
if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") ||
|
|
!valid_positive("power_step") || !valid_positive("cleaning_wind_speed_step") ||
|
|
!valid_positive("rotor_radius") || !valid_positive("gearbox_ratio") ||
|
|
!valid_positive("report_wind_speed_interval") ||
|
|
!valid_non_negative("wind_speed_change_threshold") ||
|
|
!valid_non_negative("iqr_lower_multiplier") ||
|
|
!valid_non_negative("iqr_upper_multiplier") ||
|
|
!valid_non_negative("minimum_generator_speed") ||
|
|
!valid_non_negative("generator_speed_k")) {
|
|
SendError(callback, kErrorInvalidRequest, "方案一参数必须为合法数值");
|
|
return;
|
|
}
|
|
if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 ||
|
|
GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 ||
|
|
GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 ||
|
|
GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) {
|
|
SendError(callback, kErrorInvalidRequest, "方案一参数超出允许范围");
|
|
return;
|
|
}
|
|
scheme_one_parameters = params;
|
|
} else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) {
|
|
if (!body.value()["parameters"].is_object()) {
|
|
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
|
return;
|
|
}
|
|
const auto& params = body.value()["parameters"];
|
|
grid_connected_speed = GetDoubleField(params, "grid_connected_speed");
|
|
rated_generator_speed = GetDoubleField(params, "rated_generator_speed");
|
|
rated_power = GetDoubleField(params, "rated_power");
|
|
report_wind_speed_interval = GetDoubleField(params, "report_wind_speed_interval");
|
|
if (!grid_connected_speed.has_value() || grid_connected_speed.value() <= 0.0 ||
|
|
!rated_generator_speed.has_value() || rated_generator_speed.value() <= 0.0 ||
|
|
!rated_power.has_value() || rated_power.value() <= 0.0 ||
|
|
!report_wind_speed_interval.has_value() || report_wind_speed_interval.value() <= 0.0 ||
|
|
report_wind_speed_interval.value() > 2.0) {
|
|
SendError(callback, kErrorInvalidRequest, "方案二参数必须为正数");
|
|
return;
|
|
}
|
|
}
|
|
|
|
auto schemes = LoadSchemes();
|
|
bool updated = false;
|
|
for (auto& scheme : schemes) {
|
|
if (scheme.id == normalized_id) {
|
|
scheme.description = description.value();
|
|
if (scheme.id == kDefaultSchemeId) {
|
|
const auto& params = scheme_one_parameters.value();
|
|
scheme.scheme_one_rated_power = GetDoubleField(params, "rated_power").value();
|
|
scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value();
|
|
scheme.scheme_one_power_step = GetDoubleField(params, "power_step").value();
|
|
scheme.scheme_one_cleaning_wind_speed_step =
|
|
GetDoubleField(params, "cleaning_wind_speed_step").value();
|
|
scheme.scheme_one_wind_speed_change_threshold =
|
|
GetDoubleField(params, "wind_speed_change_threshold").value();
|
|
scheme.scheme_one_iqr_lower_multiplier =
|
|
GetDoubleField(params, "iqr_lower_multiplier").value();
|
|
scheme.scheme_one_iqr_upper_multiplier =
|
|
GetDoubleField(params, "iqr_upper_multiplier").value();
|
|
scheme.scheme_one_minimum_generator_speed =
|
|
GetDoubleField(params, "minimum_generator_speed").value();
|
|
scheme.scheme_one_generator_speed_k =
|
|
GetDoubleField(params, "generator_speed_k").value();
|
|
scheme.scheme_one_rotor_radius = GetDoubleField(params, "rotor_radius").value();
|
|
scheme.scheme_one_gearbox_ratio = GetDoubleField(params, "gearbox_ratio").value();
|
|
scheme.scheme_one_report_wind_speed_interval =
|
|
GetDoubleField(params, "report_wind_speed_interval").value();
|
|
} else if (scheme.id == kSchemeTwoId) {
|
|
if (grid_connected_speed.has_value()) {
|
|
scheme.grid_connected_speed = grid_connected_speed.value();
|
|
}
|
|
if (rated_generator_speed.has_value()) {
|
|
scheme.rated_generator_speed = rated_generator_speed.value();
|
|
}
|
|
if (rated_power.has_value()) {
|
|
scheme.rated_power = rated_power.value();
|
|
}
|
|
if (report_wind_speed_interval.has_value()) {
|
|
scheme.scheme_two_report_wind_speed_interval =
|
|
report_wind_speed_interval.value();
|
|
}
|
|
}
|
|
updated = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!updated) {
|
|
SendError(callback, kErrorInvalidRequest, "方案不存在");
|
|
return;
|
|
}
|
|
if (!SaveSchemes(schemes, normalized_id)) {
|
|
SendError(callback, kErrorServer, "保存方案描述失败");
|
|
return;
|
|
}
|
|
|
|
const auto saved_scheme = FindScheme(schemes, normalized_id);
|
|
json data;
|
|
data["default_scheme_id"] = normalized_id;
|
|
data["scheme"] = SchemeToJson(saved_scheme.value());
|
|
SendSuccess(callback, data);
|
|
}
|
|
|
|
void WindPowerController::GetChartOptions(
|
|
const HttpRequestPtr&,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
json data;
|
|
const auto options = LoadChartOptions();
|
|
data["configured"] = options.has_value();
|
|
if (options.has_value()) {
|
|
data["options"] = options.value();
|
|
}
|
|
SendSuccess(callback, data);
|
|
}
|
|
|
|
void WindPowerController::SaveChartOptions(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
std::string error;
|
|
const auto options = ParseBody(req, error);
|
|
if (!options.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
if (!ValidateChartOptions(options.value(), error)) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
if (!SaveChartOptionsToFile(options.value())) {
|
|
SendError(callback, kErrorServer, "保存图表参数失败");
|
|
return;
|
|
}
|
|
json data;
|
|
data["configured"] = true;
|
|
data["options"] = options.value();
|
|
SendSuccess(callback, data);
|
|
}
|
|
|
|
void WindPowerController::StartJob(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
std::string error;
|
|
const auto body = ParseBody(req, error);
|
|
if (!body.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
|
|
if (!body->contains("files") || !(*body)["files"].is_array() ||
|
|
!body->contains("mapping") || !(*body)["mapping"].is_object()) {
|
|
SendError(callback, kErrorInvalidRequest, "缺少 files 或 mapping 参数");
|
|
return;
|
|
}
|
|
|
|
ExpireIdleUploadTask();
|
|
CleanupExpiredCompletedJobs();
|
|
const auto job_id = GenerateJobId();
|
|
if (!AcquireTask(job_id, true)) {
|
|
SendError(callback, kErrorJobBusy, "服务器正在处理数据,请等待当前任务完成");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs::create_directories(JobsRoot());
|
|
fs::create_directories(JobDir(job_id));
|
|
|
|
std::ofstream meta(JobDir(job_id) / "metadata.json", std::ios::trunc);
|
|
meta << body->dump(2);
|
|
meta.close();
|
|
|
|
std::ofstream rows(JobRowsPath(job_id), std::ios::trunc);
|
|
rows.close();
|
|
std::ofstream raw_rows(JobRawRowsPath(job_id), std::ios::trunc);
|
|
raw_rows.close();
|
|
|
|
json data;
|
|
data["job_id"] = job_id;
|
|
SendSuccess(callback, data);
|
|
} catch (const std::exception&) {
|
|
ReleaseTask(job_id);
|
|
SendError(callback, kErrorServer, "创建计算任务失败");
|
|
}
|
|
}
|
|
|
|
void WindPowerController::UploadChunk(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
std::string error;
|
|
const auto body = ParseBody(req, error);
|
|
if (!body.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
|
|
const auto job_id = GetStringField(*body, "job_id");
|
|
if (!job_id.has_value() || !IsSafeJobId(job_id.value()) ||
|
|
!fs::exists(JobDir(job_id.value()))) {
|
|
SendError(callback, kErrorJobNotFound, "计算任务不存在");
|
|
return;
|
|
}
|
|
if (!body->contains("rows") || !(*body)["rows"].is_array()) {
|
|
SendError(callback, kErrorInvalidRequest, "缺少 rows 参数");
|
|
return;
|
|
}
|
|
if (IsTaskBusyFor(job_id.value())) {
|
|
SendError(callback, kErrorJobBusy, "服务器正在处理数据,请等待当前任务完成");
|
|
return;
|
|
}
|
|
try {
|
|
std::ofstream out(JobRowsPath(job_id.value()), std::ios::app);
|
|
std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app);
|
|
int accepted = 0;
|
|
for (const auto& row : (*body)["rows"]) {
|
|
if (!row.is_object()) {
|
|
continue;
|
|
}
|
|
out << row.dump() << '\n';
|
|
++accepted;
|
|
}
|
|
out.close();
|
|
if (body->contains("raw_rows") && (*body)["raw_rows"].is_array()) {
|
|
for (const auto& row : (*body)["raw_rows"]) {
|
|
if (row.is_object()) raw_out << row.dump() << '\n';
|
|
}
|
|
}
|
|
raw_out.close();
|
|
TouchTask(job_id.value());
|
|
|
|
json data;
|
|
data["accepted_rows"] = accepted;
|
|
SendSuccess(callback, data);
|
|
} catch (const std::exception&) {
|
|
SendError(callback, kErrorServer, "保存分片数据失败");
|
|
}
|
|
}
|
|
|
|
void WindPowerController::FinishJob(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
|
std::string error;
|
|
const auto body = ParseBody(req, error);
|
|
if (!body.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
|
|
const auto job_id = GetStringField(*body, "job_id");
|
|
if (!job_id.has_value() || !IsSafeJobId(job_id.value()) ||
|
|
!fs::exists(JobRowsPath(job_id.value()))) {
|
|
SendError(callback, kErrorJobNotFound, "计算任务不存在");
|
|
return;
|
|
}
|
|
if (IsTaskBusyFor(job_id.value())) {
|
|
SendError(callback, kErrorJobBusy, "服务器正在处理数据,请等待当前任务完成");
|
|
return;
|
|
}
|
|
TaskReleaseGuard finish_guard(job_id.value());
|
|
|
|
const CalculationOptions options = ParseOptions(*body);
|
|
if (body->contains("options") && (*body)["options"].is_object() &&
|
|
(*body)["options"].contains("report_wind_speed_interval")) {
|
|
const auto interval = GetDoubleField((*body)["options"], "report_wind_speed_interval");
|
|
if (!interval.has_value() || !std::isfinite(interval.value()) ||
|
|
interval.value() <= 0.0 || interval.value() > 2.0) {
|
|
SendError(callback, kErrorInvalidRequest,
|
|
"报告公式风速区间半宽必须大于 0 且不超过 2");
|
|
return;
|
|
}
|
|
}
|
|
if (IsSchemeTwo(options) &&
|
|
(!options.rated_power_provided ||
|
|
!options.grid_connected_speed_provided ||
|
|
!options.rated_generator_speed_provided)) {
|
|
SendError(callback, kErrorInvalidRequest, "方案二需要填写并网转速、额定转速和额定功率");
|
|
return;
|
|
}
|
|
|
|
std::vector<ValidRow> parsed_rows;
|
|
std::unordered_map<std::string, int> invalid_reasons;
|
|
int raw_rows = 0;
|
|
|
|
try {
|
|
std::ifstream in(JobRowsPath(job_id.value()));
|
|
std::string line;
|
|
while (std::getline(in, line)) {
|
|
if (Trim(line).empty()) {
|
|
continue;
|
|
}
|
|
++raw_rows;
|
|
|
|
json row;
|
|
try {
|
|
row = json::parse(line);
|
|
} catch (const std::exception&) {
|
|
AddInvalid(invalid_reasons, "invalid_json");
|
|
continue;
|
|
}
|
|
|
|
const auto time_text = GetStringField(row, "time");
|
|
const auto fan_id = GetStringField(row, "fan_id");
|
|
const auto wind_speed = GetDoubleField(row, "wind_speed");
|
|
const auto active_power = GetDoubleField(row, "active_power");
|
|
const auto generator_speed = GetDoubleField(row, "generator_speed");
|
|
const auto blade_pitch_1 = GetDoubleField(row, "blade_pitch_1");
|
|
const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2");
|
|
const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3");
|
|
|
|
if (!active_power.has_value() || !std::isfinite(active_power.value()) ||
|
|
active_power.value() <= 0.0) {
|
|
AddInvalid(invalid_reasons, "invalid_active_power");
|
|
continue;
|
|
}
|
|
if (!generator_speed.has_value() || !std::isfinite(generator_speed.value()) ||
|
|
generator_speed.value() <
|
|
options.generator_speed_k * options.minimum_generator_speed) {
|
|
AddInvalid(invalid_reasons, "invalid_generator_speed");
|
|
continue;
|
|
}
|
|
if (IsSchemeTwo(options) &&
|
|
(!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) ||
|
|
!blade_pitch_2.has_value() || !std::isfinite(blade_pitch_2.value()) ||
|
|
!blade_pitch_3.has_value() || !std::isfinite(blade_pitch_3.value()))) {
|
|
AddInvalid(invalid_reasons, "invalid_pitch_angle");
|
|
continue;
|
|
}
|
|
if (!time_text.has_value()) {
|
|
AddInvalid(invalid_reasons, "invalid_time");
|
|
continue;
|
|
}
|
|
const auto timestamp = ParseTime(time_text.value());
|
|
if (!timestamp.has_value()) {
|
|
AddInvalid(invalid_reasons, "invalid_time");
|
|
continue;
|
|
}
|
|
if (!fan_id.has_value() || fan_id.value().empty()) {
|
|
AddInvalid(invalid_reasons, "empty_fan_id");
|
|
continue;
|
|
}
|
|
if (!wind_speed.has_value() || !std::isfinite(wind_speed.value()) ||
|
|
wind_speed.value() <= 0.0) {
|
|
AddInvalid(invalid_reasons, "invalid_wind_speed");
|
|
continue;
|
|
}
|
|
|
|
ValidRow valid_row;
|
|
valid_row.time = time_text.value();
|
|
valid_row.timestamp = timestamp.value();
|
|
valid_row.date = DatePart(time_text.value());
|
|
valid_row.fan_id = fan_id.value();
|
|
valid_row.wind_speed = wind_speed.value();
|
|
valid_row.active_power = active_power.value();
|
|
valid_row.generator_speed = generator_speed.value();
|
|
if (IsSchemeTwo(options)) {
|
|
valid_row.blade_pitch_1 = blade_pitch_1.value();
|
|
valid_row.blade_pitch_2 = blade_pitch_2.value();
|
|
valid_row.blade_pitch_3 = blade_pitch_3.value();
|
|
valid_row.pitch_angle_average =
|
|
(valid_row.blade_pitch_1 +
|
|
valid_row.blade_pitch_2 +
|
|
valid_row.blade_pitch_3) / 3.0;
|
|
}
|
|
parsed_rows.push_back(valid_row);
|
|
}
|
|
} catch (const std::exception&) {
|
|
SendError(callback, kErrorServer, "读取任务数据失败");
|
|
return;
|
|
}
|
|
|
|
std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) {
|
|
if (left.fan_id != right.fan_id) {
|
|
return left.fan_id < right.fan_id;
|
|
}
|
|
return left.timestamp < right.timestamp;
|
|
});
|
|
|
|
std::vector<ValidRow> deduped_rows;
|
|
std::set<std::string> seen_keys;
|
|
int duplicate_rows = 0;
|
|
for (const auto& row : parsed_rows) {
|
|
const auto key = row.fan_id + "|" + std::to_string(row.timestamp);
|
|
if (!seen_keys.insert(key).second) {
|
|
++duplicate_rows;
|
|
continue;
|
|
}
|
|
deduped_rows.push_back(row);
|
|
}
|
|
if (duplicate_rows > 0) {
|
|
AddInvalid(invalid_reasons, "duplicate_time");
|
|
}
|
|
invalid_reasons["duplicate_time"] = duplicate_rows;
|
|
|
|
std::unordered_map<std::string, std::vector<ValidRow>> rows_by_fan;
|
|
for (const auto& row : deduped_rows) {
|
|
rows_by_fan[row.fan_id].push_back(row);
|
|
}
|
|
|
|
json fans = json::array();
|
|
json curves = json::object();
|
|
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());
|
|
for (const auto& item : rows_by_fan) {
|
|
fan_ids.push_back(item.first);
|
|
}
|
|
std::sort(fan_ids.begin(), fan_ids.end());
|
|
|
|
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 scheme_two_grid_speed_low_count = 0;
|
|
int scheme_two_low_power_pitch_wind_count = 0;
|
|
int scheme_two_low_speed_pitch_count = 0;
|
|
int scheme_two_low_power_pitch_count = 0;
|
|
int cleaned_rows_count = 0;
|
|
for (const auto& fan_id : fan_ids) {
|
|
fans.push_back(fan_id);
|
|
auto fan_rows = rows_by_fan[fan_id];
|
|
std::vector<RemovedPoint> fan_removed_points;
|
|
if (IsSchemeTwo(options)) {
|
|
ComputeWindSpeed600sAverage(fan_rows);
|
|
}
|
|
|
|
int removed = 0;
|
|
fan_rows = FilterSchemeTwoGridSpeed(
|
|
fan_rows,
|
|
options,
|
|
removed,
|
|
fan_removed_points);
|
|
scheme_two_grid_speed_low_count += removed;
|
|
|
|
const auto initial_estimated_params = EstimateRatedParams(fan_rows, options);
|
|
|
|
fan_rows = FilterLimitPower(
|
|
fan_rows,
|
|
options,
|
|
&initial_estimated_params,
|
|
removed,
|
|
fan_removed_points);
|
|
limit_power_count += removed;
|
|
|
|
for (auto& row : fan_rows) {
|
|
row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio *
|
|
options.rotor_radius * 30.0 /
|
|
row.wind_speed;
|
|
}
|
|
|
|
fan_rows = FilterByWindBinIqr(
|
|
fan_rows,
|
|
options,
|
|
[](const ValidRow& row) { return row.tip_speed_ratio; },
|
|
removed,
|
|
fan_removed_points,
|
|
"tip_speed_ratio_outlier");
|
|
tip_speed_ratio_outlier_count += removed;
|
|
|
|
fan_rows = FilterByWindBinIqr(
|
|
fan_rows,
|
|
options,
|
|
[](const ValidRow& row) { return row.active_power; },
|
|
removed,
|
|
fan_removed_points,
|
|
"speed_power_outlier",
|
|
&initial_estimated_params);
|
|
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;
|
|
|
|
int low_power_pitch_wind_removed = 0;
|
|
int low_speed_pitch_removed = 0;
|
|
int low_power_pitch_removed = 0;
|
|
fan_rows = FilterSchemeTwoPitchRules(
|
|
fan_rows,
|
|
options,
|
|
low_power_pitch_wind_removed,
|
|
low_speed_pitch_removed,
|
|
low_power_pitch_removed,
|
|
fan_removed_points);
|
|
scheme_two_low_power_pitch_wind_count += low_power_pitch_wind_removed;
|
|
scheme_two_low_speed_pitch_count += low_speed_pitch_removed;
|
|
scheme_two_low_power_pitch_count += low_power_pitch_removed;
|
|
|
|
cleaned_rows_count += static_cast<int>(fan_rows.size());
|
|
|
|
json fan_scatter = json::array();
|
|
for (const auto& row : fan_rows) {
|
|
json point;
|
|
point["fan_id"] = row.fan_id;
|
|
point["time"] = row.time;
|
|
point["wind_speed"] = row.wind_speed;
|
|
point["active_power"] = row.active_power;
|
|
point["generator_speed"] = row.generator_speed;
|
|
point["pitch_angle_average"] = row.pitch_angle_average;
|
|
fan_scatter.push_back(point);
|
|
}
|
|
scatter_points[fan_id] = fan_scatter;
|
|
|
|
json fan_filtered = json::array();
|
|
for (const auto& removed_point : fan_removed_points) {
|
|
json point;
|
|
point["fan_id"] = removed_point.row.fan_id;
|
|
point["time"] = removed_point.row.time;
|
|
point["wind_speed"] = removed_point.row.wind_speed;
|
|
point["active_power"] = removed_point.row.active_power;
|
|
point["generator_speed"] = removed_point.row.generator_speed;
|
|
point["pitch_angle_average"] = removed_point.row.pitch_angle_average;
|
|
point["reason"] = removed_point.reason;
|
|
fan_filtered.push_back(point);
|
|
}
|
|
filtered_points[fan_id] = fan_filtered;
|
|
|
|
json fan_curve = json::array();
|
|
json fan_bins_json = json::array();
|
|
const double curve_min = 1.0 - options.curve_wind_speed_step * 0.5;
|
|
const double curve_max = 25.0 + options.curve_wind_speed_step * 0.5;
|
|
for (double start = curve_min; start < curve_max; start += options.curve_wind_speed_step) {
|
|
const double end = start + options.curve_wind_speed_step;
|
|
std::vector<double> values;
|
|
for (const auto& row : fan_rows) {
|
|
if (row.wind_speed > start && row.wind_speed <= end) {
|
|
values.push_back(row.active_power);
|
|
}
|
|
}
|
|
|
|
if (!values.empty()) {
|
|
json point;
|
|
point["wind_speed_start"] = start;
|
|
point["wind_speed_end"] = end;
|
|
point["wind_speed"] = (start + end) / 2.0;
|
|
point["sample_count"] = values.size();
|
|
point["average_power"] = Mean(values);
|
|
point["median_power"] = Quantile(values, 0.5);
|
|
point["stddev_power"] = StdDev(values, point["average_power"].get<double>());
|
|
point["p25_power"] = Quantile(values, 0.25);
|
|
point["p75_power"] = Quantile(values, 0.75);
|
|
point["confidence"] = "脚本分箱";
|
|
fan_curve.push_back(point);
|
|
fan_bins_json.push_back(point);
|
|
}
|
|
}
|
|
|
|
curves[fan_id] = fan_curve;
|
|
bins[fan_id] = fan_bins_json;
|
|
}
|
|
|
|
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;
|
|
invalid_reasons["scheme_two_grid_speed_low"] = scheme_two_grid_speed_low_count;
|
|
invalid_reasons["scheme_two_low_power_pitch_wind"] =
|
|
scheme_two_low_power_pitch_wind_count;
|
|
invalid_reasons["scheme_two_low_speed_pitch"] = scheme_two_low_speed_pitch_count;
|
|
invalid_reasons["scheme_two_low_power_pitch"] = scheme_two_low_power_pitch_count;
|
|
|
|
const int invalid_total = raw_rows - cleaned_rows_count;
|
|
|
|
json summary;
|
|
summary["raw_rows"] = raw_rows;
|
|
summary["valid_rows"] = std::max(0, cleaned_rows_count);
|
|
summary["invalid_rows"] = std::max(0, invalid_total);
|
|
summary["duplicate_rows"] = duplicate_rows;
|
|
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["scheme_two_grid_speed_low_rows"] = scheme_two_grid_speed_low_count;
|
|
summary["scheme_two_low_power_pitch_wind_rows"] =
|
|
scheme_two_low_power_pitch_wind_count;
|
|
summary["scheme_two_low_speed_pitch_rows"] = scheme_two_low_speed_pitch_count;
|
|
summary["scheme_two_low_power_pitch_rows"] = scheme_two_low_power_pitch_count;
|
|
summary["invalid_reasons"] = CounterJson(invalid_reasons);
|
|
summary["fan_count"] = fans.size();
|
|
|
|
json data;
|
|
const auto selected_scheme = FindScheme(LoadSchemes(), options.scheme_id);
|
|
data["scheme"] = SchemeToJson(selected_scheme.value());
|
|
data["scheme"]["parameters"]["report_wind_speed_interval"] =
|
|
options.report_wind_speed_interval;
|
|
data["summary"] = summary;
|
|
data["fans"] = fans;
|
|
data["curves"] = curves;
|
|
data["bins"] = bins;
|
|
data["scatter_points"] = scatter_points;
|
|
data["filtered_points"] = filtered_points;
|
|
data["estimated_params"] = estimated_params;
|
|
|
|
try {
|
|
std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc);
|
|
result_file << data.dump();
|
|
} catch (const std::exception&) {
|
|
SendError(callback, kErrorServer, "保存计算结果失败");
|
|
return;
|
|
}
|
|
|
|
SendSuccess(callback, data);
|
|
}
|
|
|
|
void WindPowerController::ExportReport(
|
|
const HttpRequestPtr& req,
|
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
|
const std::string& job_id) {
|
|
if (!IsSafeJobId(job_id) || !fs::exists(JobResultPath(job_id))) {
|
|
SendError(callback, kErrorJobNotFound, "计算任务不存在或结果已过期");
|
|
return;
|
|
}
|
|
std::string error;
|
|
const auto body = ParseBody(req, error);
|
|
if (!body.has_value()) {
|
|
SendError(callback, kErrorInvalidRequest, error);
|
|
return;
|
|
}
|
|
const auto fan_id = GetStringField(*body, "fan_id");
|
|
if (!fan_id.has_value() || !body->contains("effective_rows") ||
|
|
!(*body)["effective_rows"].is_array() || !body->contains("report_rows") ||
|
|
!(*body)["report_rows"].is_array()) {
|
|
SendError(callback, kErrorInvalidRequest, "报告参数不完整");
|
|
return;
|
|
}
|
|
if (!AcquireTask(job_id, false)) {
|
|
SendError(callback, kErrorJobBusy, "服务器正在处理数据,请等待当前任务完成");
|
|
return;
|
|
}
|
|
TaskReleaseGuard report_guard(job_id);
|
|
|
|
try {
|
|
json metadata;
|
|
json result;
|
|
{ std::ifstream input(JobDir(job_id) / "metadata.json"); input >> metadata; }
|
|
{ std::ifstream input(JobResultPath(job_id)); input >> result; }
|
|
const auto headers = metadata.value("raw_headers", json::array());
|
|
const auto mapping = metadata.value("mapping", json::object());
|
|
const auto fan_header = mapping.value("fan_id", "");
|
|
const auto wind_header = mapping.value("wind_speed", "");
|
|
if (!headers.is_array() || fan_header.empty() || wind_header.empty()) {
|
|
SendError(callback, kErrorServer, "任务未保存原始数据列,无法导出完整报告");
|
|
return;
|
|
}
|
|
size_t fan_column = headers.size();
|
|
size_t wind_column = headers.size();
|
|
for (size_t i = 0; i < headers.size(); ++i) {
|
|
const auto header = headers[i].is_string() ? headers[i].get<std::string>() : "";
|
|
if (header == fan_header) fan_column = i;
|
|
if (header == wind_header) wind_column = i;
|
|
}
|
|
if (fan_column == headers.size() || wind_column == headers.size()) {
|
|
SendError(callback, kErrorServer, "原始数据缺少风机编号或风速列");
|
|
return;
|
|
}
|
|
|
|
const auto report_path = JobDir(job_id) / ("report_" + FileNameForFan(fan_id.value()) + ".xlsx");
|
|
lxw_workbook_options options{};
|
|
const auto temp_dir = JobDir(job_id).string();
|
|
options.constant_memory = LXW_TRUE;
|
|
options.use_zip64 = LXW_TRUE;
|
|
options.tmpdir = const_cast<char*>(temp_dir.c_str());
|
|
lxw_workbook* workbook = workbook_new_opt(report_path.string().c_str(), &options);
|
|
if (!workbook) throw std::runtime_error("无法创建 Excel 工作簿");
|
|
lxw_format* header_format = workbook_add_format(workbook);
|
|
format_set_bold(header_format);
|
|
format_set_bg_color(header_format, 0xE2E8F0);
|
|
format_set_align(header_format, LXW_ALIGN_CENTER);
|
|
lxw_format* number_format = workbook_add_format(workbook);
|
|
format_set_num_format(number_format, "0.0000");
|
|
|
|
lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据");
|
|
worksheet_freeze_panes(detail, 1, 0);
|
|
const bool include_pitch = result.value("scheme", json::object()).value("id", "") == kSchemeTwoId;
|
|
const std::vector<std::string> detail_headers = include_pitch
|
|
? std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"}
|
|
: std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"};
|
|
for (size_t i = 0; i < detail_headers.size(); ++i) {
|
|
worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format);
|
|
worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr);
|
|
}
|
|
lxw_row_t detail_row = 1;
|
|
for (const auto& point : (*body)["effective_rows"]) {
|
|
if (!point.is_object()) continue;
|
|
worksheet_write_string(detail, detail_row, 0, point.value("fan_id", fan_id.value()).c_str(), nullptr);
|
|
worksheet_write_string(detail, detail_row, 1, point.value("time", "").c_str(), nullptr);
|
|
worksheet_write_number(detail, detail_row, 2, point.value("active_power", 0.0), number_format);
|
|
worksheet_write_number(detail, detail_row, 3, point.value("generator_speed", 0.0), number_format);
|
|
worksheet_write_number(detail, detail_row, 4, point.value("wind_speed", 0.0), number_format);
|
|
if (include_pitch) worksheet_write_number(detail, detail_row, 5, point.value("pitch_angle_average", 0.0), number_format);
|
|
++detail_row;
|
|
}
|
|
worksheet_autofilter(detail, 0, 0, std::max<lxw_row_t>(1, detail_row - 1), detail_headers.size() - 1);
|
|
|
|
lxw_worksheet* raw = workbook_add_worksheet(workbook, "筛选前的数据");
|
|
worksheet_freeze_panes(raw, 1, 0);
|
|
for (size_t i = 0; i < headers.size(); ++i) {
|
|
const auto header = headers[i].is_string() ? headers[i].get<std::string>() : "";
|
|
worksheet_write_string(raw, 0, i, header.c_str(), header_format);
|
|
worksheet_set_column(raw, i, i, 16, nullptr);
|
|
}
|
|
std::set<std::string> source_files;
|
|
lxw_row_t raw_row = 1;
|
|
std::ifstream raw_input(JobRawRowsPath(job_id));
|
|
std::string line;
|
|
while (std::getline(raw_input, line)) {
|
|
const auto source = json::parse(line, nullptr, false);
|
|
if (source.is_discarded() || !source.contains("values") || !source["values"].is_array()) continue;
|
|
const auto& values = source["values"];
|
|
if (fan_column >= values.size() || JsonText(values[fan_column]) != fan_id.value()) continue;
|
|
source_files.insert(source.value("file_name", ""));
|
|
for (size_t column = 0; column < values.size(); ++column) WriteJsonCell(raw, raw_row, column, values[column]);
|
|
++raw_row;
|
|
}
|
|
if (source_files.size() != 1) {
|
|
workbook_close(workbook);
|
|
SendError(callback, kErrorInvalidRequest, "完整报告要求当前风机对应唯一一份上传 Excel 文件");
|
|
return;
|
|
}
|
|
worksheet_autofilter(raw, 0, 0, std::max<lxw_row_t>(1, raw_row - 1), headers.size() - 1);
|
|
|
|
lxw_worksheet* curve = workbook_add_worksheet(workbook, "功率曲线计算表");
|
|
worksheet_freeze_panes(curve, 1, 0);
|
|
const std::vector<std::string> curve_headers = {"序号", "风速 (m/s)", "风频时间 (h)", "计算功率 (kW)", "保证功率 (kW)", "计算发电量 (kWh)", "理论发电量 (kWh)", "", "K值"};
|
|
for (size_t i = 0; i < curve_headers.size(); ++i) {
|
|
worksheet_write_string(curve, 0, i, curve_headers[i].c_str(), header_format);
|
|
worksheet_set_column(curve, i, i, i == 0 ? 9 : 17, nullptr);
|
|
}
|
|
const auto raw_wind = ExcelColumnName(wind_column);
|
|
const auto report_interval = result.value("scheme", json::object())
|
|
.value("parameters", json::object())
|
|
.value("report_wind_speed_interval", 0.25);
|
|
const auto interval_text = std::to_string(report_interval);
|
|
const auto report_row_count = (*body)["report_rows"].size();
|
|
const auto k_last_row = std::max<size_t>(2, report_row_count + 1);
|
|
const auto k_formula = "=IFERROR(SUM(F2:F" + std::to_string(k_last_row) +
|
|
")/SUM(G2:G" + std::to_string(k_last_row) + "),0)";
|
|
lxw_row_t curve_row = 1;
|
|
for (const auto& point : (*body)["report_rows"]) {
|
|
const auto excel_row = curve_row + 1;
|
|
worksheet_write_number(curve, curve_row, 0, excel_row - 1, nullptr);
|
|
worksheet_write_number(curve, curve_row, 1, point.value("wind_speed", 0.0), number_format);
|
|
const auto frequency = "=(COUNTIFS('筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + ")/COUNT('筛选前的数据'!" + raw_wind + ":" + raw_wind + "))*8760";
|
|
worksheet_write_formula(curve, curve_row, 2, frequency.c_str(), number_format);
|
|
const auto actual = "=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!$E:$E,\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选后的数据'!$E:$E,\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + "),0)";
|
|
worksheet_write_formula(curve, curve_row, 3, actual.c_str(), number_format);
|
|
worksheet_write_number(curve, curve_row, 4, point.value("design_power", 0.0), number_format);
|
|
const auto generated = "=IFERROR(ROUND(C" + std::to_string(excel_row) + "*D" + std::to_string(excel_row) + "/1000,4),0)";
|
|
const auto theoretical = "=ROUND(C" + std::to_string(excel_row) + "*E" + std::to_string(excel_row) + "/1000,4)";
|
|
worksheet_write_formula(curve, curve_row, 5, generated.c_str(), number_format);
|
|
worksheet_write_formula(curve, curve_row, 6, theoretical.c_str(), number_format);
|
|
if (curve_row == 1) {
|
|
worksheet_write_formula(curve, curve_row, 8, k_formula.c_str(), number_format);
|
|
}
|
|
++curve_row;
|
|
}
|
|
if (report_row_count == 0) {
|
|
worksheet_write_formula(curve, 1, 8, k_formula.c_str(), number_format);
|
|
}
|
|
worksheet_autofilter(curve, 0, 0, std::max<lxw_row_t>(1, curve_row - 1), 6);
|
|
|
|
if (body->contains("chart_image") && (*body)["chart_image"].is_string()) {
|
|
auto image_data = (*body)["chart_image"].get<std::string>();
|
|
const auto comma = image_data.find(',');
|
|
if (comma != std::string::npos) image_data = image_data.substr(comma + 1);
|
|
const auto image_path = JobDir(job_id) / "report_chart.png";
|
|
std::ofstream image(image_path, std::ios::binary | std::ios::trunc);
|
|
image << drogon::utils::base64Decode(image_data);
|
|
image.close();
|
|
lxw_image_options image_options{};
|
|
image_options.x_scale = 0.5;
|
|
image_options.y_scale = 0.5;
|
|
worksheet_insert_image_opt(curve, 3, 8, image_path.string().c_str(),
|
|
&image_options);
|
|
}
|
|
if (workbook_close(workbook) != LXW_NO_ERROR) throw std::runtime_error("写入 Excel 文件失败");
|
|
callback(HttpResponse::newFileResponse(report_path.string(),
|
|
"完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx",
|
|
CT_CUSTOM,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
|
} catch (const std::exception&) {
|
|
SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试");
|
|
}
|
|
}
|
|
|
|
void WindPowerController::DeleteJob(
|
|
const HttpRequestPtr&,
|
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
|
const std::string& job_id) {
|
|
if (!IsSafeJobId(job_id)) {
|
|
SendError(callback, kErrorInvalidRequest, "任务编号非法");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs::remove_all(JobDir(job_id));
|
|
ReleaseTask(job_id);
|
|
SendSuccess(callback);
|
|
} catch (const std::exception&) {
|
|
SendError(callback, kErrorServer, "清理任务失败");
|
|
}
|
|
}
|