diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 438aef4..7fce60b 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -36,6 +36,13 @@ endif() find_package(Drogon CONFIG REQUIRED) find_package(Threads REQUIRED) +# libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。 +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +add_subdirectory(${REPO_ROOT}/third_party/libxlsxwriter_repo + ${CMAKE_CURRENT_BINARY_DIR}/libxlsxwriter) + file(GLOB_RECURSE SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/*.h" @@ -54,6 +61,7 @@ target_include_directories(wind_server PRIVATE target_link_libraries(wind_server PRIVATE Drogon::Drogon Threads::Threads + xlsxwriter ) # 运行期 .so 解析:RPATH 指向 Drogon install 的 libs 目录 diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index fb7fcf5..c050172 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,11 @@ #include #include +#include +#include +#include +#include + using json = nlohmann::json; namespace fs = std::filesystem; @@ -25,6 +31,9 @@ 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 = "方案一"; @@ -65,15 +74,17 @@ struct RemovedPoint { struct CalculationOptions { std::string scheme_id = kDefaultSchemeId; double rated_power = 4800.0; - double rated_wind_speed = 18.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 wind_speed_change_threshold = 1.0; - double iqr_lower_multiplier = 1.8; + 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; @@ -97,6 +108,17 @@ 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 grid_connected_speed = 1030.0; double rated_generator_speed = 1755.0; double rated_power = 2000.0; @@ -137,6 +159,123 @@ 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 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 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 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 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 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('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(), format); + } else if (value.is_boolean()) { + worksheet_write_boolean(sheet, row, column, value.get(), format); + } else if (!value.is_null()) { + const auto text = value.is_string() ? value.get() : 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(); + if (value.is_number_integer()) return std::to_string(value.get()); + if (value.is_number_unsigned()) return std::to_string(value.get()); + if (value.is_number_float()) { + std::ostringstream output; + output << value.get(); + 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(ch)) ? ch : '_'; + return output.empty() ? "wind_turbine" : output; +} + fs::path SchemeConfigPath() { return fs::path("data") / "wind_schemes.json"; } @@ -347,10 +486,16 @@ std::string NormalizeSchemeId(const std::string& scheme_id) { } std::vector DefaultSchemes() { - return { - {kDefaultSchemeId, kSchemeOneName, kSchemeOneDefaultDescription}, - {kSchemeTwoId, kSchemeTwoName, kSchemeTwoDefaultDescription, 1030.0, 1755.0, 2000.0}, - }; + 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 FindScheme(const std::vector& schemes, @@ -369,7 +514,21 @@ json SchemeToJson(const SchemeInfo& scheme) { data["id"] = scheme.id; data["name"] = scheme.name; data["description"] = scheme.description; - if (scheme.id == kSchemeTwoId) { + 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}, + }; + } 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; @@ -401,10 +560,37 @@ std::vector LoadSchemes() { if (description.has_value()) { scheme.description = description.value(); } - if (scheme.id == kSchemeTwoId && - item.contains("parameters") && - item["parameters"].is_object()) { - const auto& params = item["parameters"]; + 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); + } 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(); @@ -624,6 +810,14 @@ CalculationOptions ParseOptions(const json& body) { 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(); @@ -1161,10 +1355,43 @@ void WindPowerController::SaveSchemeDescription( return; } + std::optional scheme_one_parameters; std::optional grid_connected_speed; std::optional rated_generator_speed; std::optional rated_power; - if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) { + 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_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, "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; @@ -1186,7 +1413,26 @@ void WindPowerController::SaveSchemeDescription( for (auto& scheme : schemes) { if (scheme.id == normalized_id) { scheme.description = description.value(); - if (scheme.id == kSchemeTwoId) { + 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(); + } else if (scheme.id == kSchemeTwoId) { if (grid_connected_speed.has_value()) { scheme.grid_connected_speed = grid_connected_speed.value(); } @@ -1268,9 +1514,16 @@ void WindPowerController::StartJob( return; } + ExpireIdleUploadTask(); + CleanupExpiredCompletedJobs(); + const auto job_id = GenerateJobId(); + if (!AcquireTask(job_id, true)) { + SendError(callback, kErrorJobBusy, "服务器正在处理数据,请等待当前任务完成"); + return; + } + try { fs::create_directories(JobsRoot()); - const auto job_id = GenerateJobId(); fs::create_directories(JobDir(job_id)); std::ofstream meta(JobDir(job_id) / "metadata.json", std::ios::trunc); @@ -1279,11 +1532,14 @@ void WindPowerController::StartJob( 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, "创建计算任务失败"); } } @@ -1308,9 +1564,13 @@ void WindPowerController::UploadChunk( 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()) { @@ -1320,6 +1580,13 @@ void WindPowerController::UploadChunk( ++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; @@ -1345,6 +1612,11 @@ void WindPowerController::FinishJob( 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 (IsSchemeTwo(options) && @@ -1526,7 +1798,8 @@ void WindPowerController::FinishJob( limit_power_count += removed; for (auto& row : fan_rows) { - row.tip_speed_ratio = row.generator_speed * 3.14 * 162.0 * 78.0 * 30.0 / + row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio * + options.rotor_radius * 30.0 / row.wind_speed; } @@ -1700,14 +1973,182 @@ void WindPowerController::FinishJob( data["estimated_params"] = estimated_params; try { - fs::remove_all(JobDir(job_id.value())); + 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&& 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() : ""; + 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(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 detail_headers = include_pitch + ? std::vector{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"} + : std::vector{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"}; + 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(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() : ""; + worksheet_write_string(raw, 0, i, header.c_str(), header_format); + worksheet_set_column(raw, i, i, 16, nullptr); + } + std::set 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(1, raw_row - 1), headers.size() - 1); + + lxw_worksheet* curve = workbook_add_worksheet(workbook, "功率曲线计算表"); + worksheet_freeze_panes(curve, 1, 0); + const std::vector 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); + 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) + "-0.5,'筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\"<\"&B" + std::to_string(excel_row) + "+0.5)/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) + "-0.5,'筛选后的数据'!$E:$E,\"<\"&B" + std::to_string(excel_row) + "+0.5),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); + ++curve_row; + } + const auto last_row = std::max(2, curve_row); + const auto k_formula = "=IFERROR(SUM(F2:F" + std::to_string(last_row) + ")/SUM(G2:G" + std::to_string(last_row) + "),0)"; + worksheet_write_formula(curve, 1, 8, k_formula.c_str(), number_format); + worksheet_autofilter(curve, 0, 0, std::max(1, curve_row - 1), 6); + + if (body->contains("chart_image") && (*body)["chart_image"].is_string()) { + auto image_data = (*body)["chart_image"].get(); + 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(); + worksheet_insert_image(curve, 3, 8, image_path.string().c_str()); + } + 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&& callback, @@ -1719,6 +2160,7 @@ void WindPowerController::DeleteJob( try { fs::remove_all(JobDir(job_id)); + ReleaseTask(job_id); SendSuccess(callback); } catch (const std::exception&) { SendError(callback, kErrorServer, "清理任务失败"); diff --git a/backend/src/controllers/WindPowerController.h b/backend/src/controllers/WindPowerController.h index 8d879c7..c11e51d 100644 --- a/backend/src/controllers/WindPowerController.h +++ b/backend/src/controllers/WindPowerController.h @@ -14,6 +14,7 @@ public: ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post); ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", Post); ADD_METHOD_TO(WindPowerController::FinishJob, "/api/wind/jobs/finish", Post); + ADD_METHOD_TO(WindPowerController::ExportReport, "/api/wind/jobs/{1}/report", Post); ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete); ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get); ADD_METHOD_TO(WindPowerController::SaveSchemeDescription, @@ -38,6 +39,9 @@ public: std::function&& callback); void FinishJob(const HttpRequestPtr& req, std::function&& callback); + void ExportReport(const HttpRequestPtr& req, + std::function&& callback, + const std::string& job_id); void DeleteJob(const HttpRequestPtr& req, std::function&& callback, const std::string& job_id); diff --git a/deploy.sh b/deploy.sh index 5fc4e5d..458c78a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -93,7 +93,9 @@ fi step "3/5 上传应用与 systemd 配置..." ssh_cmd 'command -v rsync >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq rsync >/dev/null)' -sshpass -p "${SSH_PASSWORD}" rsync -az --delete -e "ssh ${SSH_OPTS[*]}" \ +# data/、uploads/ 和 logs/ 由远端服务在运行时维护;发布代码时必须保留它们。 +sshpass -p "${SSH_PASSWORD}" rsync -az --delete \ + --exclude '/data/' --exclude '/uploads/' --exclude '/logs/' -e "ssh ${SSH_OPTS[*]}" \ "${LOCAL_APP_DIR}/" "${TARGET}:${REMOTE_APP_PATH}/" sshpass -p "${SSH_PASSWORD}" scp "${SSH_OPTS[@]}" \ "${DEPLOY_STAGE}/${SERVICE_NAME}.service" \ diff --git a/docs/接口文档.md b/docs/接口文档.md index ba00be8..f3e3ba6 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -53,7 +53,7 @@ ### POST /api/wind/jobs/start -创建风功率计算任务。前端已完成 Excel 表头解析和字段映射,后端只保存任务元数据。 +创建风功率计算任务。服务器全局同一时刻仅允许一个上传、计算或报告生成任务;繁忙时返回 `status: 1004` 和“服务器正在处理数据,请等待当前任务完成”。 请求: @@ -71,7 +71,8 @@ "wind_speed": "风速(m/s)", "active_power": "有功功率(kW)", "generator_speed": "发电机转速(rpm)" - } + }, + "raw_headers": ["时间", "风机编号", "风速(m/s)", "有功功率(kW)", "发电机转速(rpm)"] } ``` @@ -89,7 +90,7 @@ ### POST /api/wind/jobs/chunk -上传标准化后的数据分片。每行字段必须使用标准字段名。 +上传标准化后的数据分片。`raw_rows` 保存原始 Excel 行,用于服务端完整报告的“筛选前的数据”工作表。 请求: @@ -105,6 +106,9 @@ "active_power": 114.3471, "generator_speed": 1109.3143 } + ], + "raw_rows": [ + {"file_name": "01_风机历史数据.xls", "values": ["2024-09-01 14:00:00", "01#", 3.414, 114.3471, 1109.3143]} ] } ``` @@ -131,14 +135,18 @@ { "job_id": "job_123", "options": { + "rated_power": 4800, + "rated_wind_speed": 14, "power_step": 5, "cleaning_wind_speed_step": 0.25, "curve_wind_speed_step": 0.5, "wind_speed_change_threshold": 1, - "iqr_lower_multiplier": 1.8, + "iqr_lower_multiplier": 1.2, "iqr_upper_multiplier": 2, "minimum_generator_speed": 1, - "generator_speed_k": 0.9 + "generator_speed_k": 0.9, + "rotor_radius": 78, + "gearbox_ratio": 162 } } ``` @@ -229,7 +237,7 @@ - `wind_speed <= 0` 的数据剔除。 - 同一风机同一时间重复记录保留第一条。 - 限功率识别按参考脚本执行:按功率分箱、按日期分组,组内风速跨度大于阈值时剔除。 -- 叶尖速比使用参考脚本公式 `generator_speed * 3.14 * 162 * 78 * 30 / wind_speed`,按风速分箱做 IQR 清洗。 +- 叶尖速比按 `generator_speed * 3.14 * gearbox_ratio * rotor_radius * 30 / wind_speed` 计算;`3.14` 和 `30` 固定,叶轮半径与齿轮箱传动比由方案一参数提供,再按风速分箱做 IQR 清洗。 - 风速-功率关系按同一风速分箱和 IQR 参数清洗。 - 每台风机在 IQR 清洗后自动估算平台功率和平台起始风速,返回到 `estimated_params`。 - 高风速平台区明显低于平台功率的点剔除为 `high_wind_low_power`。 @@ -239,6 +247,12 @@ - `scatter_points` 返回清洗后保留点,`filtered_points` 返回所有过滤阶段滤除的点和原因。 - `estimated_params.source` 为 `auto`、`auto_power_fallback_wind` 或 `fallback`。 +### POST /api/wind/jobs/{jobId}/report + +服务端以常量内存方式生成完整报告并直接返回 `.xlsx` 附件。计算完成的数据保留 24 小时;上传任务连续 30 分钟未活动会自动清理。 + +请求体包含 `fan_id`、当前编辑后的 `effective_rows`、`report_rows` 和可选的 `chart_image`(PNG data URL)。成功时响应为 Excel 文件流;失败时仍使用统一 JSON 响应信封。 + ### DELETE /api/wind/jobs/{job_id} 清理未完成任务的临时文件。 diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index a15ec59..fc0f108 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -254,6 +254,13 @@ margin-top: 14px; } +.schemeParamHint { + margin: 14px 0 0; + color: #64748b; + font-size: 13px; + line-height: 1.5; +} + .schemeParamGrid input { min-width: 0; width: 100%; @@ -805,6 +812,44 @@ td { .error { border-color: #fecaca; background: #fff1f2; color: #b91c1c; } .alert.info { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; } +.dialogBackdrop { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.42); +} + +.appDialog { + width: min(440px, 100%); + padding: 22px; + border: 1px solid #dbe3ed; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22); +} + +.appDialog h2 { + margin: 0; + color: #172033; + font-size: 18px; +} + +.appDialog p { + margin: 12px 0 0; + color: #475569; + font-size: 14px; + line-height: 1.6; +} + +.appDialogActions { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + .schemeDescriptionControl span, .fieldControl span { color: #334155; } .schemeDescriptionControl input, diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index 367b56c..25bcec5 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css'; import * as XLSX from 'xlsx'; import { deleteWindJob, + downloadWindReport, finishWindJob, getWindChartOptions, getWindSchemes, @@ -58,7 +59,24 @@ const EDIT_TOOL_ERASE = 'erase'; const DEFAULT_SCHEME_ID = 'scheme_one'; const SCHEME_TWO_ID = 'scheme_two'; const DEFAULT_SCHEMES = [ - { id: 'scheme_one', name: '方案一', description: '通用方案' }, + { + id: 'scheme_one', + name: '方案一', + description: '通用方案', + parameters: { + rated_power: 4800, + rated_wind_speed: 14, + power_step: 5, + cleaning_wind_speed_step: 0.25, + wind_speed_change_threshold: 1, + iqr_lower_multiplier: 1.2, + iqr_upper_multiplier: 2, + minimum_generator_speed: 1, + generator_speed_k: 0.9, + rotor_radius: 78, + gearbox_ratio: 162, + }, + }, { id: SCHEME_TWO_ID, name: '方案二', @@ -76,6 +94,32 @@ const DEFAULT_SCHEME_TWO_PARAMS = { rated_generator_speed: '1755', rated_power: '2000', }; +const DEFAULT_SCHEME_ONE_PARAMS = { + rated_power: '4800', + rated_wind_speed: '14', + power_step: '5', + cleaning_wind_speed_step: '0.25', + wind_speed_change_threshold: '1', + iqr_lower_multiplier: '1.2', + iqr_upper_multiplier: '2', + minimum_generator_speed: '1', + generator_speed_k: '0.9', + rotor_radius: '78', + gearbox_ratio: '162', +}; +const SCHEME_ONE_PARAM_FIELDS = [ + { key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' }, + { key: 'rated_wind_speed', label: '额定风速', unit: 'm/s', min: '0', step: '0.01' }, + { key: 'power_step', label: '功率分箱步长', unit: 'kW', min: '0', step: '0.01' }, + { key: 'cleaning_wind_speed_step', label: '清洗风速分箱步长', unit: 'm/s', min: '0', step: '0.01' }, + { key: 'wind_speed_change_threshold', label: '风速变化阈值', unit: 'm/s', min: '0', step: '0.01' }, + { key: 'iqr_lower_multiplier', label: 'IQR 下限系数', unit: '', min: '0', step: '0.01' }, + { key: 'iqr_upper_multiplier', label: 'IQR 上限系数', unit: '', min: '0', step: '0.01' }, + { key: 'minimum_generator_speed', label: '最小发电机转速', unit: 'rpm', min: '0', step: '0.01' }, + { key: 'generator_speed_k', label: '发电机转速系数 K', unit: '', min: '0', step: '0.01' }, + { key: 'rotor_radius', label: '叶轮半径', unit: 'm', min: '0', step: '0.01' }, + { key: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' }, +]; const CHART_STATE_KEY = 'wind_power_chart_state_v1'; const DEFAULT_CHART_OPTIONS = { title: '#机组功率曲线', @@ -393,11 +437,13 @@ function buildStandardRows(files, mapping) { ); const rows = []; + const rawRows = []; for (const file of files) { const localIndexes = Object.fromEntries( REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]), ); - for (const row of file.rows) { + for (let rowIndex = 0; rowIndex < file.rows.length; rowIndex += 1) { + const row = file.rows[rowIndex]; rows.push({ time: normalizeTime(getCell(row, localIndexes.time)), fan_id: String(getCell(row, localIndexes.fan_id) ?? '').trim(), @@ -408,10 +454,14 @@ function buildStandardRows(files, mapping) { blade_pitch_2: normalizeNumber(getCell(row, localIndexes.blade_pitch_2)), blade_pitch_3: normalizeNumber(getCell(row, localIndexes.blade_pitch_3)), }); + rawRows.push({ + file_name: file.file_name, + values: [...(file.raw_rows?.[rowIndex] || row)], + }); } } - return { rows, indexes }; + return { rows, rawRows, indexes }; } function hasMatchingHeaderSequence(files) { @@ -423,44 +473,6 @@ function hasMatchingHeaderSequence(files) { )); } -function findRawSourceFiles(files, mapping, fanId) { - if (!fanId) return []; - return files.filter((file) => { - const fanColumnIndex = file.headers.indexOf(mapping.fan_id); - return fanColumnIndex >= 0 && file.rows.some((row) => ( - String(row[fanColumnIndex] ?? '').trim() === fanId - )); - }); -} - -function buildRawReportData(file, mapping, fanId) { - if (!file || !fanId) return null; - - const fanColumnIndex = file.headers.indexOf(mapping.fan_id); - const windSpeedColumnIndex = file.headers.indexOf(mapping.wind_speed); - if (fanColumnIndex < 0 || windSpeedColumnIndex < 0) return null; - - const rows = file.rows.reduce((result, row, index) => { - if (String(row[fanColumnIndex] ?? '').trim() === fanId) { - const rawRow = [...(file.raw_rows?.[index] || row)]; - const windSpeed = normalizeNumber(rawRow[windSpeedColumnIndex]); - // 部分源 Excel 将风速保存为“数字文本”。保留显示值的同时写成数值单元格, - // 使计算表的 COUNTIFS 能正确统计风频。 - if (Number.isFinite(windSpeed)) { - rawRow[windSpeedColumnIndex] = windSpeed; - } - result.push(rawRow); - } - return result; - }, []); - - return { - headers: [...(file.raw_headers || file.headers)], - rows, - windSpeedColumnIndex, - }; -} - function formatTimestampForFile(date = new Date()) { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; @@ -585,6 +597,13 @@ function schemeParamsToState(parameters) { }; } +function schemeOneParamsToState(parameters) { + return Object.fromEntries(Object.entries(DEFAULT_SCHEME_ONE_PARAMS).map(([field, fallback]) => [ + field, + String(parameters?.[field] ?? fallback), + ])); +} + function SummaryCards({ summary }) { if (!summary) return null; const cards = [ @@ -972,14 +991,25 @@ async function captureChartFrame(frame) { }; } +function downloadReportBlob(blob, fanId) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `完整功率曲线报告_${sanitizeFileName(fanId)}_${formatTimestampForFile()}.xlsx`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +/* 已迁移到 reportExportWorker,由 Worker 承担 ExcelJS 的内存压力。 */ +/* const REPORT_DETAIL_HEADERS = [ '风机编号', '采样时间', '平均功率', '平均转速', '平均风速', - '3个叶片变桨角平均值', ]; +const REPORT_PITCH_HEADER = '3个叶片变桨角平均值'; const REPORT_BORDER = { top: { style: 'thin', color: { argb: 'FF475569' } }, @@ -1000,16 +1030,19 @@ function reportPitchAverage(row) { return values.every((value) => value !== '') ? values.reduce((sum, value) => sum + value, 0) / values.length : ''; } -function reportDetailValues(row, fanId) { +function reportDetailValues(row, fanId, includePitch) { const savedPitchAverage = reportNumber(row.pitch_angle_average); - return [ + const values = [ row.fan_id || fanId || '', row.time || '', reportNumber(row.active_power), reportNumber(row.generator_speed), reportNumber(row.wind_speed), - savedPitchAverage !== '' ? savedPitchAverage : reportPitchAverage(row), ]; + if (includePitch) { + values.push(savedPitchAverage !== '' ? savedPitchAverage : reportPitchAverage(row)); + } + return values; } function styleReportHeader(row) { @@ -1036,17 +1069,25 @@ function styleReportBody(sheet, startRow, endRow, numericColumns) { } function addReportDetailSheet(workbook, name, rows, fanId) { + const includePitch = rows.some((row) => ( + reportNumber(row.pitch_angle_average) !== '' || reportPitchAverage(row) !== '' + )); + const headers = includePitch ? [...REPORT_DETAIL_HEADERS, REPORT_PITCH_HEADER] : REPORT_DETAIL_HEADERS; const sheet = workbook.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 1 }], }); sheet.columns = [ - { width: 16 }, { width: 22 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 23 }, + { width: 16 }, { width: 22 }, { width: 14 }, { width: 14 }, { width: 14 }, + ...(includePitch ? [{ width: 23 }] : []), ]; - sheet.addRow(REPORT_DETAIL_HEADERS); - rows.forEach((row) => sheet.addRow(reportDetailValues(row, fanId))); + sheet.addRow(headers); + rows.forEach((row) => sheet.addRow(reportDetailValues(row, fanId, includePitch))); styleReportHeader(sheet.getRow(1)); - styleReportBody(sheet, 2, Math.max(2, rows.length + 1), [3, 4, 5, 6]); - sheet.autoFilter = `A1:F${Math.max(1, rows.length + 1)}`; + // 对每个数据单元格设置样式会使 ExcelJS 为大量行保留大量对象,容易耗尽浏览器内存。 + for (const columnNumber of includePitch ? [3, 4, 5, 6] : [3, 4, 5]) { + sheet.getColumn(columnNumber).numFmt = '0.0000'; + } + sheet.autoFilter = `A1:${includePitch ? 'F' : 'E'}${Math.max(1, rows.length + 1)}`; return sheet; } @@ -1067,14 +1108,15 @@ function addRawReportSheet(workbook, { headers, rows }) { }); sheet.addRow(headers); rows.forEach((row) => sheet.addRow(row)); + // 列宽只需参考样本即可,避免大文件导出时重复扫描全部单元格。 + const widthSampleRows = rows.slice(0, 1000); sheet.columns = headers.map((header, index) => { - const maxLength = rows.reduce((max, row) => ( + const maxLength = widthSampleRows.reduce((max, row) => ( Math.max(max, String(row[index] ?? '').length) ), String(header ?? '').length); return { width: Math.min(32, Math.max(12, maxLength + 2)) }; }); styleReportHeader(sheet.getRow(1)); - styleReportBody(sheet, 2, Math.max(2, rows.length + 1), []); sheet.autoFilter = `A1:${excelColumnName(Math.max(0, headers.length - 1))}${Math.max(1, rows.length + 1)}`; return sheet; } @@ -1086,7 +1128,6 @@ async function exportPowerCurveReport({ reportRows, chartRenderSnapshot, }) { - const { default: ExcelJS } = await import('exceljs'); const workbook = new ExcelJS.Workbook(); workbook.creator = '风电功率计算平台'; workbook.created = new Date(); @@ -1157,6 +1198,8 @@ async function exportPowerCurveReport({ URL.revokeObjectURL(url); } +*/ + function PowerCurveChart({ points, scatterPoints, @@ -1218,7 +1261,7 @@ function PowerCurveChart({ const yAxisSize = Math.max(64, Math.round(screenTypography.tick * 3.2 + 16)); const axisLabelSize = screenTypography.axis + 18; const showScatterOnChart = activeChartOptions.show_scatter || editMode; - chartSnapshotRef.current = async () => { + chartSnapshotRef.current = async (captureFrame = true) => { const snapshot = { actualCurve: visibleActualCurve, designCurve: visibleDesignCurve, @@ -1231,6 +1274,9 @@ function PowerCurveChart({ options: activeChartOptions, scales: plotRef.current?.scales, }; + if (!captureFrame) { + return snapshot; + } try { const capturedImage = await captureChartFrame(frameRef.current); return capturedImage ? { ...snapshot, ...capturedImage } : snapshot; @@ -1244,6 +1290,8 @@ function PowerCurveChart({ useEffect(() => { if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined; + const transientErasedPointIds = new Set(); + let redrawFrameId = 0; const sortedCurve = [...visibleActualCurve].sort((left, right) => left.wind_speed - right.wind_speed); const sortedDesign = [...visibleDesignCurve].sort((left, right) => left.wind_speed - right.wind_speed); const actualByWindSpeed = new Map( @@ -1372,6 +1420,9 @@ function PowerCurveChart({ if (showScatterOnChart) { ctx.fillStyle = hexToRgba(activeChartOptions.scatter_color, activeChartOptions.scatter_opacity); for (const point of validScatter) { + if (transientErasedPointIds.has(point.point_id)) { + continue; + } const x = u.valToPos(point.wind_speed, 'x', true); const y = u.valToPos(point.active_power, 'y', true); if (Number.isFinite(x) && Number.isFinite(y)) { @@ -1437,6 +1488,16 @@ function PowerCurveChart({ plotRef.current = chart; setSeriesVisibility({ actual: true, design: true }); + const requestChartRedraw = () => { + if (redrawFrameId) { + return; + } + redrawFrameId = window.requestAnimationFrame(() => { + redrawFrameId = 0; + chart.redraw(true, false); + }); + }; + const hideBrush = () => { setBrush((prev) => ({ ...prev, visible: false, dragging: false })); }; @@ -1449,6 +1510,7 @@ function PowerCurveChart({ } let dragging = false; + const pendingPoints = new Map(); const updateBrush = (event, nextDragging = dragging) => { if (!frameRef.current) return; const rect = frameRef.current.getBoundingClientRect(); @@ -1484,16 +1546,32 @@ function PowerCurveChart({ if (!hitPoints.length) { return; } + for (const point of hitPoints) { + const pointId = point.point_id || `${point.wind_speed}_${point.active_power}`; + pendingPoints.set(pointId, point); + if (editTool === EDIT_TOOL_ERASE) { + transientErasedPointIds.add(pointId); + } + } if (editTool === EDIT_TOOL_ERASE) { - onErasePoints?.(hitPoints); + requestChartRedraw(); + } + }; + const applyPendingPoints = () => { + if (!pendingPoints.size) return; + const points = Array.from(pendingPoints.values()); + pendingPoints.clear(); + if (editTool === EDIT_TOOL_ERASE) { + onErasePoints?.(points); } else { - onRestorePoints?.(hitPoints); + onRestorePoints?.(points); } }; const handlePointerDown = (event) => { if (event.button !== 0) return; event.preventDefault(); dragging = true; + chart.over.setPointerCapture?.(event.pointerId); updateBrush(event, true); applyToolAt(event); }; @@ -1506,6 +1584,11 @@ function PowerCurveChart({ const handlePointerUp = (event) => { updateBrush(event, false); dragging = false; + applyToolAt(event); + applyPendingPoints(); + if (chart.over.hasPointerCapture?.(event.pointerId)) { + chart.over.releasePointerCapture(event.pointerId); + } }; chart.over.addEventListener('pointerdown', handlePointerDown); @@ -1518,6 +1601,10 @@ function PowerCurveChart({ chart.over.removeEventListener('pointermove', handlePointerMove); chart.over.removeEventListener('pointerleave', hideBrush); window.removeEventListener('pointerup', handlePointerUp); + if (redrawFrameId) { + window.cancelAnimationFrame(redrawFrameId); + } + pendingPoints.clear(); if (plotRef.current === chart) plotRef.current = null; chart.destroy(); }; @@ -1626,6 +1713,8 @@ export default function HomePage() { const [savingChartOptions, setSavingChartOptions] = useState(false); const [chartSettingsMessage, setChartSettingsMessage] = useState(''); const [exportingReport, setExportingReport] = useState(false); + const [reportExportProgress, setReportExportProgress] = useState(''); + const [reportExportNotice, setReportExportNotice] = useState(''); const chartExportRef = useRef(() => false); const chartSnapshotRef = useRef(() => null); const [schemes, setSchemes] = useState(DEFAULT_SCHEMES); @@ -1633,12 +1722,14 @@ export default function HomePage() { const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description); const [savingScheme, setSavingScheme] = useState(false); const [schemeMessage, setSchemeMessage] = useState(''); + const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS); const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS); const headers = files[0]?.headers || []; - const missingFields = REQUIRED_FIELDS.filter((field) => !mapping[field.key]); const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0]; const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID; + const mappingFields = isSchemeTwo ? REQUIRED_FIELDS : REQUIRED_FIELDS.slice(0, 5); + const missingFields = mappingFields.filter((field) => !mapping[field.key]); const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : []; const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : []; const selectedScatterRaw = selectedFan && result?.scatter_points @@ -1702,14 +1793,6 @@ export default function HomePage() { () => hasMatchingHeaderSequence(files), [files], ); - const rawReportSourceFiles = useMemo( - () => findRawSourceFiles(files, mapping, selectedFan), - [files, mapping, selectedFan], - ); - const selectedRawReportData = useMemo(() => { - if (!hasConsistentRawHeaders || rawReportSourceFiles.length !== 1) return null; - return buildRawReportData(rawReportSourceFiles[0], mapping, selectedFan); - }, [hasConsistentRawHeaders, mapping, rawReportSourceFiles, selectedFan]); const selectedEstimatedParams = selectedFan && result?.estimated_params ? result.estimated_params[selectedFan] : null; @@ -1924,48 +2007,68 @@ export default function HomePage() { () => buildReportCurveRows(effectiveScatter, reportDesignCurve), [effectiveScatter, reportDesignCurve], ); - const reportExportDisabledReason = files.length && !hasConsistentRawHeaders - ? '同一批上传文件的列头和列顺序必须完全一致' - : files.length && rawReportSourceFiles.length !== 1 - ? '完整报告要求当前风机对应唯一一份上传 Excel 文件' - : files.length && (!selectedRawReportData || !selectedRawReportData.rows.length) - ? '当前风机没有可导出的原始数据,或未识别到风速列' + const reportExportDisabledReason = !selectedFan + ? '请先完成计算并选择要导出的风机' + : isEditMode + ? '请先退出曲线编辑模式,再导出完整报告' + : !reportDesignCurve.length + ? '请先填写或上传设计功率曲线' + : !result?.job_id + ? '当前计算结果已失效,请重新计算后导出报告' + : files.length && !hasConsistentRawHeaders + ? '同一批上传文件的列头和列顺序必须完全一致' + : !reportCurveRows.length + ? '当前设计功率曲线与有效数据未形成可导出的功率曲线点' : ''; const canExportFullReport = Boolean( - selectedFan && selectedRawReportData?.rows.length && reportCurveRows.length && !isEditMode && !reportExportDisabledReason, ); const handleExportFullReport = useCallback(async () => { - if (!canExportFullReport || exportingReport) return; + if (exportingReport) return; + if (!canExportFullReport) { + setReportExportNotice(reportExportDisabledReason || '当前条件不满足,无法导出完整报告'); + return; + } setExportingReport(true); + setReportExportProgress('正在服务器生成完整报告'); setError(''); try { - const chartRenderSnapshot = await chartSnapshotRef.current?.(); + const chartRenderSnapshot = await chartSnapshotRef.current?.(false); if (!chartRenderSnapshot) { throw new Error('图表尚未完成绘制,请稍后重试'); } - await exportPowerCurveReport({ - fanId: selectedFan, - rawReportData: selectedRawReportData, - effectiveRows: effectiveScatter, - reportRows: reportCurveRows, - chartRenderSnapshot, + const chartImageData = renderPowerCurvePng(chartRenderSnapshot); + const blob = await downloadWindReport(result.job_id, { + fan_id: selectedFan, + effective_rows: effectiveScatter, + report_rows: reportCurveRows, + chart_image: chartImageData || '', }); + downloadReportBlob(blob, selectedFan); } catch (err) { - setError(err.message || '导出完整报告失败'); - } finally { + const message = err.message || '导出完整报告失败'; + setError(message); + setReportExportNotice(message); setExportingReport(false); + setReportExportProgress(''); } }, [ canExportFullReport, effectiveScatter, exportingReport, + reportExportDisabledReason, reportCurveRows, - rawReportSourceFiles, - selectedRawReportData, selectedFan, + result?.job_id, ]); + const handleCancelReportExport = useCallback(() => { + setExportingReport(false); + setReportExportProgress(''); + setReportExportNotice('已取消完整报告导出'); + }, []); + + useEffect(() => { let canceled = false; @@ -1981,6 +2084,8 @@ export default function HomePage() { setSchemes(loadedSchemes); setSelectedSchemeId(defaultScheme.id); setSchemeDescription(defaultScheme.description || ''); + const schemeOne = loadedSchemes.find((scheme) => scheme.id === DEFAULT_SCHEME_ID); + setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters)); const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID); setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters)); } catch (err) { @@ -2000,7 +2105,9 @@ export default function HomePage() { const nextScheme = schemes.find((scheme) => scheme.id === value); setSelectedSchemeId(value); setSchemeDescription(nextScheme?.description || ''); - if (value === SCHEME_TWO_ID) { + if (value === DEFAULT_SCHEME_ID) { + setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters)); + } else if (value === SCHEME_TWO_ID) { setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters)); } setSchemeMessage(''); @@ -2013,6 +2120,13 @@ export default function HomePage() { })); } + function handleSchemeOneParamChange(field, value) { + setSchemeOneParams((prev) => ({ + ...prev, + [field]: value, + })); + } + async function handleSaveSchemeDescription() { if (!selectedScheme) return; @@ -2020,9 +2134,26 @@ export default function HomePage() { setError(''); setSchemeMessage(''); try { + const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [ + field, + normalizeNumber(value), + ])); const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); const ratedPower = normalizeNumber(schemeTwoParams.rated_power); + if (selectedScheme.id === DEFAULT_SCHEME_ID && + (!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 || + !Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 || + !Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 || + !Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || schemeOneValues.cleaning_wind_speed_step <= 0 || + !Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 || + !Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 || + ['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier', + 'minimum_generator_speed', 'generator_speed_k'].some((field) => ( + !Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0 + )))) { + throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0'); + } if (selectedScheme.id === SCHEME_TWO_ID && (!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 || !Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 || @@ -2031,6 +2162,7 @@ export default function HomePage() { } const data = await saveWindSchemeDescription(selectedScheme.id, { description: schemeDescription, + ...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}), ...(selectedScheme.id === SCHEME_TWO_ID ? { parameters: { grid_connected_speed: gridConnectedSpeed, @@ -2044,7 +2176,9 @@ export default function HomePage() { scheme.id === savedScheme.id ? { ...scheme, ...savedScheme } : scheme ))); setSchemeDescription(savedScheme.description || ''); - if (savedScheme.id === SCHEME_TWO_ID) { + if (savedScheme.id === DEFAULT_SCHEME_ID) { + setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters)); + } else if (savedScheme.id === SCHEME_TWO_ID) { setSchemeTwoParams(schemeParamsToState(savedScheme.parameters)); } setSchemeMessage('已保存'); @@ -2142,6 +2276,26 @@ export default function HomePage() { setError('请先上传文件并完成所有字段映射'); return; } + const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [ + field, + normalizeNumber(value), + ])); + const schemeOneInvalid = + !Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 || + !Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 || + !Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 || + !Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || + schemeOneValues.cleaning_wind_speed_step <= 0 || + !Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 || + !Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 || + ['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier', + 'minimum_generator_speed', 'generator_speed_k'].some((field) => ( + !Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0 + )); + if (!isSchemeTwo && schemeOneInvalid) { + setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0'); + return; + } const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); const ratedPower = normalizeNumber(schemeTwoParams.rated_power); @@ -2166,13 +2320,14 @@ export default function HomePage() { setProgress('正在准备标准化数据'); try { - const { rows } = buildStandardRows(files, mapping); + const { rows, rawRows } = buildStandardRows(files, mapping); const start = await startWindJob({ files: files.map((file) => ({ file_name: file.file_name, row_count: file.row_count, })), mapping, + raw_headers: files[0]?.raw_headers || files[0]?.headers || [], }); jobId = start.job_id; @@ -2184,6 +2339,7 @@ export default function HomePage() { job_id: jobId, chunk_index: index, rows: chunkRows, + raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE), }); } @@ -2191,15 +2347,9 @@ export default function HomePage() { const calculation = await finishWindJob({ job_id: jobId, options: { - power_step: 5, - cleaning_wind_speed_step: 0.25, curve_wind_speed_step: 0.5, - wind_speed_change_threshold: 1, - iqr_lower_multiplier: 1.8, - iqr_upper_multiplier: 2, - minimum_generator_speed: 1, - generator_speed_k: 0.9, scheme_id: selectedSchemeId, + ...(!isSchemeTwo ? schemeOneValues : {}), ...(isSchemeTwo ? { grid_connected_speed: gridConnectedSpeed, rated_generator_speed: ratedGeneratorSpeed, @@ -2207,7 +2357,7 @@ export default function HomePage() { } : {}), }, }); - setResult(calculation); + setResult({ ...calculation, job_id: jobId }); setSelectedFan(calculation.fans?.[0] || ''); setProgress('计算完成'); } catch (err) { @@ -2218,7 +2368,11 @@ export default function HomePage() { // 失败任务清理失败不影响用户看到主错误。 } } - setError(err.message || '计算失败'); + const message = err.message || '计算失败'; + setError(message); + if (message.includes('服务器正在处理数据')) { + setReportExportNotice(message); + } setProgress(''); } finally { setSubmitting(false); @@ -2248,6 +2402,19 @@ export default function HomePage() { {error &&
{error}
} {progress &&
{progress}
} + {reportExportNotice && ( +
+
+

提示

+

{reportExportNotice}

+
+ +
+
+
+ )}
@@ -2283,6 +2450,29 @@ export default function HomePage() { />
+ {!isSchemeTwo && ( + <> +

+ 叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速 +

+
+ {SCHEME_ONE_PARAM_FIELDS.map((field) => ( + + ))} +
+ + )} {isSchemeTwo && (