功能: 支持功率曲线手动恢复滤除点
- 增加额定拐角过渡点保护,减少过度筛除 - 补齐滤除点导出字段 - 增加功率曲线编辑模式和笔刷恢复交互
This commit is contained in:
@@ -101,6 +101,11 @@ struct SchemeInfo {
|
|||||||
double rated_power = 2000.0;
|
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) {
|
std::string Trim(const std::string& value) {
|
||||||
const auto begin = value.find_first_not_of(" \t\r\n");
|
const auto begin = value.find_first_not_of(" \t\r\n");
|
||||||
if (begin == std::string::npos) {
|
if (begin == std::string::npos) {
|
||||||
@@ -485,8 +490,20 @@ void ComputeWindSpeed600sAverage(std::vector<ValidRow>& 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<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
|
std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
|
||||||
const CalculationOptions& options,
|
const CalculationOptions& options,
|
||||||
|
const EstimatedParams* transition_params,
|
||||||
int& removed_count,
|
int& removed_count,
|
||||||
std::vector<RemovedPoint>& removed_points) {
|
std::vector<RemovedPoint>& removed_points) {
|
||||||
removed_count = 0;
|
removed_count = 0;
|
||||||
@@ -529,13 +546,15 @@ std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
|
|||||||
std::vector<ValidRow> result;
|
std::vector<ValidRow> result;
|
||||||
result.reserve(rows.size());
|
result.reserve(rows.size());
|
||||||
for (size_t i = 0; i < rows.size(); ++i) {
|
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]);
|
result.push_back(rows[i]);
|
||||||
} else {
|
} else {
|
||||||
removed_points.push_back(RemovedPoint{rows[i], "limit_power"});
|
removed_points.push_back(RemovedPoint{rows[i], "limit_power"});
|
||||||
|
++removed_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
removed_count = static_cast<int>(remove_indexes.size());
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +626,8 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
|
|||||||
ValueGetter value_getter,
|
ValueGetter value_getter,
|
||||||
int& removed_count,
|
int& removed_count,
|
||||||
std::vector<RemovedPoint>& removed_points,
|
std::vector<RemovedPoint>& removed_points,
|
||||||
const std::string& reason) {
|
const std::string& reason,
|
||||||
|
const EstimatedParams* transition_params = nullptr) {
|
||||||
removed_count = 0;
|
removed_count = 0;
|
||||||
if (rows.empty()) {
|
if (rows.empty()) {
|
||||||
return {};
|
return {};
|
||||||
@@ -649,6 +669,9 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
|
|||||||
const double value = value_getter(row);
|
const double value = value_getter(row);
|
||||||
if (value >= lower && value <= upper) {
|
if (value >= lower && value <= upper) {
|
||||||
result.push_back(row);
|
result.push_back(row);
|
||||||
|
} else if (transition_params != nullptr &&
|
||||||
|
IsRatedTransitionCorner(row, *transition_params)) {
|
||||||
|
result.push_back(row);
|
||||||
} else {
|
} else {
|
||||||
++removed_count;
|
++removed_count;
|
||||||
removed_points.push_back(RemovedPoint{row, reason});
|
removed_points.push_back(RemovedPoint{row, reason});
|
||||||
@@ -768,9 +791,10 @@ std::vector<ValidRow> FilterHighWindLowPower(const std::vector<ValidRow>& rows,
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const double high_wind_start = params.rated_wind_speed + 1.0;
|
||||||
std::vector<double> platform_powers;
|
std::vector<double> platform_powers;
|
||||||
for (const auto& row : rows) {
|
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);
|
platform_powers.push_back(row.active_power);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -786,7 +810,8 @@ std::vector<ValidRow> FilterHighWindLowPower(const std::vector<ValidRow>& rows,
|
|||||||
std::vector<ValidRow> result;
|
std::vector<ValidRow> result;
|
||||||
result.reserve(rows.size());
|
result.reserve(rows.size());
|
||||||
for (const auto& row : rows) {
|
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) {
|
row.active_power < lower_limit) {
|
||||||
++removed_count;
|
++removed_count;
|
||||||
removed_points.push_back(RemovedPoint{row, "high_wind_low_power"});
|
removed_points.push_back(RemovedPoint{row, "high_wind_low_power"});
|
||||||
@@ -846,14 +871,26 @@ std::vector<ValidRow> FilterCurveResidualOutliers(const std::vector<ValidRow>& r
|
|||||||
const double median = Quantile(residuals, 0.5);
|
const double median = Quantile(residuals, 0.5);
|
||||||
const double mad_sigma = MedianAbsoluteDeviation(residuals, median) * 1.4826;
|
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 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);
|
double transition_multiplier = 1.0;
|
||||||
const double upper_margin = std::max(4.0 * mad_sigma, rated_power * 0.16);
|
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 lower = median - lower_margin;
|
||||||
const double upper = median + upper_margin;
|
const double upper = median + upper_margin;
|
||||||
|
|
||||||
for (size_t i = 0; i < interval_rows.size(); ++i) {
|
for (size_t i = 0; i < interval_rows.size(); ++i) {
|
||||||
if (residuals[i] >= lower && residuals[i] <= upper) {
|
if (residuals[i] >= lower && residuals[i] <= upper) {
|
||||||
result.push_back(interval_rows[i]);
|
result.push_back(interval_rows[i]);
|
||||||
|
} else if (IsRatedTransitionCorner(interval_rows[i], params)) {
|
||||||
|
result.push_back(interval_rows[i]);
|
||||||
} else {
|
} else {
|
||||||
++removed_count;
|
++removed_count;
|
||||||
removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"});
|
removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"});
|
||||||
@@ -873,7 +910,7 @@ std::vector<ValidRow> FilterStrictRatedPlateau(const std::vector<ValidRow>& rows
|
|||||||
return 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<double> plateau_powers;
|
std::vector<double> plateau_powers;
|
||||||
for (const auto& row : rows) {
|
for (const auto& row : rows) {
|
||||||
if (row.wind_speed >= strict_start_wind_speed) {
|
if (row.wind_speed >= strict_start_wind_speed) {
|
||||||
@@ -894,6 +931,7 @@ std::vector<ValidRow> FilterStrictRatedPlateau(const std::vector<ValidRow>& rows
|
|||||||
result.reserve(rows.size());
|
result.reserve(rows.size());
|
||||||
for (const auto& row : rows) {
|
for (const auto& row : rows) {
|
||||||
if (row.wind_speed >= strict_start_wind_speed &&
|
if (row.wind_speed >= strict_start_wind_speed &&
|
||||||
|
!IsRatedTransitionCorner(row, params) &&
|
||||||
row.active_power < lower_limit) {
|
row.active_power < lower_limit) {
|
||||||
++removed_count;
|
++removed_count;
|
||||||
removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"});
|
removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"});
|
||||||
@@ -1270,7 +1308,14 @@ void WindPowerController::FinishJob(
|
|||||||
fan_removed_points);
|
fan_removed_points);
|
||||||
scheme_two_grid_speed_low_count += removed;
|
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;
|
limit_power_count += removed;
|
||||||
|
|
||||||
for (auto& row : fan_rows) {
|
for (auto& row : fan_rows) {
|
||||||
@@ -1293,7 +1338,8 @@ void WindPowerController::FinishJob(
|
|||||||
[](const ValidRow& row) { return row.active_power; },
|
[](const ValidRow& row) { return row.active_power; },
|
||||||
removed,
|
removed,
|
||||||
fan_removed_points,
|
fan_removed_points,
|
||||||
"speed_power_outlier");
|
"speed_power_outlier",
|
||||||
|
&initial_estimated_params);
|
||||||
speed_power_outlier_count += removed;
|
speed_power_outlier_count += removed;
|
||||||
|
|
||||||
const auto fan_estimated_params = EstimateRatedParams(fan_rows, options);
|
const auto fan_estimated_params = EstimateRatedParams(fan_rows, options);
|
||||||
@@ -1357,8 +1403,12 @@ void WindPowerController::FinishJob(
|
|||||||
json fan_filtered = json::array();
|
json fan_filtered = json::array();
|
||||||
for (const auto& removed_point : fan_removed_points) {
|
for (const auto& removed_point : fan_removed_points) {
|
||||||
json point;
|
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["wind_speed"] = removed_point.row.wind_speed;
|
||||||
point["active_power"] = removed_point.row.active_power;
|
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;
|
point["reason"] = removed_point.reason;
|
||||||
fan_filtered.push_back(point);
|
fan_filtered.push_back(point);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@
|
|||||||
background: #24243a;
|
background: #24243a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondaryButton.active {
|
||||||
|
border-color: #fbbf24;
|
||||||
|
background: rgba(251, 191, 36, 0.14);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
.uploadButton input {
|
.uploadButton input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -499,6 +505,10 @@ select:focus {
|
|||||||
min-height: 540px;
|
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 {
|
.uplotWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 540px;
|
min-height: 540px;
|
||||||
@@ -580,6 +590,23 @@ select:focus {
|
|||||||
border-color: #22c55e;
|
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 {
|
.chartTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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 from 'uplot';
|
||||||
import 'uplot/dist/uPlot.min.css';
|
import 'uplot/dist/uPlot.min.css';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
@@ -49,6 +49,7 @@ const DESIGN_FIELD_HINTS = {
|
|||||||
|
|
||||||
const CHUNK_SIZE = 4000;
|
const CHUNK_SIZE = 4000;
|
||||||
const CHART_HEIGHT = 540;
|
const CHART_HEIGHT = 540;
|
||||||
|
const BRUSH_RADIUS = 16;
|
||||||
const DEFAULT_SCHEME_ID = 'scheme_one';
|
const DEFAULT_SCHEME_ID = 'scheme_one';
|
||||||
const SCHEME_TWO_ID = 'scheme_two';
|
const SCHEME_TWO_ID = 'scheme_two';
|
||||||
const DEFAULT_SCHEMES = [
|
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) {
|
function interpolateDesignPower(points, windSpeed) {
|
||||||
if (!points.length) return null;
|
if (!points.length) return null;
|
||||||
if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) {
|
if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) {
|
||||||
@@ -417,8 +435,12 @@ function PowerCurveChart({
|
|||||||
filteredPoints,
|
filteredPoints,
|
||||||
showFiltered,
|
showFiltered,
|
||||||
designPoints,
|
designPoints,
|
||||||
|
editMode,
|
||||||
|
onRestorePoints,
|
||||||
}) {
|
}) {
|
||||||
|
const frameRef = useRef(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
|
const [brush, setBrush] = useState({ visible: false, x: 0, y: 0, dragging: false });
|
||||||
const validPoints = useMemo(
|
const validPoints = useMemo(
|
||||||
() => (points || []).filter((point) => point.sample_count > 0),
|
() => (points || []).filter((point) => point.sample_count > 0),
|
||||||
[points],
|
[points],
|
||||||
@@ -476,7 +498,7 @@ function PowerCurveChart({
|
|||||||
width,
|
width,
|
||||||
height: CHART_HEIGHT,
|
height: CHART_HEIGHT,
|
||||||
cursor: {
|
cursor: {
|
||||||
drag: { x: true, y: true },
|
drag: { x: !editMode, y: !editMode },
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { time: false },
|
x: { time: false },
|
||||||
@@ -546,21 +568,116 @@ function PowerCurveChart({
|
|||||||
},
|
},
|
||||||
}, [curveX, actualY, designY], chartRef.current);
|
}, [curveX, actualY, designY], chartRef.current);
|
||||||
|
|
||||||
return () => chart.destroy();
|
const hideBrush = () => {
|
||||||
}, [validPoints, validScatter, validFiltered, validDesign, showFiltered]);
|
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) {
|
if (!validPoints.length) {
|
||||||
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chartFrame">
|
<div className={`chartFrame ${editMode ? 'chartFrameEditing' : ''}`} ref={frameRef}>
|
||||||
<div className="chartLegend">
|
<div className="chartLegend">
|
||||||
<span><i className="legendDot scatter" />散点数据</span>
|
<span><i className="legendDot scatter" />散点数据</span>
|
||||||
<span><i className="legendLine actual" />实际功率</span>
|
<span><i className="legendLine actual" />实际功率</span>
|
||||||
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
|
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="uplotWrap" ref={chartRef} />
|
<div className="uplotWrap" ref={chartRef} />
|
||||||
|
{editMode && brush.visible && (
|
||||||
|
<div
|
||||||
|
className={`chartBrush ${brush.dragging ? 'dragging' : ''}`}
|
||||||
|
style={{
|
||||||
|
width: BRUSH_RADIUS * 2,
|
||||||
|
height: BRUSH_RADIUS * 2,
|
||||||
|
transform: `translate(${brush.x - BRUSH_RADIUS}px, ${brush.y - BRUSH_RADIUS}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -575,6 +692,8 @@ export default function HomePage() {
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [selectedFan, setSelectedFan] = useState('');
|
const [selectedFan, setSelectedFan] = useState('');
|
||||||
const [showFiltered, setShowFiltered] = useState(false);
|
const [showFiltered, setShowFiltered] = useState(false);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [restoredPointIds, setRestoredPointIds] = useState({});
|
||||||
const [readingDesign, setReadingDesign] = useState(false);
|
const [readingDesign, setReadingDesign] = useState(false);
|
||||||
const [designRows, setDesignRows] = useState(() => createDesignRows());
|
const [designRows, setDesignRows] = useState(() => createDesignRows());
|
||||||
const [designMeta, setDesignMeta] = useState(null);
|
const [designMeta, setDesignMeta] = useState(null);
|
||||||
@@ -594,13 +713,63 @@ export default function HomePage() {
|
|||||||
const selectedScatter = selectedFan && result?.scatter_points
|
const selectedScatter = selectedFan && result?.scatter_points
|
||||||
? result.scatter_points[selectedFan] || []
|
? result.scatter_points[selectedFan] || []
|
||||||
: [];
|
: [];
|
||||||
const selectedFiltered = selectedFan && result?.filtered_points
|
const selectedFilteredRaw = selectedFan && result?.filtered_points
|
||||||
? result.filtered_points[selectedFan] || []
|
? 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
|
const selectedEstimatedParams = selectedFan && result?.estimated_params
|
||||||
? result.estimated_params[selectedFan]
|
? result.estimated_params[selectedFan]
|
||||||
: null;
|
: 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(
|
const totalRows = useMemo(
|
||||||
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
||||||
[files],
|
[files],
|
||||||
@@ -726,6 +895,8 @@ export default function HomePage() {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
setSelectedFan('');
|
setSelectedFan('');
|
||||||
setShowFiltered(false);
|
setShowFiltered(false);
|
||||||
|
setIsEditMode(false);
|
||||||
|
setRestoredPointIds({});
|
||||||
try {
|
try {
|
||||||
const parsedFiles = [];
|
const parsedFiles = [];
|
||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
@@ -812,6 +983,8 @@ export default function HomePage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setShowFiltered(false);
|
setShowFiltered(false);
|
||||||
|
setIsEditMode(false);
|
||||||
|
setRestoredPointIds({});
|
||||||
setProgress('正在准备标准化数据');
|
setProgress('正在准备标准化数据');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1181,22 +1354,30 @@ export default function HomePage() {
|
|||||||
<h2>功率曲线</h2>
|
<h2>功率曲线</h2>
|
||||||
<div className="chartTools">
|
<div className="chartTools">
|
||||||
<span>
|
<span>
|
||||||
曲线 {selectedPoints.length} 点 · 散点 {selectedScatter.length.toLocaleString()} 点
|
曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点
|
||||||
· 滤除 {selectedFiltered.length.toLocaleString()} 点
|
· 滤除 {visibleFilteredPoints.length.toLocaleString()} 点
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="secondaryButton"
|
className="secondaryButton"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFiltered((prev) => !prev)}
|
onClick={() => setShowFiltered((prev) => !prev)}
|
||||||
disabled={!selectedFiltered.length}
|
disabled={isEditMode || !visibleFilteredPoints.length}
|
||||||
>
|
>
|
||||||
{showFiltered ? '隐藏滤除点' : '显示滤除点'}
|
{showFiltered ? '隐藏滤除点' : '显示滤除点'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`secondaryButton ${isEditMode ? 'active' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleEditMode}
|
||||||
|
disabled={!selectedFiltered.length && !isEditMode}
|
||||||
|
>
|
||||||
|
{isEditMode ? '退出编辑' : '进入编辑'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="secondaryButton"
|
className="secondaryButton"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => exportValidRowsToExcel(selectedFan, selectedScatter)}
|
onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
|
||||||
disabled={!selectedScatter.length}
|
disabled={!effectiveScatter.length}
|
||||||
>
|
>
|
||||||
导出有效数据
|
导出有效数据
|
||||||
</button>
|
</button>
|
||||||
@@ -1228,10 +1409,12 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
<PowerCurveChart
|
<PowerCurveChart
|
||||||
points={selectedPoints}
|
points={selectedPoints}
|
||||||
scatterPoints={selectedScatter}
|
scatterPoints={effectiveScatter}
|
||||||
filteredPoints={selectedFiltered}
|
filteredPoints={visibleFilteredPoints}
|
||||||
showFiltered={showFiltered}
|
showFiltered={showFiltered || isEditMode}
|
||||||
designPoints={designCurve}
|
designPoints={designCurve}
|
||||||
|
editMode={isEditMode}
|
||||||
|
onRestorePoints={handleRestorePoints}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user