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 (

风电功率计算平台

多 Excel 导入、字段映射、数据清洗与功率曲线计算

{error &&
{error}
} {progress &&
{progress}
}

计算方案

选择本次计算使用的清洗方案,方案描述可保存为服务端共享配置。

{selectedScheme?.name || '方案一'}
{isSchemeTwo && (
)}
{schemeMessage}

设计功率曲线

可手动填写风速和设计功率,也可上传 Excel 自动加载后再修改。

{designCurve.length ? `${designCurve.length.toLocaleString()} 个有效点` : '未启用'}
{designMeta && (
{designMeta.file_name || '手动填写'} {designMeta.sheet_name && {designMeta.sheet_name}} {designMeta.point_count > 0 && 导入 {designMeta.point_count.toLocaleString()} 行} {designMeta.edited && 已手动修改}
)}
{designRows.map((row) => ( ))}
风速 (m/s) 设计功率 (kW) 操作
handleDesignRowChange(row.id, 'wind_speed', event.target.value)} /> handleDesignRowChange(row.id, 'design_power', event.target.value)} />

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}
)}
); }