diff --git a/.agents/skills/skill-builder/SKILL.md b/.agents/skills/skill-builder/SKILL.md index b1f6dd9..8ac4af2 100644 --- a/.agents/skills/skill-builder/SKILL.md +++ b/.agents/skills/skill-builder/SKILL.md @@ -10,7 +10,7 @@ description: 技能建设规范。用于为 wind_power_cal 创建/改写 .agents ## 规则 - 每个 skill 一个目录,内含 `SKILL.md`。 - frontmatter:`name`(kebab-case)、`description`(一句话:**何时触发** + 做什么;用于检索匹配)。 -- 内容贴合 **wind_power_cal 实际**:C++ Drogon 后端(:8848)、React+Vite 前端、`package.sh / run.sh / deploy.sh`、`third_party` 源码编译 Drogon、响应信封 `{status,msg,data}`。 +- 内容贴合 **wind_power_cal 实际**:C++ Drogon 后端(:8080)、React+Vite 前端、`package.sh / run.sh / deploy.sh`、`third_party` 源码编译 Drogon、响应信封 `{status,msg,data}`。 - **禁止**原封照搬 edge_collector 等其它项目的 skill——其 OTA / 协议采集 / 边缘主机 / collector / configurator / cloud_server 等内容在本仓库不存在,会误导。 - 用 `[[other-skill-name]]` 链接相关技能。 - 通用工程规范(代码风格、git 提交、shell、前端调试)与项目专属规范(`wind-*`)分开维护。 diff --git a/.agents/skills/wind-backend-conventions/SKILL.md b/.agents/skills/wind-backend-conventions/SKILL.md index 3f5a0e2..e191500 100644 --- a/.agents/skills/wind-backend-conventions/SKILL.md +++ b/.agents/skills/wind-backend-conventions/SKILL.md @@ -32,7 +32,7 @@ SendForbidden(callback); // 无权限(code 3, 403) - `main.cpp` 必须 `using namespace drogon;`,否则 `app()` / `HttpResponse` / `CT_TEXT_HTML` 未声明 ## 配置 / SPA -- `backend/config/server_config.json`:listener(默认 :8848)、CORS、`document_root ./web` +- `backend/config/server_config.json`:listener(默认 :8080)、CORS、`document_root ./web` - SPA 回退:`app().setCustom404Page(HttpResponse::newFileResponse("./web/index.html","",CT_TEXT_HTML), false)` ## 现有 Controller diff --git a/.agents/skills/wind-build-run/SKILL.md b/.agents/skills/wind-build-run/SKILL.md index edf8728..9a59928 100644 --- a/.agents/skills/wind-build-run/SKILL.md +++ b/.agents/skills/wind-build-run/SKILL.md @@ -14,11 +14,11 @@ description: wind_power_cal 本地构建与运行规范。用于编译后端、 `--publish` 额外生成 `publish/wind_power-.tar.gz`。 ## 运行 -- 一键(后端托管 SPA):`./run.sh`,访问 http://localhost:8848/ +- 一键(后端托管 SPA):`./run.sh`,访问 http://localhost:8080/ - 仅后端:`bash backend/run.sh`(先 build.sh 再启动,设 `LD_LIBRARY_PATH=libs`) - 开发(前后端分离热更): - - 后端 `bash backend/run.sh`(:8848) - - 前端 `cd frontend/web_app && npm install && npm run dev`(:5173,代理 `/api`→:8848) + - 后端 `bash backend/run.sh`(:8080) + - 前端 `cd frontend/web_app && npm install && npm run dev`(:5173,代理 `/api`→:8080) ## 后端构建细节 - `backend/CMakeLists.txt`:`find_package(Drogon CONFIG REQUIRED)`,`file(GLOB_RECURSE src/*.cpp)`,链 `Drogon::Drogon`,include `src` + `third_party`。 diff --git a/.agents/skills/wind-deploy/SKILL.md b/.agents/skills/wind-deploy/SKILL.md index 038e54c..cfa09d0 100644 --- a/.agents/skills/wind-deploy/SKILL.md +++ b/.agents/skills/wind-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: wind-deploy -description: wind_power_cal 远端部署规范。用于把服务部署到公网主机(默认 82.157.83.226)时,按 deploy.sh 流程操作并校验 :8848。 +description: wind_power_cal 远端部署规范。用于把服务部署到公网主机(默认 82.157.83.226)时,按 deploy.sh 流程操作并校验 :8080。 --- # 远端部署(deploy.sh) @@ -14,7 +14,7 @@ description: wind_power_cal 远端部署规范。用于把服务部署到公网 - `User=ubuntu`,`ExecStart=~/wind_power/wind_server` - `Environment=LD_LIBRARY_PATH=~/wind_power/libs` - 日志 `logs/server.log`,`Restart=on-failure` -4. 重启 `wind_power`,校验 `localhost:8848` +4. 重启 `wind_power`,校验 `localhost:8080` ## 远端运行期依赖(最小化服务器常缺) `sudo apt-get install -y libjsoncpp25 libc-ares2`(deploy.sh 已内置)。 @@ -28,7 +28,7 @@ description: wind_power_cal 远端部署规范。用于把服务部署到公网 ## 校验 ``` -curl -s localhost:8848/api/system/health +curl -s localhost:8080/api/system/health # {"status":0,"msg":"success","data":{"status":"ok"}} ``` 查看服务:`ssh ubuntu@82.157.83.226 'systemctl status wind_power'` diff --git a/.agents/skills/wind-frontend-conventions/SKILL.md b/.agents/skills/wind-frontend-conventions/SKILL.md index 23d1aec..f395561 100644 --- a/.agents/skills/wind-frontend-conventions/SKILL.md +++ b/.agents/skills/wind-frontend-conventions/SKILL.md @@ -26,7 +26,7 @@ description: wind_power_cal 前端 React+Vite 规范。用于前端页面/接口 - 后端经 `/api` 前缀;生产由后端托管 SPA(`document_root ./web`) ## 构建配置 -`vite.config.js`:`base='/'`,dev server :5173,proxy `/api` → `http://localhost:8848`。 +`vite.config.js`:`base='/'`,dev server :5173,proxy `/api` → `http://localhost:8080`。 ## 开发 ``` diff --git a/.agents/skills/wind-project-overview/SKILL.md b/.agents/skills/wind-project-overview/SKILL.md index 9f18e2d..ada2340 100644 --- a/.agents/skills/wind-project-overview/SKILL.md +++ b/.agents/skills/wind-project-overview/SKILL.md @@ -32,8 +32,8 @@ wind_power_cal/ ## 端口 | 服务 | 端口 | | --- | --- | -| 后端 wind_server | 8848(监听地址见 server_config.json) | -| 前端 dev | 5173(vite proxy `/api` → :8848) | +| 后端 wind_server | 8080(监听地址见 server_config.json) | +| 前端 dev | 5173(vite proxy `/api` → :8080) | ## 脚本入口 - `./run.sh` 一键打包+前台运行(后端托管 SPA) diff --git a/.claude/settings.json b/.claude/settings.json index c7746fe..b151dd5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,26 @@ { "permissions": { "allow": [ - "Bash(grep -E \"node_modules|/build/|build_package|^runtime/|^publish/|third_party/drogon/install|\\\\.so$|\\\\.o$\")" + "Bash(grep -E \"node_modules|/build/|build_package|^runtime/|^publish/|third_party/drogon/install|\\\\.so$|\\\\.o$\")", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(echo \"=== exit: $? ===\")", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'echo \"=== nginx status ===\"; systemctl is-active nginx 2>/dev/null || service nginx status 2>/dev/null | head -3; echo; echo \"=== all listen ports in nginx config ===\"; sudo grep -rhoE \"listen[^;]*;\" /etc/nginx/ 2>/dev/null | sort | uniq -c')", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'echo \"=== nginx 监听的端口(去重)===\"; sudo nginx -T 2>/dev/null | grep -oE \"listen[[:space:]]+[^;]+\" | sed \"s/listen[[:space:]]*//\" | sed \"s/[[:space:]]*default_server//\" | sed \"s/[[:space:]]*ssl//\" | sort -u; echo; echo \"=== proxy_pass 转发目标(去重)===\"; sudo nginx -T 2>/dev/null | grep -oE \"proxy_pass[[:space:]]+[^;]+\" | sed \"s/proxy_pass[[:space:]]*//\" | sort -u')", + "Bash(sshpass -p i7568737i~ ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 ' *)", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'echo \"=== 服务器上 8080 / 8082 占用情况 ===\"; sudo ss -tlnp 2>/dev/null | grep -E \":8080|:8082|:8848|:3000\" ; echo; echo \"=== nginx 对外监听 8080 的 server 块 ===\"; sudo nginx -T 2>/dev/null | grep -B2 -A20 \"listen.*8080\"')", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'echo \"=== IPv4 网卡 ===\"; ip -4 addr show | grep -E \"inet \" | grep -v \"127.0.0.1\"; echo; echo \"=== IPv6 全局地址 ===\"; ip -6 addr show | grep -i \"scope global\" || echo \"\\(无全局 IPv6\\)\"; echo; echo \"=== 现有 listen 10.2.0.8:8081 server 块\\(作为方案A的参照\\) ===\"; sudo nginx -T 2>/dev/null | grep -B1 -A18 \"listen[[:space:]]+10.2.0.8:8081\"; echo; echo \"=== 确认 8080 当前 default_server 标记 ===\"; sudo nginx -T 2>/dev/null | grep -nE \"listen.*8080\"')", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'bash -s')", + "Bash(curl -s -m 5 -o /dev/null -w \"HTTP %{http_code} 耗时%{time_total}s\\\\n\" http://82.157.83.226:8080/api/system/health)", + "Bash(curl -s -m 5 http://82.157.83.226:8080/api/system/health)", + "Bash(echo \"health body: $\\(curl -s -m 5 http://82.157.83.226:8080/api/system/health\\)\")", + "Bash(sshpass -p 'i7568737i~' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 ubuntu@82.157.83.226 'sudo nginx -t && sudo systemctl reload nginx && echo \"[nginx] reloaded -> 10.2.0.8:8080 转发 127.0.0.1:8080 \\(中断开始\\)\"')", + "Bash(./deploy.sh)", + "Bash(curl -s -m5 http://82.157.83.226:8080/api/system/health)", + "Bash(curl -s -m5 http://82.157.83.226:8080/api/system/version)", + "Bash(curl -s -m5 -o /dev/null -w \"HTTP %{http_code} 类型%{content_type}\\\\n\" http://82.157.83.226:8080/)", + "Bash(curl -s -m5 -o /dev/null -w \"8848 HTTP %{http_code} \\(000=不通/拒绝, 符合预期\\)\\\\n\" http://82.157.83.226:8848/)", + "Bash(git restore *)" ] } } diff --git a/.claude/skills/skill-builder/SKILL.md b/.claude/skills/skill-builder/SKILL.md index b1f6dd9..8ac4af2 100644 --- a/.claude/skills/skill-builder/SKILL.md +++ b/.claude/skills/skill-builder/SKILL.md @@ -10,7 +10,7 @@ description: 技能建设规范。用于为 wind_power_cal 创建/改写 .agents ## 规则 - 每个 skill 一个目录,内含 `SKILL.md`。 - frontmatter:`name`(kebab-case)、`description`(一句话:**何时触发** + 做什么;用于检索匹配)。 -- 内容贴合 **wind_power_cal 实际**:C++ Drogon 后端(:8848)、React+Vite 前端、`package.sh / run.sh / deploy.sh`、`third_party` 源码编译 Drogon、响应信封 `{status,msg,data}`。 +- 内容贴合 **wind_power_cal 实际**:C++ Drogon 后端(:8080)、React+Vite 前端、`package.sh / run.sh / deploy.sh`、`third_party` 源码编译 Drogon、响应信封 `{status,msg,data}`。 - **禁止**原封照搬 edge_collector 等其它项目的 skill——其 OTA / 协议采集 / 边缘主机 / collector / configurator / cloud_server 等内容在本仓库不存在,会误导。 - 用 `[[other-skill-name]]` 链接相关技能。 - 通用工程规范(代码风格、git 提交、shell、前端调试)与项目专属规范(`wind-*`)分开维护。 diff --git a/.claude/skills/wind-backend-conventions/SKILL.md b/.claude/skills/wind-backend-conventions/SKILL.md index 3f5a0e2..e191500 100644 --- a/.claude/skills/wind-backend-conventions/SKILL.md +++ b/.claude/skills/wind-backend-conventions/SKILL.md @@ -32,7 +32,7 @@ SendForbidden(callback); // 无权限(code 3, 403) - `main.cpp` 必须 `using namespace drogon;`,否则 `app()` / `HttpResponse` / `CT_TEXT_HTML` 未声明 ## 配置 / SPA -- `backend/config/server_config.json`:listener(默认 :8848)、CORS、`document_root ./web` +- `backend/config/server_config.json`:listener(默认 :8080)、CORS、`document_root ./web` - SPA 回退:`app().setCustom404Page(HttpResponse::newFileResponse("./web/index.html","",CT_TEXT_HTML), false)` ## 现有 Controller diff --git a/.claude/skills/wind-build-run/SKILL.md b/.claude/skills/wind-build-run/SKILL.md index edf8728..9a59928 100644 --- a/.claude/skills/wind-build-run/SKILL.md +++ b/.claude/skills/wind-build-run/SKILL.md @@ -14,11 +14,11 @@ description: wind_power_cal 本地构建与运行规范。用于编译后端、 `--publish` 额外生成 `publish/wind_power-.tar.gz`。 ## 运行 -- 一键(后端托管 SPA):`./run.sh`,访问 http://localhost:8848/ +- 一键(后端托管 SPA):`./run.sh`,访问 http://localhost:8080/ - 仅后端:`bash backend/run.sh`(先 build.sh 再启动,设 `LD_LIBRARY_PATH=libs`) - 开发(前后端分离热更): - - 后端 `bash backend/run.sh`(:8848) - - 前端 `cd frontend/web_app && npm install && npm run dev`(:5173,代理 `/api`→:8848) + - 后端 `bash backend/run.sh`(:8080) + - 前端 `cd frontend/web_app && npm install && npm run dev`(:5173,代理 `/api`→:8080) ## 后端构建细节 - `backend/CMakeLists.txt`:`find_package(Drogon CONFIG REQUIRED)`,`file(GLOB_RECURSE src/*.cpp)`,链 `Drogon::Drogon`,include `src` + `third_party`。 diff --git a/.claude/skills/wind-deploy/SKILL.md b/.claude/skills/wind-deploy/SKILL.md index 038e54c..cfa09d0 100644 --- a/.claude/skills/wind-deploy/SKILL.md +++ b/.claude/skills/wind-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: wind-deploy -description: wind_power_cal 远端部署规范。用于把服务部署到公网主机(默认 82.157.83.226)时,按 deploy.sh 流程操作并校验 :8848。 +description: wind_power_cal 远端部署规范。用于把服务部署到公网主机(默认 82.157.83.226)时,按 deploy.sh 流程操作并校验 :8080。 --- # 远端部署(deploy.sh) @@ -14,7 +14,7 @@ description: wind_power_cal 远端部署规范。用于把服务部署到公网 - `User=ubuntu`,`ExecStart=~/wind_power/wind_server` - `Environment=LD_LIBRARY_PATH=~/wind_power/libs` - 日志 `logs/server.log`,`Restart=on-failure` -4. 重启 `wind_power`,校验 `localhost:8848` +4. 重启 `wind_power`,校验 `localhost:8080` ## 远端运行期依赖(最小化服务器常缺) `sudo apt-get install -y libjsoncpp25 libc-ares2`(deploy.sh 已内置)。 @@ -28,7 +28,7 @@ description: wind_power_cal 远端部署规范。用于把服务部署到公网 ## 校验 ``` -curl -s localhost:8848/api/system/health +curl -s localhost:8080/api/system/health # {"status":0,"msg":"success","data":{"status":"ok"}} ``` 查看服务:`ssh ubuntu@82.157.83.226 'systemctl status wind_power'` diff --git a/.claude/skills/wind-frontend-conventions/SKILL.md b/.claude/skills/wind-frontend-conventions/SKILL.md index 23d1aec..f395561 100644 --- a/.claude/skills/wind-frontend-conventions/SKILL.md +++ b/.claude/skills/wind-frontend-conventions/SKILL.md @@ -26,7 +26,7 @@ description: wind_power_cal 前端 React+Vite 规范。用于前端页面/接口 - 后端经 `/api` 前缀;生产由后端托管 SPA(`document_root ./web`) ## 构建配置 -`vite.config.js`:`base='/'`,dev server :5173,proxy `/api` → `http://localhost:8848`。 +`vite.config.js`:`base='/'`,dev server :5173,proxy `/api` → `http://localhost:8080`。 ## 开发 ``` diff --git a/.claude/skills/wind-project-overview/SKILL.md b/.claude/skills/wind-project-overview/SKILL.md index 9f18e2d..ada2340 100644 --- a/.claude/skills/wind-project-overview/SKILL.md +++ b/.claude/skills/wind-project-overview/SKILL.md @@ -32,8 +32,8 @@ wind_power_cal/ ## 端口 | 服务 | 端口 | | --- | --- | -| 后端 wind_server | 8848(监听地址见 server_config.json) | -| 前端 dev | 5173(vite proxy `/api` → :8848) | +| 后端 wind_server | 8080(监听地址见 server_config.json) | +| 前端 dev | 5173(vite proxy `/api` → :8080) | ## 脚本入口 - `./run.sh` 一键打包+前台运行(后端托管 SPA) diff --git a/README.md b/README.md index a638523..4ff40d5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ wind_power_cal/ ## 快速开始 -### 1. 一键启动(默认监听 :8848) +### 1. 一键启动(默认监听 :8080) ```bash ./run.sh @@ -35,10 +35,10 @@ wind_power_cal/ 验证: ```bash -curl -s localhost:8848/api/system/health +curl -s localhost:8080/api/system/health # {"data":{"status":"ok"},"msg":"success","status":0} -curl -s localhost:8848/api/system/version +curl -s localhost:8080/api/system/version # {"data":{"name":"wind_power_cal","version":"0.1.0"},"msg":"success","status":0} ``` @@ -50,17 +50,17 @@ npm install npm run dev ``` -打开 http://localhost:5173 ,首页通过 Vite 代理 (`/api` → `localhost:8848`) 访问后端接口。 +打开 http://localhost:5173 ,首页通过 Vite 代理 (`/api` → `localhost:8080`) 访问后端接口。 ### 3. 生产联调 -统一使用根目录 `./run.sh`。脚本会构建前端并把 `dist/` 同步到 `runtime/wind_power/web/`,由后端托管 SPA;访问 http://localhost:8848/ 直接返回前端页面。 +统一使用根目录 `./run.sh`。脚本会构建前端并把 `dist/` 同步到 `runtime/wind_power/web/`,由后端托管 SPA;访问 http://localhost:8080/ 直接返回前端页面。 ## 端口 | 服务 | 端口 | | ------ | ----- | -| 后端 | 8848 | +| 后端 | 8080 | | 前端 dev | 5173 | ## 说明 diff --git a/backend/config/server_config.json b/backend/config/server_config.json index 227aaf9..071b1b1 100644 --- a/backend/config/server_config.json +++ b/backend/config/server_config.json @@ -1,8 +1,8 @@ { "listeners": [ { - "address": "0.0.0.0", - "port": 8848, + "address": "127.0.0.1", + "port": 8080, "https": false } ], @@ -29,8 +29,8 @@ "enabled": true, "allow_origins": [ "http://localhost:5173", - "http://localhost:8848", - "http://127.0.0.1:8848" + "http://localhost:8080", + "http://127.0.0.1:8080" ], "allow_methods": [ "GET", diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index 42257dd..97ecb02 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,13 @@ namespace { constexpr int kErrorInvalidRequest = 1001; constexpr int kErrorJobNotFound = 1002; constexpr int kErrorServer = 1003; +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; @@ -40,6 +48,11 @@ struct ValidRow { 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; }; @@ -49,6 +62,7 @@ struct RemovedPoint { }; struct CalculationOptions { + std::string scheme_id = kDefaultSchemeId; double rated_power = 4800.0; double rated_wind_speed = 18.0; double power_step = 5.0; @@ -59,6 +73,11 @@ struct CalculationOptions { double iqr_upper_multiplier = 2.0; double minimum_generator_speed = 1.0; double generator_speed_k = 0.9; + 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 { @@ -73,6 +92,15 @@ struct CurveBin { size_t sample_count = 0; }; +struct SchemeInfo { + std::string id; + std::string name; + std::string description; + double grid_connected_speed = 1030.0; + double rated_generator_speed = 1755.0; + double rated_power = 2000.0; +}; + std::string Trim(const std::string& value) { const auto begin = value.find_first_not_of(" \t\r\n"); if (begin == std::string::npos) { @@ -103,6 +131,10 @@ fs::path JobRowsPath(const std::string& job_id) { return JobDir(job_id) / "rows.jsonl"; } +fs::path SchemeConfigPath() { + return fs::path("data") / "wind_schemes.json"; +} + std::string GenerateJobId() { const auto now = std::chrono::system_clock::now().time_since_epoch().count(); std::random_device rd; @@ -148,6 +180,117 @@ std::optional GetStringField(const json& body, const std::string& f return std::nullopt; } +std::optional GetNumberField(const json& body, const std::string& field) { + if (!body.contains(field) || !body[field].is_number()) { + return std::nullopt; + } + return body[field].get(); +} + +std::string NormalizeSchemeId(const std::string& scheme_id) { + return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId; +} + +std::vector DefaultSchemes() { + return { + {kDefaultSchemeId, kSchemeOneName, kSchemeOneDefaultDescription}, + {kSchemeTwoId, kSchemeTwoName, kSchemeTwoDefaultDescription, 1030.0, 1755.0, 2000.0}, + }; +} + +std::optional FindScheme(const std::vector& 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 == 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; + } + return data; +} + +std::vector 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 (scheme.id == kSchemeTwoId && + item.contains("parameters") && + item["parameters"].is_object()) { + const auto& params = item["parameters"]; + 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(); + } + } + } + } + } catch (const std::exception&) { + return schemes; + } + return schemes; +} + +bool SaveSchemes(const std::vector& schemes) { + try { + const auto config_path = SchemeConfigPath(); + fs::create_directories(config_path.parent_path()); + + json data; + data["default_scheme_id"] = kDefaultSchemeId; + 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 GetDoubleField(const json& body, const std::string& field) { if (!body.contains(field)) { return std::nullopt; @@ -261,9 +404,13 @@ CalculationOptions ParseOptions(const json& body) { } 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) { @@ -301,13 +448,43 @@ CalculationOptions ParseOptions(const json& body) { value.has_value() && value.value() >= 0.0) { options.generator_speed_k = 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& counters, const std::string& reason) { counters[reason] += 1; } +void ComputeWindSpeed600sAverage(std::vector& rows) { + std::deque 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(window.size()); + } +} + std::vector FilterLimitPower(const std::vector& rows, const CalculationOptions& options, int& removed_count, @@ -362,6 +539,68 @@ std::vector FilterLimitPower(const std::vector& rows, return result; } +std::vector FilterSchemeTwoGridSpeed(const std::vector& rows, + const CalculationOptions& options, + int& removed_count, + std::vector& removed_points) { + removed_count = 0; + if (!IsSchemeTwo(options) || rows.empty()) { + return rows; + } + + std::vector 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 FilterSchemeTwoPitchRules( + const std::vector& rows, + const CalculationOptions& options, + int& low_power_pitch_wind_count, + int& low_speed_pitch_count, + int& low_power_pitch_count, + std::vector& 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 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 std::vector FilterByWindBinIqr(const std::vector& rows, const CalculationOptions& options, @@ -674,6 +913,100 @@ std::string DatePart(const std::string& time_text) { } // namespace +void WindPowerController::GetSchemes( + const HttpRequestPtr&, + std::function&& callback) { + json data; + data["default_scheme_id"] = kDefaultSchemeId; + 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&& 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 grid_connected_speed; + std::optional rated_generator_speed; + std::optional rated_power; + 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"); + 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) { + 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 == 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(); + } + } + updated = true; + break; + } + } + if (!updated) { + SendError(callback, kErrorInvalidRequest, "方案不存在"); + return; + } + if (!SaveSchemes(schemes)) { + SendError(callback, kErrorServer, "保存方案描述失败"); + return; + } + + const auto saved_scheme = FindScheme(schemes, normalized_id); + json data; + data["scheme"] = SchemeToJson(saved_scheme.value()); + SendSuccess(callback, data); +} + void WindPowerController::StartJob( const HttpRequestPtr& req, std::function&& callback) { @@ -769,6 +1102,14 @@ void WindPowerController::FinishJob( } const CalculationOptions options = ParseOptions(*body); + if (IsSchemeTwo(options) && + (!options.rated_power_provided || + !options.grid_connected_speed_provided || + !options.rated_generator_speed_provided)) { + SendError(callback, kErrorInvalidRequest, "方案二需要填写并网转速、额定转速和额定功率"); + return; + } + std::vector parsed_rows; std::unordered_map invalid_reasons; int raw_rows = 0; @@ -795,6 +1136,9 @@ void WindPowerController::FinishJob( 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) { @@ -807,6 +1151,13 @@ void WindPowerController::FinishJob( 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; @@ -834,6 +1185,15 @@ void WindPowerController::FinishJob( 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&) { @@ -889,13 +1249,27 @@ void WindPowerController::FinishJob( 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 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; + fan_rows = FilterLimitPower(fan_rows, options, removed, fan_removed_points); limit_power_count += removed; @@ -951,13 +1325,31 @@ void WindPowerController::FinishJob( 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(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; @@ -1012,6 +1404,11 @@ void WindPowerController::FinishJob( 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; @@ -1026,10 +1423,17 @@ void WindPowerController::FinishJob( 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["summary"] = summary; data["fans"] = fans; data["curves"] = curves; diff --git a/backend/src/controllers/WindPowerController.h b/backend/src/controllers/WindPowerController.h index 25e04f3..7af4b91 100644 --- a/backend/src/controllers/WindPowerController.h +++ b/backend/src/controllers/WindPowerController.h @@ -15,8 +15,17 @@ public: ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", Post); ADD_METHOD_TO(WindPowerController::FinishJob, "/api/wind/jobs/finish", 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, + "/api/wind/schemes/{1}/description", + Post); METHOD_LIST_END + void GetSchemes(const HttpRequestPtr& req, + std::function&& callback); + void SaveSchemeDescription(const HttpRequestPtr& req, + std::function&& callback, + const std::string& scheme_id); void StartJob(const HttpRequestPtr& req, std::function&& callback); void UploadChunk(const HttpRequestPtr& req, diff --git a/deploy.sh b/deploy.sh index c7d56a3..5fc4e5d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -6,7 +6,7 @@ # 1. 本地执行 package.sh 生成 runtime/wind_power/ # 2. rsync runtime/wind_power/ → ~/wind_power/ # 3. 安装/更新 systemd 服务 wind_power.service -# 4. 重启 wind_power,并验证 8848 直连接口 +# 4. 重启 wind_power,并验证 8080 直连接口 # # 不安装、不配置 nginx;不修改 gitea。 # @@ -114,17 +114,17 @@ sudo systemctl restart "${SERVICE_NAME}" echo "[remote] systemd ${SERVICE_NAME} 已安装并启动" REMOTE -step "5/5 校验 8848 直连服务..." +step "5/5 校验 8080 直连服务..." sleep 2 ssh_cmd "SERVICE_NAME='${SERVICE_NAME}'" 'bash -s' <<'REMOTE' | sed 's/^/[remote] /' set +e echo "service: $(systemctl is-active ${SERVICE_NAME})" -echo "health: $(curl -s -m 5 localhost:8848/api/system/health)" -echo "version: $(curl -s -m 5 localhost:8848/api/system/version)" -echo "listen: $(ss -tlnp 2>/dev/null | grep ':8848' | head -1 | sed 's/.*users://')" +echo "health: $(curl -s -m 5 localhost:8080/api/system/health)" +echo "version: $(curl -s -m 5 localhost:8080/api/system/version)" +echo "listen: $(ss -tlnp 2>/dev/null | grep ':8080' | head -1 | sed 's/.*users://')" REMOTE echo "" info "✅ 部署完成" -echo -e " wind_power: ${GREEN}http://${TARGET#*@}:8848/${NC}" +echo -e " wind_power: ${GREEN}http://${TARGET#*@}:8080/${NC}" echo " 查看服务: ssh ${TARGET} 'systemctl status ${SERVICE_NAME}'" diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index c2d1710..c92c3b1 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -154,6 +154,102 @@ margin-bottom: 18px; } +.schemePanel { + margin-bottom: 18px; +} + +.schemeGrid { + display: grid; + grid-template-columns: minmax(240px, 0.38fr) minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.schemeDescriptionControl { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.schemeDescriptionControl span { + color: #cbd5e1; + font-size: 14px; +} + +.schemeDescriptionControl input { + width: 100%; + min-width: 0; + height: 36px; + border: 1px solid #343449; + border-radius: 7px; + background: #0f0f18; + color: #e5e7eb; + padding: 0 10px; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schemeDescriptionControl input:focus { + border-color: #6366f1; + outline: none; +} + +.schemeDescriptionControl input:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.schemeParamGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.schemeParamGrid input { + min-width: 0; + width: 100%; + height: 36px; + border: 1px solid #343449; + border-radius: 7px; + background: #0f0f18; + color: #e5e7eb; + padding: 0 10px; + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.schemeParamGrid input:focus { + border-color: #6366f1; + outline: none; +} + +.schemeParamGrid input:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.schemeParamGrid input::placeholder { + color: #5f6673; +} + +.schemeFooter { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 14px; +} + +.schemeFooter span { + min-height: 18px; + color: #86efac; + font-size: 13px; +} + .designActions { display: flex; align-items: center; @@ -571,7 +667,9 @@ td { } .homeHeader, - .workspace { + .workspace, + .schemeGrid, + .schemeParamGrid { grid-template-columns: 1fr; } diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index 0dfb49f..1550275 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -5,6 +5,8 @@ import * as XLSX from 'xlsx'; import { deleteWindJob, finishWindJob, + getWindSchemes, + saveWindSchemeDescription, startWindJob, uploadWindChunk, } from '../utils/api'; @@ -15,6 +17,9 @@ const REQUIRED_FIELDS = [ { key: 'wind_speed', label: '风速' }, { key: 'active_power', label: '有功功率' }, { key: 'generator_speed', label: '发电机转速' }, + { key: 'blade_pitch_1', label: '1#叶片角度' }, + { key: 'blade_pitch_2', label: '2#叶片角度' }, + { key: 'blade_pitch_3', label: '3#叶片角度' }, ]; const FIELD_HINTS = { @@ -23,6 +28,9 @@ const FIELD_HINTS = { wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'], active_power: ['平均有功功率', '有功功率', 'active power', 'power'], generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'], + blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'], + blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'], + blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'], }; const FIELD_EXCLUDES = { @@ -41,6 +49,27 @@ const DESIGN_FIELD_HINTS = { const CHUNK_SIZE = 4000; const CHART_HEIGHT = 540; +const DEFAULT_SCHEME_ID = 'scheme_one'; +const SCHEME_TWO_ID = 'scheme_two'; +const DEFAULT_SCHEMES = [ + { id: 'scheme_one', name: '方案一', description: '通用方案' }, + { + id: SCHEME_TWO_ID, + name: '方案二', + description: '桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数', + parameters: { + grid_connected_speed: 1030, + rated_generator_speed: 1755, + rated_power: 2000, + }, + }, +]; + +const DEFAULT_SCHEME_TWO_PARAMS = { + grid_connected_speed: '1030', + rated_generator_speed: '1755', + rated_power: '2000', +}; function createDesignRows(points) { const source = points?.length ? points : [{ wind_speed: '', design_power: '' }]; @@ -289,6 +318,9 @@ function buildStandardRows(files, mapping) { wind_speed: normalizeNumber(getCell(row, localIndexes.wind_speed)), active_power: normalizeNumber(getCell(row, localIndexes.active_power)), generator_speed: normalizeNumber(getCell(row, localIndexes.generator_speed)), + blade_pitch_1: normalizeNumber(getCell(row, localIndexes.blade_pitch_1)), + blade_pitch_2: normalizeNumber(getCell(row, localIndexes.blade_pitch_2)), + blade_pitch_3: normalizeNumber(getCell(row, localIndexes.blade_pitch_3)), }); } } @@ -296,6 +328,69 @@ function buildStandardRows(files, mapping) { return { rows, indexes }; } +function formatTimestampForFile(date = new Date()) { + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function sanitizeFileName(value) { + return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_'); +} + +function exportValidRowsToExcel(fanId, rows) { + const exportRows = (rows || []).map((row) => ({ + 风机编号: row.fan_id || fanId || '', + 采样时间: row.time || '', + 平均功率: row.active_power ?? '', + 平均转速: row.generator_speed ?? '', + 平均风速: row.wind_speed ?? '', + '3个叶片变桨角平均值': row.pitch_angle_average ?? '', + })); + const worksheet = XLSX.utils.json_to_sheet(exportRows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, '有效数据'); + XLSX.writeFile( + workbook, + `有效数据_${sanitizeFileName(fanId)}_${formatTimestampForFile()}.xlsx`, + ); +} + +function interpolateDesignPower(points, windSpeed) { + if (!points.length) return null; + if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) { + return null; + } + + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + if (Math.abs(point.wind_speed - windSpeed) < 0.000001) { + return point.design_power; + } + if (point.wind_speed > windSpeed && index > 0) { + const left = points[index - 1]; + const right = point; + const span = right.wind_speed - left.wind_speed; + if (span <= 0) return left.design_power; + const ratio = (windSpeed - left.wind_speed) / span; + return left.design_power + (right.design_power - left.design_power) * ratio; + } + } + + return points[points.length - 1].design_power; +} + +function schemeParamsToState(parameters) { + return { + grid_connected_speed: String( + parameters?.grid_connected_speed ?? DEFAULT_SCHEME_TWO_PARAMS.grid_connected_speed, + ), + rated_generator_speed: String( + parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed, + ), + rated_power: String(parameters?.rated_power ?? DEFAULT_SCHEME_TWO_PARAMS.rated_power), + }; +} + function SummaryCards({ summary }) { if (!summary) return null; const cards = [ @@ -352,15 +447,12 @@ function PowerCurveChart({ const actualByWindSpeed = new Map( sortedCurve.map((point) => [point.wind_speed.toFixed(6), point.average_power]), ); - const designByWindSpeed = new Map( - sortedDesign.map((point) => [point.wind_speed.toFixed(6), point.design_power]), - ); const curveX = Array.from(new Set([ ...sortedCurve.map((point) => point.wind_speed), ...sortedDesign.map((point) => point.wind_speed), ])).sort((left, right) => left - right); const actualY = curveX.map((windSpeed) => actualByWindSpeed.get(windSpeed.toFixed(6)) ?? null); - const designY = curveX.map((windSpeed) => designByWindSpeed.get(windSpeed.toFixed(6)) ?? null); + const designY = curveX.map((windSpeed) => interpolateDesignPower(sortedDesign, windSpeed)); const maxScatterPower = validScatter.reduce( (max, point) => Math.max(max, point.active_power), 0, @@ -409,6 +501,7 @@ function PowerCurveChart({ stroke: '#ef4444', width: 2.5, paths: uPlot.paths.spline(), + spanGaps: true, points: { show: false }, }, { @@ -416,6 +509,7 @@ function PowerCurveChart({ stroke: '#22c55e', width: 2.2, paths: uPlot.paths.spline(), + spanGaps: true, points: { show: false }, }, ], @@ -430,7 +524,7 @@ function PowerCurveChart({ const y = u.valToPos(point.active_power, 'y', true); if (Number.isFinite(x) && Number.isFinite(y)) { ctx.beginPath(); - ctx.arc(x, y, 1.6, 0, Math.PI * 2); + ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); } } @@ -441,7 +535,7 @@ function PowerCurveChart({ const y = u.valToPos(point.active_power, 'y', true); if (Number.isFinite(x) && Number.isFinite(y)) { ctx.beginPath(); - ctx.arc(x, y, 1.9, 0, Math.PI * 2); + ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); } } @@ -484,9 +578,17 @@ export default function HomePage() { const [readingDesign, setReadingDesign] = useState(false); const [designRows, setDesignRows] = useState(() => createDesignRows()); const [designMeta, setDesignMeta] = useState(null); + const [schemes, setSchemes] = useState(DEFAULT_SCHEMES); + const [selectedSchemeId, setSelectedSchemeId] = useState(DEFAULT_SCHEME_ID); + const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description); + const [savingScheme, setSavingScheme] = useState(false); + const [schemeMessage, setSchemeMessage] = useState(''); + 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 selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : []; const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : []; const selectedScatter = selectedFan && result?.scatter_points @@ -526,6 +628,95 @@ export default function HomePage() { .sort((left, right) => left.wind_speed - right.wind_speed); }, [designRows]); + useEffect(() => { + let canceled = false; + + async function loadSchemes() { + try { + const data = await getWindSchemes(); + if (canceled) return; + + const loadedSchemes = data.schemes?.length ? data.schemes : DEFAULT_SCHEMES; + const defaultSchemeId = data.default_scheme_id || DEFAULT_SCHEME_ID; + const defaultScheme = loadedSchemes.find((scheme) => scheme.id === defaultSchemeId) + || loadedSchemes[0]; + setSchemes(loadedSchemes); + setSelectedSchemeId(defaultScheme.id); + setSchemeDescription(defaultScheme.description || ''); + const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID); + setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters)); + } catch (err) { + if (!canceled) { + setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案'); + } + } + } + + loadSchemes(); + return () => { + canceled = true; + }; + }, []); + + function handleSchemeChange(value) { + const nextScheme = schemes.find((scheme) => scheme.id === value); + setSelectedSchemeId(value); + setSchemeDescription(nextScheme?.description || ''); + if (value === SCHEME_TWO_ID) { + setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters)); + } + setSchemeMessage(''); + } + + function handleSchemeTwoParamChange(field, value) { + setSchemeTwoParams((prev) => ({ + ...prev, + [field]: value, + })); + } + + async function handleSaveSchemeDescription() { + if (!selectedScheme) return; + + setSavingScheme(true); + setError(''); + setSchemeMessage(''); + try { + const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); + const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); + const ratedPower = normalizeNumber(schemeTwoParams.rated_power); + if (selectedScheme.id === SCHEME_TWO_ID && + (!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 || + !Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 || + !Number.isFinite(ratedPower) || ratedPower <= 0)) { + throw new Error('方案二参数必须为正数'); + } + const data = await saveWindSchemeDescription(selectedScheme.id, { + description: schemeDescription, + ...(selectedScheme.id === SCHEME_TWO_ID ? { + parameters: { + grid_connected_speed: gridConnectedSpeed, + rated_generator_speed: ratedGeneratorSpeed, + rated_power: ratedPower, + }, + } : {}), + }); + const savedScheme = data.scheme; + setSchemes((prev) => prev.map((scheme) => ( + scheme.id === savedScheme.id ? { ...scheme, ...savedScheme } : scheme + ))); + setSchemeDescription(savedScheme.description || ''); + if (savedScheme.id === SCHEME_TWO_ID) { + setSchemeTwoParams(schemeParamsToState(savedScheme.parameters)); + } + setSchemeMessage('已保存'); + } catch (err) { + setError(err.message || '保存失败'); + } finally { + setSavingScheme(false); + } + } + async function handleFilesChange(event) { const selectedFiles = Array.from(event.target.files || []); if (!selectedFiles.length) return; @@ -605,6 +796,16 @@ export default function HomePage() { setError('请先上传文件并完成所有字段映射'); return; } + const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); + const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); + const ratedPower = normalizeNumber(schemeTwoParams.rated_power); + if (isSchemeTwo && + (!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 || + !Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 || + !Number.isFinite(ratedPower) || ratedPower <= 0)) { + setError('方案二需要填写并网转速、额定转速和额定功率,且必须为正数'); + return; + } let jobId = ''; setSubmitting(true); @@ -647,6 +848,12 @@ export default function HomePage() { iqr_upper_multiplier: 2, minimum_generator_speed: 1, generator_speed_k: 0.9, + scheme_id: selectedSchemeId, + ...(isSchemeTwo ? { + grid_connected_speed: gridConnectedSpeed, + rated_generator_speed: ratedGeneratorSpeed, + rated_power: ratedPower, + } : {}), }, }); setResult(calculation); @@ -691,6 +898,102 @@ export default function HomePage() { {error &&
{error}
} {progress &&
{progress}
} +
+
+
+

计算方案

+

选择本次计算使用的清洗方案,方案描述可保存为服务端共享配置。

+
+ {selectedScheme?.name || '方案一'} +
+
+ + +
+ {isSchemeTwo && ( +
+ + + +
+ )} +
+ {schemeMessage} + +
+
+
@@ -860,6 +1163,12 @@ export default function HomePage() { 计算完成
+ {result.scheme && ( +
+ 方案:{result.scheme.name} + {result.scheme.description} +
+ )}
{Object.entries(result.summary?.invalid_reasons || {}).map(([reason, count]) => ( {reason}: {Number(count).toLocaleString()} @@ -883,6 +1192,14 @@ export default function HomePage() { > {showFiltered ? '隐藏滤除点' : '显示滤除点'} +