Files
wind_power_cal/frontend/web_app/src/pages/HomePage.jsx
T
cloud d5d381759e 功能: 完善风电功率方案计算
- 新增计算方案保存与方案二筛选参数
- 支持方案二桨角规则和有效数据导出
- 优化功率曲线绘制和设计功率展示
2026-07-17 16:01:58 +08:00

1275 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}