From 84d67dc431c9c784356d68d7bbe5df654019fd56 Mon Sep 17 00:00:00 2001 From: cloud Date: Fri, 17 Jul 2026 17:21:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8A=9F=E7=8E=87=E6=9B=B2=E7=BA=BF=E6=89=8B=E5=8A=A8=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=BB=A4=E9=99=A4=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加额定拐角过渡点保护,减少过度筛除 - 补齐滤除点导出字段 - 增加功率曲线编辑模式和笔刷恢复交互 --- .../src/controllers/WindPowerController.cpp | 70 +++++- frontend/web_app/src/App.css | 27 +++ frontend/web_app/src/pages/HomePage.jsx | 211 ++++++++++++++++-- 3 files changed, 284 insertions(+), 24 deletions(-) diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index 97ecb02..41af81a 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -101,6 +101,11 @@ struct SchemeInfo { double rated_power = 2000.0; }; +constexpr double kRatedCornerWindBefore = 0.5; +constexpr double kRatedCornerWindAfter = 1.2; +constexpr double kRatedCornerPowerLowerRatio = 0.88; +constexpr double kRatedCornerPowerUpperRatio = 1.03; + std::string Trim(const std::string& value) { const auto begin = value.find_first_not_of(" \t\r\n"); if (begin == std::string::npos) { @@ -485,8 +490,20 @@ void ComputeWindSpeed600sAverage(std::vector& rows) { } } +bool IsRatedTransitionCorner(const ValidRow& row, const EstimatedParams& params) { + if (params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) { + return false; + } + + return row.wind_speed >= params.rated_wind_speed - kRatedCornerWindBefore && + row.wind_speed <= params.rated_wind_speed + kRatedCornerWindAfter && + row.active_power >= params.rated_power * kRatedCornerPowerLowerRatio && + row.active_power <= params.rated_power * kRatedCornerPowerUpperRatio; +} + std::vector FilterLimitPower(const std::vector& rows, const CalculationOptions& options, + const EstimatedParams* transition_params, int& removed_count, std::vector& removed_points) { removed_count = 0; @@ -529,13 +546,15 @@ std::vector FilterLimitPower(const std::vector& rows, std::vector result; result.reserve(rows.size()); for (size_t i = 0; i < rows.size(); ++i) { - if (remove_indexes.count(i) == 0) { + const bool protect_transition = + transition_params != nullptr && IsRatedTransitionCorner(rows[i], *transition_params); + if (remove_indexes.count(i) == 0 || protect_transition) { result.push_back(rows[i]); } else { removed_points.push_back(RemovedPoint{rows[i], "limit_power"}); + ++removed_count; } } - removed_count = static_cast(remove_indexes.size()); return result; } @@ -607,7 +626,8 @@ std::vector FilterByWindBinIqr(const std::vector& rows, ValueGetter value_getter, int& removed_count, std::vector& removed_points, - const std::string& reason) { + const std::string& reason, + const EstimatedParams* transition_params = nullptr) { removed_count = 0; if (rows.empty()) { return {}; @@ -649,6 +669,9 @@ std::vector FilterByWindBinIqr(const std::vector& rows, const double value = value_getter(row); if (value >= lower && value <= upper) { result.push_back(row); + } else if (transition_params != nullptr && + IsRatedTransitionCorner(row, *transition_params)) { + result.push_back(row); } else { ++removed_count; removed_points.push_back(RemovedPoint{row, reason}); @@ -768,9 +791,10 @@ std::vector FilterHighWindLowPower(const std::vector& rows, return rows; } + const double high_wind_start = params.rated_wind_speed + 1.0; std::vector platform_powers; for (const auto& row : rows) { - if (row.wind_speed >= params.rated_wind_speed) { + if (row.wind_speed >= high_wind_start) { platform_powers.push_back(row.active_power); } } @@ -786,7 +810,8 @@ std::vector FilterHighWindLowPower(const std::vector& rows, std::vector result; result.reserve(rows.size()); for (const auto& row : rows) { - if (row.wind_speed >= params.rated_wind_speed && + if (row.wind_speed >= high_wind_start && + !IsRatedTransitionCorner(row, params) && row.active_power < lower_limit) { ++removed_count; removed_points.push_back(RemovedPoint{row, "high_wind_low_power"}); @@ -846,14 +871,26 @@ std::vector FilterCurveResidualOutliers(const std::vector& r const double median = Quantile(residuals, 0.5); const double mad_sigma = MedianAbsoluteDeviation(residuals, median) * 1.4826; const double rated_power = params.rated_power > 0.0 ? params.rated_power : options.rated_power; - const double lower_margin = std::max(4.0 * mad_sigma, rated_power * 0.10); - const double upper_margin = std::max(4.0 * mad_sigma, rated_power * 0.16); + double transition_multiplier = 1.0; + if (params.rated_wind_speed > 0.0) { + const double interval_center = (start + end) / 2.0; + if (interval_center >= params.rated_wind_speed - 1.0 && + interval_center <= params.rated_wind_speed + 1.5) { + transition_multiplier = 1.8; + } + } + const double lower_margin = + std::max(4.0 * mad_sigma, rated_power * 0.10) * transition_multiplier; + const double upper_margin = + std::max(4.0 * mad_sigma, rated_power * 0.16) * transition_multiplier; const double lower = median - lower_margin; const double upper = median + upper_margin; for (size_t i = 0; i < interval_rows.size(); ++i) { if (residuals[i] >= lower && residuals[i] <= upper) { result.push_back(interval_rows[i]); + } else if (IsRatedTransitionCorner(interval_rows[i], params)) { + result.push_back(interval_rows[i]); } else { ++removed_count; removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"}); @@ -873,7 +910,7 @@ std::vector FilterStrictRatedPlateau(const std::vector& rows return rows; } - const double strict_start_wind_speed = params.rated_wind_speed + 0.5; + const double strict_start_wind_speed = params.rated_wind_speed + 1.5; std::vector plateau_powers; for (const auto& row : rows) { if (row.wind_speed >= strict_start_wind_speed) { @@ -894,6 +931,7 @@ std::vector FilterStrictRatedPlateau(const std::vector& rows result.reserve(rows.size()); for (const auto& row : rows) { if (row.wind_speed >= strict_start_wind_speed && + !IsRatedTransitionCorner(row, params) && row.active_power < lower_limit) { ++removed_count; removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"}); @@ -1270,7 +1308,14 @@ void WindPowerController::FinishJob( fan_removed_points); scheme_two_grid_speed_low_count += removed; - fan_rows = FilterLimitPower(fan_rows, options, removed, fan_removed_points); + const auto initial_estimated_params = EstimateRatedParams(fan_rows, options); + + fan_rows = FilterLimitPower( + fan_rows, + options, + &initial_estimated_params, + removed, + fan_removed_points); limit_power_count += removed; for (auto& row : fan_rows) { @@ -1293,7 +1338,8 @@ void WindPowerController::FinishJob( [](const ValidRow& row) { return row.active_power; }, removed, fan_removed_points, - "speed_power_outlier"); + "speed_power_outlier", + &initial_estimated_params); speed_power_outlier_count += removed; const auto fan_estimated_params = EstimateRatedParams(fan_rows, options); @@ -1357,8 +1403,12 @@ void WindPowerController::FinishJob( json fan_filtered = json::array(); for (const auto& removed_point : fan_removed_points) { json point; + point["fan_id"] = removed_point.row.fan_id; + point["time"] = removed_point.row.time; point["wind_speed"] = removed_point.row.wind_speed; point["active_power"] = removed_point.row.active_power; + point["generator_speed"] = removed_point.row.generator_speed; + point["pitch_angle_average"] = removed_point.row.pitch_angle_average; point["reason"] = removed_point.reason; fan_filtered.push_back(point); } diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index c92c3b1..dc47007 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -69,6 +69,12 @@ background: #24243a; } +.secondaryButton.active { + border-color: #fbbf24; + background: rgba(251, 191, 36, 0.14); + color: #fde68a; +} + .uploadButton input { display: none; } @@ -499,6 +505,10 @@ select:focus { min-height: 540px; } +.chartFrameEditing .u-over { + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Cpath d='M6 22l4.6-1.1L22.1 9.4l-3.5-3.5L7.1 17.4 6 22z' fill='%23fbbf24' stroke='%2310101a' stroke-width='1.6'/%3E%3Cpath d='M17.5 5.1l1.4-1.4c.8-.8 2-.8 2.8 0l2.6 2.6c.8.8.8 2 0 2.8l-1.4 1.4-5.4-5.4z' fill='%23fde68a' stroke='%2310101a' stroke-width='1.6'/%3E%3C/svg%3E") 4 24, crosshair; +} + .uplotWrap { width: 100%; min-height: 540px; @@ -580,6 +590,23 @@ select:focus { border-color: #22c55e; } +.chartBrush { + position: absolute; + top: 0; + left: 0; + z-index: 3; + border: 1px solid rgba(251, 191, 36, 0.95); + border-radius: 50%; + background: rgba(251, 191, 36, 0.08); + box-shadow: 0 0 0 1px rgba(16, 16, 26, 0.8), 0 0 18px rgba(251, 191, 36, 0.24); + pointer-events: none; + will-change: transform; +} + +.chartBrush.dragging { + background: rgba(251, 191, 36, 0.16); +} + .chartTools { display: flex; align-items: center; diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index 1550275..dbf426b 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import uPlot from 'uplot'; import 'uplot/dist/uPlot.min.css'; import * as XLSX from 'xlsx'; @@ -49,6 +49,7 @@ const DESIGN_FIELD_HINTS = { const CHUNK_SIZE = 4000; const CHART_HEIGHT = 540; +const BRUSH_RADIUS = 16; const DEFAULT_SCHEME_ID = 'scheme_one'; const SCHEME_TWO_ID = 'scheme_two'; const DEFAULT_SCHEMES = [ @@ -355,6 +356,23 @@ function exportValidRowsToExcel(fanId, rows) { ); } +function createPointId(point, fanId) { + return [ + point.fan_id || fanId || '', + point.time || '', + point.wind_speed ?? '', + point.active_power ?? '', + point.reason || '', + ].join('|'); +} + +function withPointIds(points, fanId) { + return (points || []).map((point) => ({ + ...point, + point_id: point.point_id || createPointId(point, fanId), + })); +} + function interpolateDesignPower(points, windSpeed) { if (!points.length) return null; if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) { @@ -417,8 +435,12 @@ function PowerCurveChart({ filteredPoints, showFiltered, designPoints, + editMode, + onRestorePoints, }) { + const frameRef = useRef(null); const chartRef = useRef(null); + const [brush, setBrush] = useState({ visible: false, x: 0, y: 0, dragging: false }); const validPoints = useMemo( () => (points || []).filter((point) => point.sample_count > 0), [points], @@ -476,7 +498,7 @@ function PowerCurveChart({ width, height: CHART_HEIGHT, cursor: { - drag: { x: true, y: true }, + drag: { x: !editMode, y: !editMode }, }, scales: { x: { time: false }, @@ -546,21 +568,116 @@ function PowerCurveChart({ }, }, [curveX, actualY, designY], chartRef.current); - return () => chart.destroy(); - }, [validPoints, validScatter, validFiltered, validDesign, showFiltered]); + const hideBrush = () => { + setBrush((prev) => ({ ...prev, visible: false, dragging: false })); + }; + + if (!editMode) { + return () => chart.destroy(); + } + + let dragging = false; + const updateBrush = (event, nextDragging = dragging) => { + if (!frameRef.current) return; + const rect = frameRef.current.getBoundingClientRect(); + setBrush({ + visible: true, + x: event.clientX - rect.left, + y: event.clientY - rect.top, + dragging: nextDragging, + }); + }; + const restoreAt = (event) => { + if (!chart.over || !onRestorePoints) return; + const rect = chart.over.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const radiusSquared = BRUSH_RADIUS * BRUSH_RADIUS; + const restored = []; + for (const point of validFiltered) { + const pointX = chart.valToPos(point.wind_speed, 'x'); + const pointY = chart.valToPos(point.active_power, 'y'); + if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) { + continue; + } + const dx = pointX - x; + const dy = pointY - y; + if (dx * dx + dy * dy <= radiusSquared) { + restored.push(point); + } + } + if (restored.length) { + onRestorePoints(restored); + } + }; + const handlePointerDown = (event) => { + if (event.button !== 0) return; + event.preventDefault(); + dragging = true; + updateBrush(event, true); + restoreAt(event); + }; + const handlePointerMove = (event) => { + updateBrush(event); + if (dragging) { + restoreAt(event); + } + }; + const handlePointerUp = (event) => { + updateBrush(event, false); + dragging = false; + }; + + chart.over.addEventListener('pointerdown', handlePointerDown); + chart.over.addEventListener('pointermove', handlePointerMove); + chart.over.addEventListener('pointerleave', hideBrush); + window.addEventListener('pointerup', handlePointerUp); + + return () => { + chart.over.removeEventListener('pointerdown', handlePointerDown); + chart.over.removeEventListener('pointermove', handlePointerMove); + chart.over.removeEventListener('pointerleave', hideBrush); + window.removeEventListener('pointerup', handlePointerUp); + chart.destroy(); + }; + }, [ + validPoints, + validScatter, + validFiltered, + validDesign, + showFiltered, + editMode, + onRestorePoints, + ]); + + useEffect(() => { + if (!editMode) { + setBrush((prev) => ({ ...prev, visible: false, dragging: false })); + } + }, [editMode]); if (!validPoints.length) { return
暂无可绘制的曲线数据
; } return ( -
+
散点数据 实际功率 {validDesign.length > 0 && 设计功率}
+ {editMode && brush.visible && ( +
+ )}
); } @@ -575,6 +692,8 @@ export default function HomePage() { const [result, setResult] = useState(null); const [selectedFan, setSelectedFan] = useState(''); const [showFiltered, setShowFiltered] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [restoredPointIds, setRestoredPointIds] = useState({}); const [readingDesign, setReadingDesign] = useState(false); const [designRows, setDesignRows] = useState(() => createDesignRows()); const [designMeta, setDesignMeta] = useState(null); @@ -594,13 +713,63 @@ export default function HomePage() { const selectedScatter = selectedFan && result?.scatter_points ? result.scatter_points[selectedFan] || [] : []; - const selectedFiltered = selectedFan && result?.filtered_points + const selectedFilteredRaw = selectedFan && result?.filtered_points ? result.filtered_points[selectedFan] || [] : []; + const selectedFiltered = useMemo( + () => withPointIds(selectedFilteredRaw, selectedFan), + [selectedFilteredRaw, selectedFan], + ); + const selectedRestoredIdSet = useMemo( + () => new Set(restoredPointIds[selectedFan] || []), + [restoredPointIds, selectedFan], + ); + const restoredFilteredPoints = useMemo( + () => selectedFiltered.filter((point) => selectedRestoredIdSet.has(point.point_id)), + [selectedFiltered, selectedRestoredIdSet], + ); + const visibleFilteredPoints = useMemo( + () => selectedFiltered.filter((point) => !selectedRestoredIdSet.has(point.point_id)), + [selectedFiltered, selectedRestoredIdSet], + ); + const effectiveScatter = useMemo( + () => [...selectedScatter, ...restoredFilteredPoints], + [selectedScatter, restoredFilteredPoints], + ); const selectedEstimatedParams = selectedFan && result?.estimated_params ? result.estimated_params[selectedFan] : null; + const handleRestorePoints = useCallback((pointsToRestore) => { + if (!selectedFan || !pointsToRestore.length) return; + setRestoredPointIds((prev) => { + const nextIds = new Set(prev[selectedFan] || []); + let changed = false; + for (const point of pointsToRestore) { + const pointId = point.point_id || createPointId(point, selectedFan); + if (!nextIds.has(pointId)) { + nextIds.add(pointId); + changed = true; + } + } + if (!changed) return prev; + return { + ...prev, + [selectedFan]: Array.from(nextIds), + }; + }); + }, [selectedFan]); + + const handleToggleEditMode = useCallback(() => { + setIsEditMode((prev) => { + const next = !prev; + if (next) { + setShowFiltered(true); + } + return next; + }); + }, []); + const totalRows = useMemo( () => files.reduce((sum, file) => sum + file.row_count, 0), [files], @@ -726,6 +895,8 @@ export default function HomePage() { setResult(null); setSelectedFan(''); setShowFiltered(false); + setIsEditMode(false); + setRestoredPointIds({}); try { const parsedFiles = []; for (const file of selectedFiles) { @@ -812,6 +983,8 @@ export default function HomePage() { setError(''); setResult(null); setShowFiltered(false); + setIsEditMode(false); + setRestoredPointIds({}); setProgress('正在准备标准化数据'); try { @@ -1181,22 +1354,30 @@ export default function HomePage() {

功率曲线

- 曲线 {selectedPoints.length} 点 · 散点 {selectedScatter.length.toLocaleString()} 点 - · 滤除 {selectedFiltered.length.toLocaleString()} 点 + 曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点 + · 滤除 {visibleFilteredPoints.length.toLocaleString()} 点 + @@ -1228,10 +1409,12 @@ export default function HomePage() { )}