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 (
{cards.map(([label, value]) => (
{label}
{Number(value || 0).toLocaleString()}
))}
);
}
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 暂无可绘制的曲线数据
;
}
return (
散点数据
实际功率
{validDesign.length > 0 && 设计功率}
);
}
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 (
{error &&
{error}
}
{progress &&
{progress}
}
{designMeta && (
{designMeta.file_name || '手动填写'}
{designMeta.sheet_name && {designMeta.sheet_name}}
{designMeta.point_count > 0 && 导入 {designMeta.point_count.toLocaleString()} 行}
{designMeta.edited && 已手动修改}
)}
1. 数据文件
{files.length ? `${files.length} 个文件 / ${totalRows.toLocaleString()} 行` : '等待上传'}
{!files.length ? (
请选择一个或多个 Excel 文件,系统会读取首个非空工作表。
) : (
{files.map((file) => (
{file.file_name}
{file.sheet_name} · {file.row_count.toLocaleString()} 行
))}
)}
2. 字段映射
{missingFields.length ? `缺少 ${missingFields.length} 项` : '已完成'}
{!headers.length ? (
上传文件后可指定列头对应关系。
) : (
{REQUIRED_FIELDS.map((field) => (
))}
)}
3. 列头预览
{headers.length ? `${headers.length} 列` : '暂无'}
{headers.length ? (
{headers.map((header, index) => (
{header || `未命名列 ${index + 1}`}
))}
) : (
上传后展示首个文件的列头。
)}
{result && (
4. 清洗结果
计算完成
{result.scheme && (
方案:{result.scheme.name}
{result.scheme.description}
)}
{Object.entries(result.summary?.invalid_reasons || {}).map(([reason, count]) => (
{reason}: {Number(count).toLocaleString()}
))}
功率曲线
曲线 {selectedPoints.length} 点 · 散点 {selectedScatter.length.toLocaleString()} 点
· 滤除 {selectedFiltered.length.toLocaleString()} 点
{selectedEstimatedParams && (
自动平台功率:
{Number(selectedEstimatedParams.rated_power || 0).toFixed(0)}
{' kW'}
平台起始风速:
{Number(selectedEstimatedParams.rated_wind_speed || 0).toFixed(1)}
{' m/s'}
来源:{selectedEstimatedParams.source || 'fallback'}
)}
分箱明细
{selectedBins.length} 个区间
| 风速区间 |
平均功率 |
中位数 |
样本数 |
标准差 |
置信度 |
{selectedBins.map((bin) => (
| {bin.wind_speed_start.toFixed(2)} - {bin.wind_speed_end.toFixed(2)} |
{bin.average_power.toFixed(1)} |
{bin.median_power.toFixed(1)} |
{bin.sample_count} |
{bin.stddev_power.toFixed(1)} |
{bin.confidence} |
))}
)}
);
}