SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -0,0 +1,295 @@
|
||||
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<CalibrationParam, (v: number) => number>;
|
||||
units: Record<CalibrationParam, string>;
|
||||
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<CalibrationPanelProps> = ({ result, data, onDataChange, toDisplay, units, onFocusLocation, onAnnotations }) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedLoc, setSelectedLoc] = useState<string | null>(null);
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
|
||||
const [pickedParam, setPickedParam] = useState<CalibrationParam>(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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-xs)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--s-2)', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
value={param}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as CalibrationParam;
|
||||
setPickedParam(next);
|
||||
if (data) onDataChange({ ...data, param: next });
|
||||
}}
|
||||
style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}
|
||||
aria-label="보정 항목"
|
||||
>
|
||||
{CALIBRATION_PARAMS.map((p) => <option key={p.id} value={p.id}>{p.label} ({units[p.id]})</option>)}
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={() => fileRef.current?.click()} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
|
||||
<Upload size={12} /><span>실측 데이터 불러오기</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".dat,.csv,.txt"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f); e.target.value = ''; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!data ? (
|
||||
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
실측값 파일(.dat / .csv / .txt)을 불러오면 해석값과 비교합니다. 한 줄에 <b>위치 ID, 시각, 값</b>을 적고,
|
||||
시각은 시간(6, 6.5) 또는 시:분(6:30)으로 씁니다. 값은 화면에 표시된 단위로 입력합니다.
|
||||
<pre style={{ margin: '6px 0 0', padding: '6px 8px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', fontSize: '11px' }}>
|
||||
{`;위치 시각 값
|
||||
1265 0 52.3
|
||||
6 48.1
|
||||
1270 6:30 44.0`}
|
||||
</pre>
|
||||
</div>
|
||||
) : !report || report.points.length === 0 ? (
|
||||
<div style={{ color: 'var(--crit)' }}>
|
||||
{data.fileName}: 비교할 수 있는 관측값이 없습니다.
|
||||
{report?.unknownLocations.length ? ` 모델에 없는 위치: ${report.unknownLocations.slice(0, 8).join(', ')}` : ''}
|
||||
{' '}항목({paramMeta.label})이 맞는지 확인하세요.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
{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}개`}
|
||||
</div>
|
||||
|
||||
{/* Report table (EPANET calibration report columns) */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg-subtle)', textAlign: 'right' }}>
|
||||
{['위치', '수', '관측 평균', '계산 평균', '평균 오차', 'RMS 오차'].map((h, i) => (
|
||||
<th key={h} style={{ padding: '4px 5px', textAlign: i === 0 ? 'left' : 'right', fontWeight: 600, fontFamily: 'var(--font-sans)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.locations.map((l) => (
|
||||
<tr
|
||||
key={l.location}
|
||||
onClick={() => { 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="클릭하면 그래프와 지도 위치를 보여줍니다"
|
||||
>
|
||||
<td style={{ padding: '3px 5px' }}>{l.location}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{l.count}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.observedMean)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.computedMean)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.meanError)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.rmsError)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr style={{ fontWeight: 700, background: 'var(--bg-subtle)' }}>
|
||||
<td style={{ padding: '3px 5px' }}>전체</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{report.network.count}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.observedMean)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.computedMean)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.meanError)}</td>
|
||||
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.rmsError)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 4, color: 'var(--text-secondary)' }}>
|
||||
평균값 상관계수 R = {report.correlationOfMeans === null ? '-' : report.correlationOfMeans.toFixed(3)} ·
|
||||
전체 상관계수 R = {report.correlationAll === null ? '-' : report.correlationAll.toFixed(3)} · 단위 {unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={showOnMap} onChange={(e) => setShowOnMap(e.target.checked)} />
|
||||
지도에 값 보기 (위치별 평균 실측·계산·오차, 오차 10% 초과는 빨간색)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setShowReport((v) => !v)} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
|
||||
<FileText size={12} /><span>{showReport ? '리포트 닫기' : '리포트 보기'}</span>
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={downloadReport} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
|
||||
<Download size={12} /><span>리포트 저장 (.txt)</span>
|
||||
</button>
|
||||
</div>
|
||||
{showReport && (
|
||||
<pre style={{ margin: 0, padding: 8, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', fontSize: '10.5px', overflowX: 'auto', whiteSpace: 'pre' }}>
|
||||
{reportText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<ScatterChart points={report.points} unit={unit} highlight={activeLoc} />
|
||||
{series && activeLoc && (
|
||||
<SeriesChart location={activeLoc} unit={unit} ts={series.ts} sim={series.sim} obs={series.obs} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<svg width={W} height={H} role="img" aria-label="실측값과 계산값 상관도" style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
|
||||
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">실측 vs 계산 ({unit})</text>
|
||||
{ticks.map((t) => (
|
||||
<g key={t} fontSize={9} fill="#64748b">
|
||||
<line x1={pl} x2={W - pr} y1={sy(t)} y2={sy(t)} stroke="#e2e8f0" />
|
||||
<text x={pl - 4} y={sy(t) + 3} textAnchor="end">{fmt(t)}</text>
|
||||
<text x={sx(t)} y={H - pb + 12} textAnchor="middle">{fmt(t)}</text>
|
||||
</g>
|
||||
))}
|
||||
<line x1={sx(lo)} y1={sy(lo)} x2={sx(hi)} y2={sy(hi)} stroke="#94a3b8" strokeDasharray="4 3" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={sx(p.observed)} cy={sy(p.computed)} r={p.location === highlight ? 4 : 3}
|
||||
fill={p.location === highlight ? '#dc2626' : '#2563eb'} fillOpacity={0.8} />
|
||||
))}
|
||||
<text x={(W + pl) / 2} y={H - 4} fontSize={9} textAnchor="middle" fill="#64748b">실측값</text>
|
||||
<text x={10} y={(H - pb + pt) / 2} fontSize={9} textAnchor="middle" fill="#64748b" transform={`rotate(-90 10 ${(H - pb + pt) / 2})`}>계산값</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<svg width={W} height={H} role="img" aria-label={`${location} 시간별 실측값과 계산값`} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
|
||||
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">{location} 시간별 ({unit})</text>
|
||||
<g fontSize={9} fill="#64748b">
|
||||
<line x1={W - 118} x2={W - 104} y1={9} y2={9} stroke="#2563eb" strokeWidth={2} />
|
||||
<text x={W - 100} y={12}>계산</text>
|
||||
<circle cx={W - 62} cy={9} r={3} fill="#dc2626" />
|
||||
<text x={W - 55} y={12}>실측</text>
|
||||
</g>
|
||||
{[0, 0.5, 1].map((f) => {
|
||||
const v = lo + f * (hi - lo);
|
||||
return (
|
||||
<g key={f} fontSize={9} fill="#64748b">
|
||||
<line x1={pl} x2={W - pr} y1={sy(v)} y2={sy(v)} stroke="#e2e8f0" />
|
||||
<text x={pl - 4} y={sy(v) + 3} textAnchor="end">{fmt(v)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{hourTicks.map((t) => (
|
||||
<text key={t} x={sx(t)} y={H - pb + 12} fontSize={9} textAnchor="middle" fill="#64748b">{hhmm(t)}</text>
|
||||
))}
|
||||
<path d={path} fill="none" stroke="#2563eb" strokeWidth={1.8} />
|
||||
{obs.map((o, i) => <circle key={i} cx={sx(o.timeSec)} cy={sy(o.observed)} r={3.2} fill="#dc2626" />)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user