d5d381759e
- 新增计算方案保存与方案二筛选参数 - 支持方案二桨角规则和有效数据导出 - 优化功率曲线绘制和设计功率展示
1275 lines
44 KiB
React
1275 lines
44 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import uPlot from 'uplot';
|
||
import 'uplot/dist/uPlot.min.css';
|
||
import * as XLSX from 'xlsx';
|
||
import {
|
||
deleteWindJob,
|
||
finishWindJob,
|
||
getWindSchemes,
|
||
saveWindSchemeDescription,
|
||
startWindJob,
|
||
uploadWindChunk,
|
||
} from '../utils/api';
|
||
|
||
const REQUIRED_FIELDS = [
|
||
{ key: 'time', label: '时间' },
|
||
{ key: 'fan_id', label: '风机编号' },
|
||
{ 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 = {
|
||
time: ['时间', 'time', 'timestamp', '日期'],
|
||
fan_id: ['风机编号', '风机', '机组编号', 'fan', 'turbine'],
|
||
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 = {
|
||
active_power: ['限功率', '限电', '时间', '累计'],
|
||
};
|
||
|
||
const DESIGN_FIELDS = [
|
||
{ key: 'wind_speed', label: '风速' },
|
||
{ key: 'design_power', label: '设计功率' },
|
||
];
|
||
|
||
const DESIGN_FIELD_HINTS = {
|
||
wind_speed: ['风速', 'wind speed', 'windspeed', 'm/s'],
|
||
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
|
||
};
|
||
|
||
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: '' }];
|
||
return source.map((point, index) => ({
|
||
id: `${Date.now()}_${index}_${Math.random().toString(16).slice(2)}`,
|
||
wind_speed: point.wind_speed ?? '',
|
||
design_power: point.design_power ?? '',
|
||
}));
|
||
}
|
||
|
||
function normalizeHeader(value) {
|
||
return String(value ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/\s+/g, '')
|
||
.replace(/[()()_\-./]/g, '');
|
||
}
|
||
|
||
function inferMapping(headers) {
|
||
const normalized = headers.map((header) => ({
|
||
header,
|
||
normalized: normalizeHeader(header),
|
||
}));
|
||
const mapping = {};
|
||
|
||
for (const field of REQUIRED_FIELDS) {
|
||
const hints = FIELD_HINTS[field.key].map(normalizeHeader);
|
||
const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader);
|
||
let best = null;
|
||
for (const item of normalized) {
|
||
if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) {
|
||
continue;
|
||
}
|
||
let score = 0;
|
||
hints.forEach((hint, index) => {
|
||
const weight = hints.length - index;
|
||
if (item.normalized === hint) {
|
||
score = Math.max(score, 100 + weight);
|
||
} else if (item.normalized.includes(hint)) {
|
||
score = Math.max(score, 60 + weight);
|
||
} else if (hint.includes(item.normalized)) {
|
||
score = Math.max(score, 20 + weight);
|
||
}
|
||
});
|
||
if (!best || score > best.score) {
|
||
best = { ...item, score };
|
||
}
|
||
}
|
||
mapping[field.key] = best && best.score > 0 ? best.header : '';
|
||
}
|
||
|
||
return mapping;
|
||
}
|
||
|
||
function inferDesignMapping(headers) {
|
||
const normalized = headers.map((header) => ({
|
||
header,
|
||
normalized: normalizeHeader(header),
|
||
}));
|
||
const mapping = {};
|
||
|
||
for (const field of DESIGN_FIELDS) {
|
||
const hints = DESIGN_FIELD_HINTS[field.key].map(normalizeHeader);
|
||
let best = null;
|
||
for (const item of normalized) {
|
||
if (!item.normalized) {
|
||
continue;
|
||
}
|
||
let score = 0;
|
||
hints.forEach((hint, index) => {
|
||
const weight = hints.length - index;
|
||
if (item.normalized === hint) {
|
||
score = Math.max(score, 100 + weight);
|
||
} else if (item.normalized.includes(hint)) {
|
||
score = Math.max(score, 60 + weight);
|
||
} else if (hint.includes(item.normalized)) {
|
||
score = Math.max(score, 20 + weight);
|
||
}
|
||
});
|
||
if (!best || score > best.score) {
|
||
best = { ...item, score };
|
||
}
|
||
}
|
||
mapping[field.key] = best && best.score > 0 ? best.header : '';
|
||
}
|
||
|
||
return mapping;
|
||
}
|
||
|
||
function excelSerialToDate(value) {
|
||
const days = Number(value);
|
||
if (!Number.isFinite(days) || days <= 0) {
|
||
return null;
|
||
}
|
||
const utcDays = Math.floor(days - 25569);
|
||
const utcValue = utcDays * 86400;
|
||
const dateInfo = new Date(utcValue * 1000);
|
||
const fractionalDay = days - Math.floor(days) + 0.0000001;
|
||
const totalSeconds = Math.floor(86400 * fractionalDay);
|
||
dateInfo.setSeconds(totalSeconds);
|
||
return dateInfo;
|
||
}
|
||
|
||
function pad(value) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
function formatDate(date) {
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||
}
|
||
|
||
function normalizeTime(value) {
|
||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||
return formatDate(value);
|
||
}
|
||
if (typeof value === 'number') {
|
||
const date = excelSerialToDate(value);
|
||
return date ? formatDate(date) : '';
|
||
}
|
||
|
||
const text = String(value ?? '').trim();
|
||
if (!text) return '';
|
||
|
||
const isoMatch = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||
if (isoMatch) {
|
||
const [, year, month, day, hour, minute, second = '0'] = isoMatch;
|
||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||
}
|
||
|
||
const slashMatch = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||
if (slashMatch) {
|
||
const [, month, day, rawYear, hour, minute, second = '0'] = slashMatch;
|
||
const year = rawYear.length === 2 ? `20${rawYear}` : rawYear;
|
||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
function normalizeNumber(value) {
|
||
if (typeof value === 'number') {
|
||
return Number.isFinite(value) ? value : null;
|
||
}
|
||
const text = String(value ?? '').trim().replace(/,/g, '');
|
||
if (!text) return null;
|
||
const number = Number(text);
|
||
return Number.isFinite(number) ? number : null;
|
||
}
|
||
|
||
function chooseFirstDataSheet(workbook) {
|
||
for (const sheetName of workbook.SheetNames) {
|
||
const sheet = workbook.Sheets[sheetName];
|
||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
|
||
const nonEmptyRows = rows.filter((row) => row.some((cell) => String(cell ?? '').trim() !== ''));
|
||
if (nonEmptyRows.length > 1) {
|
||
return { sheetName, rows: nonEmptyRows };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function readExcelFile(file) {
|
||
const buffer = await file.arrayBuffer();
|
||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||
const sheetData = chooseFirstDataSheet(workbook);
|
||
if (!sheetData) {
|
||
throw new Error(`${file.name} 没有可读取的数据工作表`);
|
||
}
|
||
|
||
const headers = sheetData.rows[0].map((cell) => String(cell ?? '').trim());
|
||
const dataRows = sheetData.rows.slice(1);
|
||
return {
|
||
id: `${file.name}_${file.size}_${file.lastModified}`,
|
||
file_name: file.name,
|
||
sheet_name: sheetData.sheetName,
|
||
headers,
|
||
rows: dataRows,
|
||
row_count: dataRows.length,
|
||
};
|
||
}
|
||
|
||
async function readDesignPowerFile(file) {
|
||
const parsedFile = await readExcelFile(file);
|
||
const mapping = inferDesignMapping(parsedFile.headers);
|
||
const windSpeedIndex = parsedFile.headers.indexOf(mapping.wind_speed);
|
||
const designPowerIndex = parsedFile.headers.indexOf(mapping.design_power);
|
||
|
||
if (windSpeedIndex < 0 || designPowerIndex < 0) {
|
||
throw new Error('设计功率文件未识别到风速列或设计功率列');
|
||
}
|
||
|
||
const grouped = new Map();
|
||
for (const row of parsedFile.rows) {
|
||
const windSpeed = normalizeNumber(getCell(row, windSpeedIndex));
|
||
const designPower = normalizeNumber(getCell(row, designPowerIndex));
|
||
if (!Number.isFinite(windSpeed) || !Number.isFinite(designPower) ||
|
||
windSpeed < 0 || designPower < 0) {
|
||
continue;
|
||
}
|
||
const key = windSpeed.toFixed(6);
|
||
const current = grouped.get(key) || { wind_speed: windSpeed, sum: 0, count: 0 };
|
||
current.sum += designPower;
|
||
current.count += 1;
|
||
grouped.set(key, current);
|
||
}
|
||
|
||
const points = Array.from(grouped.values())
|
||
.map((item) => ({
|
||
wind_speed: item.wind_speed,
|
||
design_power: item.sum / item.count,
|
||
}))
|
||
.sort((left, right) => left.wind_speed - right.wind_speed);
|
||
|
||
if (!points.length) {
|
||
throw new Error('设计功率文件没有可用的风速-功率数据');
|
||
}
|
||
|
||
return {
|
||
file_name: parsedFile.file_name,
|
||
sheet_name: parsedFile.sheet_name,
|
||
points,
|
||
};
|
||
}
|
||
|
||
function getCell(row, headerIndex) {
|
||
if (headerIndex < 0) return '';
|
||
return row[headerIndex];
|
||
}
|
||
|
||
function buildStandardRows(files, mapping) {
|
||
const firstHeaders = files[0]?.headers || [];
|
||
const indexes = Object.fromEntries(
|
||
REQUIRED_FIELDS.map((field) => [field.key, firstHeaders.indexOf(mapping[field.key])]),
|
||
);
|
||
|
||
const rows = [];
|
||
for (const file of files) {
|
||
const localIndexes = Object.fromEntries(
|
||
REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]),
|
||
);
|
||
for (const row of file.rows) {
|
||
rows.push({
|
||
time: normalizeTime(getCell(row, localIndexes.time)),
|
||
fan_id: String(getCell(row, localIndexes.fan_id) ?? '').trim(),
|
||
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)),
|
||
});
|
||
}
|
||
}
|
||
|
||
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 = [
|
||
['原始行数', summary.raw_rows],
|
||
['有效行数', summary.valid_rows],
|
||
['剔除行数', summary.invalid_rows],
|
||
['风机数量', summary.fan_count],
|
||
];
|
||
return (
|
||
<div className="summaryGrid">
|
||
{cards.map(([label, value]) => (
|
||
<div className="metric" key={label}>
|
||
<span>{label}</span>
|
||
<strong>{Number(value || 0).toLocaleString()}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PowerCurveChart({
|
||
points,
|
||
scatterPoints,
|
||
filteredPoints,
|
||
showFiltered,
|
||
designPoints,
|
||
}) {
|
||
const chartRef = useRef(null);
|
||
const validPoints = useMemo(
|
||
() => (points || []).filter((point) => point.sample_count > 0),
|
||
[points],
|
||
);
|
||
const validScatter = useMemo(
|
||
() => (scatterPoints || []).filter((point) =>
|
||
Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)),
|
||
[scatterPoints],
|
||
);
|
||
const validFiltered = useMemo(
|
||
() => (filteredPoints || []).filter((point) =>
|
||
Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)),
|
||
[filteredPoints],
|
||
);
|
||
const validDesign = useMemo(
|
||
() => (designPoints || []).filter((point) =>
|
||
Number.isFinite(point.wind_speed) && Number.isFinite(point.design_power)),
|
||
[designPoints],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!chartRef.current || !validPoints.length) return undefined;
|
||
|
||
const sortedCurve = [...validPoints].sort((left, right) => left.wind_speed - right.wind_speed);
|
||
const sortedDesign = [...validDesign].sort((left, right) => left.wind_speed - right.wind_speed);
|
||
const actualByWindSpeed = new Map(
|
||
sortedCurve.map((point) => [point.wind_speed.toFixed(6), point.average_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) => interpolateDesignPower(sortedDesign, windSpeed));
|
||
const maxScatterPower = validScatter.reduce(
|
||
(max, point) => Math.max(max, point.active_power),
|
||
0,
|
||
);
|
||
const maxFilteredPower = showFiltered
|
||
? validFiltered.reduce((max, point) => Math.max(max, point.active_power), 0)
|
||
: 0;
|
||
const maxCurvePower = sortedCurve.reduce(
|
||
(max, point) => Math.max(max, point.average_power),
|
||
0,
|
||
);
|
||
const maxDesignPower = sortedDesign.reduce(
|
||
(max, point) => Math.max(max, point.design_power),
|
||
0,
|
||
);
|
||
const maxPower = Math.max(maxCurvePower, maxDesignPower, maxScatterPower, maxFilteredPower, 1);
|
||
const width = Math.max(chartRef.current.clientWidth || 760, 320);
|
||
|
||
chartRef.current.innerHTML = '';
|
||
const chart = new uPlot({
|
||
width,
|
||
height: CHART_HEIGHT,
|
||
cursor: {
|
||
drag: { x: true, y: true },
|
||
},
|
||
scales: {
|
||
x: { time: false },
|
||
y: { range: [0, maxPower * 1.08] },
|
||
},
|
||
axes: [
|
||
{
|
||
label: '风速 (m/s)',
|
||
stroke: '#9ca3af',
|
||
grid: { stroke: '#2a2a3a', width: 1 },
|
||
},
|
||
{
|
||
label: '功率 (kW)',
|
||
stroke: '#9ca3af',
|
||
grid: { stroke: '#2a2a3a', width: 1 },
|
||
},
|
||
],
|
||
series: [
|
||
{},
|
||
{
|
||
label: '实际功率',
|
||
stroke: '#ef4444',
|
||
width: 2.5,
|
||
paths: uPlot.paths.spline(),
|
||
spanGaps: true,
|
||
points: { show: false },
|
||
},
|
||
{
|
||
label: '设计功率',
|
||
stroke: '#22c55e',
|
||
width: 2.2,
|
||
paths: uPlot.paths.spline(),
|
||
spanGaps: true,
|
||
points: { show: false },
|
||
},
|
||
],
|
||
hooks: {
|
||
drawAxes: [
|
||
(u) => {
|
||
const { ctx } = u;
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(251, 191, 36, 0.28)';
|
||
for (const point of validScatter) {
|
||
const x = u.valToPos(point.wind_speed, 'x', true);
|
||
const y = u.valToPos(point.active_power, 'y', true);
|
||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
if (showFiltered) {
|
||
ctx.fillStyle = 'rgba(248, 113, 113, 0.42)';
|
||
for (const point of validFiltered) {
|
||
const x = u.valToPos(point.wind_speed, 'x', true);
|
||
const y = u.valToPos(point.active_power, 'y', true);
|
||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
},
|
||
],
|
||
},
|
||
}, [curveX, actualY, designY], chartRef.current);
|
||
|
||
return () => chart.destroy();
|
||
}, [validPoints, validScatter, validFiltered, validDesign, showFiltered]);
|
||
|
||
if (!validPoints.length) {
|
||
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="chartFrame">
|
||
<div className="chartLegend">
|
||
<span><i className="legendDot scatter" />散点数据</span>
|
||
<span><i className="legendLine actual" />实际功率</span>
|
||
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
|
||
</div>
|
||
<div className="uplotWrap" ref={chartRef} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function HomePage() {
|
||
const [files, setFiles] = useState([]);
|
||
const [mapping, setMapping] = useState({});
|
||
const [reading, setReading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [progress, setProgress] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [result, setResult] = useState(null);
|
||
const [selectedFan, setSelectedFan] = useState('');
|
||
const [showFiltered, setShowFiltered] = useState(false);
|
||
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
|
||
? result.scatter_points[selectedFan] || []
|
||
: [];
|
||
const selectedFiltered = selectedFan && result?.filtered_points
|
||
? result.filtered_points[selectedFan] || []
|
||
: [];
|
||
const selectedEstimatedParams = selectedFan && result?.estimated_params
|
||
? result.estimated_params[selectedFan]
|
||
: null;
|
||
|
||
const totalRows = useMemo(
|
||
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
||
[files],
|
||
);
|
||
const designCurve = useMemo(() => {
|
||
const grouped = new Map();
|
||
for (const row of designRows) {
|
||
const windSpeed = normalizeNumber(row.wind_speed);
|
||
const designPower = normalizeNumber(row.design_power);
|
||
if (!Number.isFinite(windSpeed) || !Number.isFinite(designPower) ||
|
||
windSpeed < 0 || designPower < 0) {
|
||
continue;
|
||
}
|
||
const key = windSpeed.toFixed(6);
|
||
const current = grouped.get(key) || { wind_speed: windSpeed, sum: 0, count: 0 };
|
||
current.sum += designPower;
|
||
current.count += 1;
|
||
grouped.set(key, current);
|
||
}
|
||
return Array.from(grouped.values())
|
||
.map((item) => ({
|
||
wind_speed: item.wind_speed,
|
||
design_power: item.sum / item.count,
|
||
}))
|
||
.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;
|
||
|
||
setReading(true);
|
||
setError('');
|
||
setResult(null);
|
||
setSelectedFan('');
|
||
setShowFiltered(false);
|
||
try {
|
||
const parsedFiles = [];
|
||
for (const file of selectedFiles) {
|
||
parsedFiles.push(await readExcelFile(file));
|
||
}
|
||
setFiles(parsedFiles);
|
||
setMapping(inferMapping(parsedFiles[0].headers));
|
||
} catch (err) {
|
||
setError(err.message || '读取 Excel 失败');
|
||
} finally {
|
||
setReading(false);
|
||
event.target.value = '';
|
||
}
|
||
}
|
||
|
||
async function handleDesignFileChange(event) {
|
||
const [file] = Array.from(event.target.files || []);
|
||
if (!file) return;
|
||
|
||
setReadingDesign(true);
|
||
setError('');
|
||
try {
|
||
const design = await readDesignPowerFile(file);
|
||
setDesignRows(createDesignRows(design.points));
|
||
setDesignMeta({
|
||
file_name: design.file_name,
|
||
sheet_name: design.sheet_name,
|
||
point_count: design.points.length,
|
||
});
|
||
} catch (err) {
|
||
setError(err.message || '读取设计功率失败');
|
||
} finally {
|
||
setReadingDesign(false);
|
||
event.target.value = '';
|
||
}
|
||
}
|
||
|
||
function handleDesignRowChange(rowId, field, value) {
|
||
setDesignRows((prev) => prev.map((row) => (
|
||
row.id === rowId ? { ...row, [field]: value } : row
|
||
)));
|
||
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
|
||
}
|
||
|
||
function handleAddDesignRow() {
|
||
setDesignRows((prev) => [
|
||
...prev,
|
||
...createDesignRows([{ wind_speed: '', design_power: '' }]),
|
||
]);
|
||
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
|
||
}
|
||
|
||
function handleDeleteDesignRow(rowId) {
|
||
setDesignRows((prev) => {
|
||
const next = prev.filter((row) => row.id !== rowId);
|
||
return next.length ? next : createDesignRows();
|
||
});
|
||
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
|
||
}
|
||
|
||
function handleClearDesignRows() {
|
||
setDesignRows(createDesignRows());
|
||
setDesignMeta(null);
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!files.length || missingFields.length) {
|
||
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);
|
||
setError('');
|
||
setResult(null);
|
||
setShowFiltered(false);
|
||
setProgress('正在准备标准化数据');
|
||
|
||
try {
|
||
const { rows } = buildStandardRows(files, mapping);
|
||
const start = await startWindJob({
|
||
files: files.map((file) => ({
|
||
file_name: file.file_name,
|
||
row_count: file.row_count,
|
||
})),
|
||
mapping,
|
||
});
|
||
jobId = start.job_id;
|
||
|
||
const chunkCount = Math.ceil(rows.length / CHUNK_SIZE);
|
||
for (let index = 0; index < chunkCount; index += 1) {
|
||
const chunkRows = rows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
|
||
setProgress(`正在上传数据分片 ${index + 1}/${chunkCount}`);
|
||
await uploadWindChunk({
|
||
job_id: jobId,
|
||
chunk_index: index,
|
||
rows: chunkRows,
|
||
});
|
||
}
|
||
|
||
setProgress('正在清洗数据并计算功率曲线');
|
||
const calculation = await finishWindJob({
|
||
job_id: jobId,
|
||
options: {
|
||
power_step: 5,
|
||
cleaning_wind_speed_step: 0.25,
|
||
curve_wind_speed_step: 0.5,
|
||
wind_speed_change_threshold: 1,
|
||
iqr_lower_multiplier: 1.8,
|
||
iqr_upper_multiplier: 2,
|
||
minimum_generator_speed: 1,
|
||
generator_speed_k: 0.9,
|
||
scheme_id: selectedSchemeId,
|
||
...(isSchemeTwo ? {
|
||
grid_connected_speed: gridConnectedSpeed,
|
||
rated_generator_speed: ratedGeneratorSpeed,
|
||
rated_power: ratedPower,
|
||
} : {}),
|
||
},
|
||
});
|
||
setResult(calculation);
|
||
setSelectedFan(calculation.fans?.[0] || '');
|
||
setProgress('计算完成');
|
||
} catch (err) {
|
||
if (jobId) {
|
||
try {
|
||
await deleteWindJob(jobId);
|
||
} catch {
|
||
// 失败任务清理失败不影响用户看到主错误。
|
||
}
|
||
}
|
||
setError(err.message || '计算失败');
|
||
setProgress('');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="home">
|
||
<header className="homeHeader">
|
||
<div>
|
||
<h1>风电功率计算平台</h1>
|
||
<p className="subtitle">多 Excel 导入、字段映射、数据清洗与功率曲线计算</p>
|
||
</div>
|
||
<div className="headerActions">
|
||
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
|
||
<input
|
||
type="file"
|
||
accept=".xls,.xlsx"
|
||
multiple
|
||
disabled={reading || submitting}
|
||
onChange={handleFilesChange}
|
||
/>
|
||
{reading ? '解析中' : '上传 Excel'}
|
||
</label>
|
||
</div>
|
||
</header>
|
||
|
||
{error && <div className="alert error">{error}</div>}
|
||
{progress && <div className="alert info">{progress}</div>}
|
||
|
||
<section className="panel schemePanel">
|
||
<div className="panelHeader">
|
||
<div>
|
||
<h2>计算方案</h2>
|
||
<p className="panelHint">选择本次计算使用的清洗方案,方案描述可保存为服务端共享配置。</p>
|
||
</div>
|
||
<span>{selectedScheme?.name || '方案一'}</span>
|
||
</div>
|
||
<div className="schemeGrid">
|
||
<label className="fieldControl">
|
||
<span>方案选择</span>
|
||
<select
|
||
value={selectedSchemeId}
|
||
onChange={(event) => handleSchemeChange(event.target.value)}
|
||
disabled={submitting}
|
||
>
|
||
{schemes.map((scheme) => (
|
||
<option key={scheme.id} value={scheme.id}>{scheme.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="schemeDescriptionControl">
|
||
<span>方案描述</span>
|
||
<input
|
||
type="text"
|
||
value={schemeDescription}
|
||
onChange={(event) => {
|
||
setSchemeDescription(event.target.value);
|
||
setSchemeMessage('');
|
||
}}
|
||
disabled={submitting || savingScheme}
|
||
/>
|
||
</label>
|
||
</div>
|
||
{isSchemeTwo && (
|
||
<div className="schemeParamGrid">
|
||
<label className="fieldControl">
|
||
<span>并网转速 (rpm)</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={schemeTwoParams.grid_connected_speed}
|
||
onChange={(event) => handleSchemeTwoParamChange(
|
||
'grid_connected_speed',
|
||
event.target.value,
|
||
)}
|
||
disabled={submitting}
|
||
placeholder="请输入"
|
||
/>
|
||
</label>
|
||
<label className="fieldControl">
|
||
<span>额定转速 (rpm)</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={schemeTwoParams.rated_generator_speed}
|
||
onChange={(event) => handleSchemeTwoParamChange(
|
||
'rated_generator_speed',
|
||
event.target.value,
|
||
)}
|
||
disabled={submitting}
|
||
placeholder="请输入"
|
||
/>
|
||
</label>
|
||
<label className="fieldControl">
|
||
<span>额定功率 (kW)</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.1"
|
||
value={schemeTwoParams.rated_power}
|
||
onChange={(event) => handleSchemeTwoParamChange(
|
||
'rated_power',
|
||
event.target.value,
|
||
)}
|
||
disabled={submitting}
|
||
placeholder="请输入"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<div className="schemeFooter">
|
||
<span>{schemeMessage}</span>
|
||
<button
|
||
className="secondaryButton"
|
||
type="button"
|
||
onClick={handleSaveSchemeDescription}
|
||
disabled={submitting || savingScheme}
|
||
>
|
||
{savingScheme ? '保存中' : '保存'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel designPowerPanel">
|
||
<div className="panelHeader">
|
||
<div>
|
||
<h2>设计功率曲线</h2>
|
||
<p className="panelHint">可手动填写风速和设计功率,也可上传 Excel 自动加载后再修改。</p>
|
||
</div>
|
||
<div className="designActions">
|
||
<span>{designCurve.length ? `${designCurve.length.toLocaleString()} 个有效点` : '未启用'}</span>
|
||
<label className={`secondaryButton fileButton ${readingDesign ? 'disabled' : ''}`}>
|
||
<input
|
||
type="file"
|
||
accept=".xls,.xlsx"
|
||
disabled={readingDesign}
|
||
onChange={handleDesignFileChange}
|
||
/>
|
||
{readingDesign ? '解析中' : '导入 Excel'}
|
||
</label>
|
||
<button className="secondaryButton" type="button" onClick={handleAddDesignRow}>
|
||
新增一行
|
||
</button>
|
||
<button className="secondaryButton" type="button" onClick={handleClearDesignRows}>
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{designMeta && (
|
||
<div className="designMeta">
|
||
<span>{designMeta.file_name || '手动填写'}</span>
|
||
{designMeta.sheet_name && <span>{designMeta.sheet_name}</span>}
|
||
{designMeta.point_count > 0 && <span>导入 {designMeta.point_count.toLocaleString()} 行</span>}
|
||
{designMeta.edited && <span>已手动修改</span>}
|
||
</div>
|
||
)}
|
||
<div className="designTableWrap">
|
||
<table className="designTable">
|
||
<thead>
|
||
<tr>
|
||
<th>风速 (m/s)</th>
|
||
<th>设计功率 (kW)</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{designRows.map((row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<input
|
||
className="tableInput"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={row.wind_speed}
|
||
placeholder="例如 3.0"
|
||
onChange={(event) => handleDesignRowChange(row.id, 'wind_speed', event.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="tableInput"
|
||
type="number"
|
||
min="0"
|
||
step="0.1"
|
||
value={row.design_power}
|
||
placeholder="例如 120.0"
|
||
onChange={(event) => handleDesignRowChange(row.id, 'design_power', event.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<button
|
||
className="textButton"
|
||
type="button"
|
||
onClick={() => handleDeleteDesignRow(row.id)}
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="workspace">
|
||
<div className="panel">
|
||
<div className="panelHeader">
|
||
<h2>1. 数据文件</h2>
|
||
<span>{files.length ? `${files.length} 个文件 / ${totalRows.toLocaleString()} 行` : '等待上传'}</span>
|
||
</div>
|
||
{!files.length ? (
|
||
<div className="emptyState">请选择一个或多个 Excel 文件,系统会读取首个非空工作表。</div>
|
||
) : (
|
||
<div className="fileList">
|
||
{files.map((file) => (
|
||
<div className="fileItem" key={file.id}>
|
||
<strong>{file.file_name}</strong>
|
||
<span>{file.sheet_name} · {file.row_count.toLocaleString()} 行</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHeader">
|
||
<h2>2. 字段映射</h2>
|
||
<span>{missingFields.length ? `缺少 ${missingFields.length} 项` : '已完成'}</span>
|
||
</div>
|
||
{!headers.length ? (
|
||
<div className="emptyState">上传文件后可指定列头对应关系。</div>
|
||
) : (
|
||
<div className="mappingGrid">
|
||
{REQUIRED_FIELDS.map((field) => (
|
||
<label className="fieldControl" key={field.key}>
|
||
<span>{field.label}</span>
|
||
<select
|
||
value={mapping[field.key] || ''}
|
||
onChange={(event) => setMapping((prev) => ({
|
||
...prev,
|
||
[field.key]: event.target.value,
|
||
}))}
|
||
disabled={submitting}
|
||
>
|
||
<option value="">请选择列头</option>
|
||
{headers.map((header, index) => (
|
||
<option value={header} key={`${header}-${index}`}>{header || `未命名列 ${index + 1}`}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="actionRow">
|
||
<button
|
||
className="primaryButton"
|
||
type="button"
|
||
disabled={!files.length || missingFields.length > 0 || submitting}
|
||
onClick={handleSubmit}
|
||
>
|
||
{submitting ? '计算中' : '开始计算'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel">
|
||
<div className="panelHeader">
|
||
<h2>3. 列头预览</h2>
|
||
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
||
</div>
|
||
{headers.length ? (
|
||
<div className="headerPreview">
|
||
{headers.map((header, index) => (
|
||
<span key={`${header}-${index}`}>{header || `未命名列 ${index + 1}`}</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="emptyState">上传后展示首个文件的列头。</div>
|
||
)}
|
||
</section>
|
||
|
||
{result && (
|
||
<section className="resultSection">
|
||
<div className="panel">
|
||
<div className="panelHeader">
|
||
<h2>4. 清洗结果</h2>
|
||
<span>计算完成</span>
|
||
</div>
|
||
<SummaryCards summary={result.summary} />
|
||
{result.scheme && (
|
||
<div className="paramStrip">
|
||
<span>方案:{result.scheme.name}</span>
|
||
<span>{result.scheme.description}</span>
|
||
</div>
|
||
)}
|
||
<div className="reasonList">
|
||
{Object.entries(result.summary?.invalid_reasons || {}).map(([reason, count]) => (
|
||
<span key={reason}>{reason}: {Number(count).toLocaleString()}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHeader">
|
||
<h2>功率曲线</h2>
|
||
<div className="chartTools">
|
||
<span>
|
||
曲线 {selectedPoints.length} 点 · 散点 {selectedScatter.length.toLocaleString()} 点
|
||
· 滤除 {selectedFiltered.length.toLocaleString()} 点
|
||
</span>
|
||
<button
|
||
className="secondaryButton"
|
||
type="button"
|
||
onClick={() => setShowFiltered((prev) => !prev)}
|
||
disabled={!selectedFiltered.length}
|
||
>
|
||
{showFiltered ? '隐藏滤除点' : '显示滤除点'}
|
||
</button>
|
||
<button
|
||
className="secondaryButton"
|
||
type="button"
|
||
onClick={() => exportValidRowsToExcel(selectedFan, selectedScatter)}
|
||
disabled={!selectedScatter.length}
|
||
>
|
||
导出有效数据
|
||
</button>
|
||
<select
|
||
className="fanSelect"
|
||
value={selectedFan}
|
||
onChange={(event) => setSelectedFan(event.target.value)}
|
||
>
|
||
{(result.fans || []).map((fan) => (
|
||
<option key={fan} value={fan}>{fan}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{selectedEstimatedParams && (
|
||
<div className="paramStrip">
|
||
<span>
|
||
自动平台功率:
|
||
{Number(selectedEstimatedParams.rated_power || 0).toFixed(0)}
|
||
{' kW'}
|
||
</span>
|
||
<span>
|
||
平台起始风速:
|
||
{Number(selectedEstimatedParams.rated_wind_speed || 0).toFixed(1)}
|
||
{' m/s'}
|
||
</span>
|
||
<span>来源:{selectedEstimatedParams.source || 'fallback'}</span>
|
||
</div>
|
||
)}
|
||
<PowerCurveChart
|
||
points={selectedPoints}
|
||
scatterPoints={selectedScatter}
|
||
filteredPoints={selectedFiltered}
|
||
showFiltered={showFiltered}
|
||
designPoints={designCurve}
|
||
/>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="panelHeader">
|
||
<h2>分箱明细</h2>
|
||
<span>{selectedBins.length} 个区间</span>
|
||
</div>
|
||
<div className="tableWrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>风速区间</th>
|
||
<th>平均功率</th>
|
||
<th>中位数</th>
|
||
<th>样本数</th>
|
||
<th>标准差</th>
|
||
<th>置信度</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{selectedBins.map((bin) => (
|
||
<tr key={`${bin.wind_speed_start}-${bin.wind_speed_end}`}>
|
||
<td>{bin.wind_speed_start.toFixed(2)} - {bin.wind_speed_end.toFixed(2)}</td>
|
||
<td>{bin.average_power.toFixed(1)}</td>
|
||
<td>{bin.median_power.toFixed(1)}</td>
|
||
<td>{bin.sample_count}</td>
|
||
<td>{bin.stddev_power.toFixed(1)}</td>
|
||
<td><span className={`confidence ${bin.confidence === '正常' ? 'ok' : 'low'}`}>{bin.confidence}</span></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|