SPipe 데모 v2 (2026-10-30) 서버 배포본

- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
bjkim1 committed 2026-10-30 11:00:46 +09:00
commit 21ebb16fb7
181 files changed
+57207

No files matched your search

+295
View File
@@ -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>
);
};