import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Upload, FileText, Download } from 'lucide-react'; import type { SimulationResult } from './types'; import { CALIBRATION_PARAMS, buildCalibrationReport, formatCalibrationReport, parseCalibrationFile, type CalibrationParam, type Observation, } from './calibration'; export interface CalibrationData { fileName: string; param: CalibrationParam; observations: Observation[]; skippedLines: number[]; } interface CalibrationPanelProps { result: SimulationResult; data: CalibrationData | null; onDataChange: (data: CalibrationData | null) => void; /** Raw engine value -> display units, per parameter. */ toDisplay: Record number>; units: Record; onFocusLocation: (type: 'node' | 'link', id: string) => void; /** 지도에 값 보기: labels for the map (null = hide). */ onAnnotations?: (annotations: import('./types').MapAnnotation[] | null) => void; } const W = 320; const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : v.toFixed(2)); const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`; function niceRange(values: number[]): [number, number] { let lo = Math.min(...values); let hi = Math.max(...values); if (!Number.isFinite(lo)) return [0, 1]; if (hi === lo) { lo -= 1; hi += 1; } const pad = (hi - lo) * 0.08; return [lo - pad, hi + pad]; } export const CalibrationPanel: React.FC = ({ result, data, onDataChange, toDisplay, units, onFocusLocation, onAnnotations }) => { const fileRef = useRef(null); const [selectedLoc, setSelectedLoc] = useState(null); const [showReport, setShowReport] = useState(false); const [pickedParam, setPickedParam] = useState(data?.param ?? 'pressure'); const param = data?.param ?? pickedParam; const paramMeta = CALIBRATION_PARAMS.find((p) => p.id === param)!; const unit = units[param]; const report = useMemo( () => (data ? buildCalibrationReport(data.observations, data.param, result, toDisplay[data.param]) : null), [data, result, toDisplay] ); const activeLoc = selectedLoc && report?.locations.some((l) => l.location === selectedLoc) ? selectedLoc : report?.locations[0]?.location ?? null; const handleFile = async (file: File) => { const parsed = parseCalibrationFile(await file.text()); onDataChange({ fileName: file.name, param, observations: parsed.observations, skippedLines: parsed.skippedLines }); setSelectedLoc(null); }; const reportText = report ? formatCalibrationReport(report, paramMeta.label, unit) : ''; const [showOnMap, setShowOnMap] = useState(false); useEffect(() => { if (!onAnnotations) return; if (!showOnMap || !report) { onAnnotations(null); return; } onAnnotations( report.locations.map((l) => ({ type: paramMeta.element, id: l.location, text: `실측 ${fmt(l.observedMean)} / 계산 ${fmt(l.computedMean)}\n오차 ${fmt(l.computedMean - l.observedMean)} ${unit}`, color: Math.abs(l.computedMean - l.observedMean) > Math.max(1e-9, 0.1 * Math.abs(l.observedMean)) ? '#b91c1c' : '#0f172a', })) ); }, [showOnMap, report, paramMeta.element, unit, onAnnotations]); useEffect(() => () => onAnnotations?.(null), [onAnnotations]); const downloadReport = () => { const blob = new Blob(['' + reportText], { type: 'text/plain;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `calibration_${param}.txt`; a.click(); URL.revokeObjectURL(a.href); }; // ---- time-series chart for the selected location ---- const series = useMemo(() => { if (!report || !activeLoc) return null; const ts = result.timeSteps ?? []; const raw = paramMeta.element === 'node' ? result.nodes[activeLoc]?.timeSeries?.[param as 'pressure' | 'head' | 'demand' | 'quality'] : result.links[activeLoc]?.timeSeries?.[param as 'flow' | 'velocity']; const sim = (raw ?? []).map((v) => toDisplay[param](paramMeta.element === 'link' ? Math.abs(v) : v)); const obs = report.points.filter((p) => p.location === activeLoc); return { ts, sim, obs }; }, [report, activeLoc, result, param, paramMeta.element, toDisplay]); return (
{ const f = e.target.files?.[0]; if (f) void handleFile(f); e.target.value = ''; }} />
{!data ? (
실측값 파일(.dat / .csv / .txt)을 불러오면 해석값과 비교합니다. 한 줄에 위치 ID, 시각, 값을 적고, 시각은 시간(6, 6.5) 또는 시:분(6:30)으로 씁니다. 값은 화면에 표시된 단위로 입력합니다.
{`;위치   시각   값
1265   0      52.3
       6      48.1
1270   6:30   44.0`}
          
) : !report || report.points.length === 0 ? (
{data.fileName}: 비교할 수 있는 관측값이 없습니다. {report?.unknownLocations.length ? ` 모델에 없는 위치: ${report.unknownLocations.slice(0, 8).join(', ')}` : ''} {' '}항목({paramMeta.label})이 맞는지 확인하세요.
) : ( <>
{data.fileName} · 관측값 {report.network.count}개 · 위치 {report.locations.length}곳 {report.unknownLocations.length > 0 && ` · 모델에 없는 위치 ${report.unknownLocations.length}곳 제외`} {report.outOfRange > 0 && ` · 해석 기간 밖 ${report.outOfRange}개 제외`} {data.skippedLines.length > 0 && ` · 읽지 못한 줄 ${data.skippedLines.length}개`}
{/* Report table (EPANET calibration report columns) */}
{['위치', '수', '관측 평균', '계산 평균', '평균 오차', 'RMS 오차'].map((h, i) => ( ))} {report.locations.map((l) => ( { setSelectedLoc(l.location); onFocusLocation(paramMeta.element, l.location); }} style={{ cursor: 'pointer', background: l.location === activeLoc ? 'var(--accent-soft, #e0ecff)' : undefined, borderBottom: '1px solid var(--border)' }} title="클릭하면 그래프와 지도 위치를 보여줍니다" > ))}
{h}
{l.location} {l.count} {fmt(l.observedMean)} {fmt(l.computedMean)} {fmt(l.meanError)} {fmt(l.rmsError)}
전체 {report.network.count} {fmt(report.network.observedMean)} {fmt(report.network.computedMean)} {fmt(report.network.meanError)} {fmt(report.network.rmsError)}
평균값 상관계수 R = {report.correlationOfMeans === null ? '-' : report.correlationOfMeans.toFixed(3)} · 전체 상관계수 R = {report.correlationAll === null ? '-' : report.correlationAll.toFixed(3)} · 단위 {unit}
{showReport && (
              {reportText}
            
)} {series && activeLoc && ( )} )}
); }; /** Observed (x) vs computed (y) with the 1:1 line -- EPANET's correlation plot. */ const ScatterChart: React.FC<{ points: { location: string; observed: number; computed: number }[]; unit: string; highlight: string | null }> = ({ points, unit, highlight }) => { const H = 240, pl = 40, pr = 10, pt = 18, pb = 30; const [lo, hi] = niceRange(points.flatMap((p) => [p.observed, p.computed])); const sx = (v: number) => pl + ((v - lo) / (hi - lo)) * (W - pl - pr); const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb); const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo)); return ( 실측 vs 계산 ({unit}) {ticks.map((t) => ( {fmt(t)} {fmt(t)} ))} {points.map((p, i) => ( ))} 실측값 계산값 ); }; /** Simulated series (line) with the measurements (dots) for one location. */ const SeriesChart: React.FC<{ location: string; unit: string; ts: number[]; sim: number[]; obs: { timeSec: number; observed: number }[] }> = ({ location, unit, ts, sim, obs }) => { const H = 200, pl = 40, pr = 10, pt = 18, pb = 26; const tMax = Math.max(ts[ts.length - 1] ?? 0, ...obs.map((o) => o.timeSec), 1); const [lo, hi] = niceRange([...sim, ...obs.map((o) => o.observed)]); const sx = (t: number) => pl + (t / tMax) * (W - pl - pr); const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb); const path = sim.map((v, i) => `${i ? 'L' : 'M'}${sx(ts[i] ?? 0).toFixed(1)},${sy(v).toFixed(1)}`).join(''); const hourStep = tMax > 48 * 3600 ? 12 : tMax > 24 * 3600 ? 6 : 3; const hourTicks: number[] = []; for (let h = 0; h * 3600 <= tMax; h += hourStep) hourTicks.push(h * 3600); return ( {location} 시간별 ({unit}) 계산 실측 {[0, 0.5, 1].map((f) => { const v = lo + f * (hi - lo); return ( {fmt(v)} ); })} {hourTicks.map((t) => ( {hhmm(t)} ))} {obs.map((o, i) => )} ); };