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

278 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure analyses on a model + result for the 분석 tab: flow-direction changes,
// interpolated contours (IDW + marching squares) and the pipe statistics report.
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
// ---------------------------------------------------------------- flow direction
export interface FlowDirectionRow {
id: string;
/** Number of sign changes of the flow over the run (near-zero flows ignored). */
reversals: number;
/** % of non-zero periods flowing against the dominant direction. */
reversedShare: number;
/** +1 node1→node2, -1 the other way, 0 no flow at all. */
dominant: 1 | -1 | 0;
maxAbsFlow: number;
}
export function analyzeFlowDirection(result: SimulationResult, relEps = 0.001): FlowDirectionRow[] {
const rows: FlowDirectionRow[] = [];
for (const [id, link] of Object.entries(result.links)) {
const series = link.timeSeries?.flow?.length ? link.timeSeries.flow : [link.flow];
const maxAbs = series.reduce((a, q) => Math.max(a, Math.abs(q)), 0);
const eps = Math.max(1e-9, relEps * maxAbs);
let pos = 0;
let neg = 0;
let last = 0;
let reversals = 0;
for (const q of series) {
const s = q > eps ? 1 : q < -eps ? -1 : 0;
if (s === 0) continue;
if (s > 0) pos++;
else neg++;
if (last !== 0 && s !== last) reversals++;
last = s;
}
const dominant: 1 | -1 | 0 = pos === 0 && neg === 0 ? 0 : pos >= neg ? 1 : -1;
const nonzero = pos + neg;
rows.push({ id, reversals, reversedShare: nonzero ? (100 * Math.min(pos, neg)) / nonzero : 0, dominant, maxAbsFlow: maxAbs });
}
return rows.sort((a, b) => b.reversals - a.reversals || b.reversedShare - a.reversedShare);
}
// ---------------------------------------------------------------- contours
export interface ScatterPoint {
x: number;
y: number;
v: number;
}
export interface Grid {
x0: number;
y0: number;
dx: number;
dy: number;
nx: number;
ny: number;
/** Row-major [j * nx + i]; NaN = outside the network (masked). */
values: Float64Array;
}
/**
* Inverse-distance-weighted grid over the points' bounding box. Cells farther
* than `maskFactor` × the typical point spacing from every point are masked,
* so contours stay inside the network instead of filling its convex hull.
*/
export function idwGrid(points: ScatterPoint[], maxCells = 120, power = 2, maskFactor = 2.5): Grid {
const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.v));
if (pts.length < 3) throw new Error('값이 있는 절점이 3개 이상 필요합니다.');
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
const w = maxX - minX || 1;
const h = maxY - minY || 1;
const cell = Math.max(w, h) / (maxCells - 1);
const nx = Math.max(2, Math.round(w / cell) + 1);
const ny = Math.max(2, Math.round(h / cell) + 1);
// Typical spacing = median nearest-neighbour distance (a sample on big models).
const step = Math.max(1, Math.floor(pts.length / 1500));
const nn: number[] = [];
for (let a = 0; a < pts.length; a += step) {
let best = Infinity;
for (let b = 0; b < pts.length; b++) {
if (b === a) continue;
const d2 = (pts[a].x - pts[b].x) ** 2 + (pts[a].y - pts[b].y) ** 2;
if (d2 > 0 && d2 < best) best = d2;
}
if (Number.isFinite(best)) nn.push(Math.sqrt(best));
}
nn.sort((a, b) => a - b);
const spacing = nn.length ? nn[Math.floor(nn.length / 2)] : Math.max(w, h);
const mask2 = (maskFactor * Math.max(spacing, cell)) ** 2;
const values = new Float64Array(nx * ny);
for (let j = 0; j < ny; j++) {
const y = minY + j * cell;
for (let i = 0; i < nx; i++) {
const x = minX + i * cell;
let num = 0, den = 0, near = Infinity, exact = NaN;
for (const p of pts) {
const d2 = (p.x - x) ** 2 + (p.y - y) ** 2;
if (d2 < near) near = d2;
if (d2 < 1e-18) { exact = p.v; break; }
const wgt = 1 / Math.pow(d2, power / 2);
num += wgt * p.v;
den += wgt;
}
values[j * nx + i] = !Number.isNaN(exact) ? exact : near > mask2 ? NaN : num / den;
}
}
return { x0: minX, y0: minY, dx: cell, dy: cell, nx, ny, values };
}
/** Round-number contour levels covering [min, max] at `interval`. */
export function contourLevels(min: number, max: number, interval: number): number[] {
if (!(interval > 0) || !Number.isFinite(min) || !Number.isFinite(max)) return [];
const out: number[] = [];
for (let v = Math.ceil(min / interval) * interval; v <= max + 1e-9 && out.length < 500; v += interval) out.push(Math.round(v * 1e6) / 1e6);
return out;
}
/** A sensible interval giving ~8-12 levels. */
export function niceInterval(min: number, max: number): number {
const span = max - min;
if (!(span > 0)) return 1;
const raw = span / 10;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
return (n < 1.5 ? 1 : n < 3.5 ? 2 : n < 7.5 ? 5 : 10) * mag;
}
type Seg = [[number, number], [number, number]];
/** Marching squares: line segments of the `level` isoline (masked cells skipped). */
export function isolineSegments(g: Grid, level: number): Seg[] {
const segs: Seg[] = [];
const at = (i: number, j: number) => g.values[j * g.nx + i];
const X = (i: number) => g.x0 + i * g.dx;
const Y = (j: number) => g.y0 + j * g.dy;
const lerp = (a: number, b: number) => (level - a) / (b - a);
for (let j = 0; j < g.ny - 1; j++) {
for (let i = 0; i < g.nx - 1; i++) {
const v0 = at(i, j), v1 = at(i + 1, j), v2 = at(i + 1, j + 1), v3 = at(i, j + 1);
if ([v0, v1, v2, v3].some(Number.isNaN)) continue;
const code = (v0 >= level ? 1 : 0) | (v1 >= level ? 2 : 0) | (v2 >= level ? 4 : 0) | (v3 >= level ? 8 : 0);
if (code === 0 || code === 15) continue;
// Edge crossing points: bottom (0-1), right (1-2), top (3-2), left (0-3).
const b: [number, number] = [X(i) + lerp(v0, v1) * g.dx, Y(j)];
const r: [number, number] = [X(i + 1), Y(j) + lerp(v1, v2) * g.dy];
const t: [number, number] = [X(i) + lerp(v3, v2) * g.dx, Y(j + 1)];
const l: [number, number] = [X(i), Y(j) + lerp(v0, v3) * g.dy];
const center = (v0 + v1 + v2 + v3) / 4 >= level;
switch (code) {
case 1: case 14: segs.push([l, b]); break;
case 2: case 13: segs.push([b, r]); break;
case 3: case 12: segs.push([l, r]); break;
case 4: case 11: segs.push([r, t]); break;
case 6: case 9: segs.push([b, t]); break;
case 7: case 8: segs.push([l, t]); break;
case 5: if (center) { segs.push([l, t], [b, r]); } else { segs.push([l, b], [r, t]); } break;
case 10: if (center) { segs.push([l, b], [r, t]); } else { segs.push([l, t], [b, r]); } break;
}
}
}
return segs;
}
/** Join touching segments into polylines (fewer, smoother features). */
export function joinSegments(segs: Seg[]): Array<Array<[number, number]>> {
const key = (p: [number, number]) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`;
const ends = new Map<string, number[]>();
segs.forEach((s, k) => {
for (const p of s) {
const id = key(p);
const list = ends.get(id);
if (list) list.push(k);
else ends.set(id, [k]);
}
});
const used = new Uint8Array(segs.length);
const lines: Array<Array<[number, number]>> = [];
const extend = (line: Array<[number, number]>, atEnd: boolean) => {
for (;;) {
const tip = atEnd ? line[line.length - 1] : line[0];
const next = (ends.get(key(tip)) ?? []).find((k) => !used[k]);
if (next === undefined) return;
used[next] = 1;
const [a, b] = segs[next];
const other = key(a) === key(tip) ? b : a;
if (atEnd) line.push(other);
else line.unshift(other);
}
};
segs.forEach((s, k) => {
if (used[k]) return;
used[k] = 1;
const line: Array<[number, number]> = [s[0], s[1]];
extend(line, true);
extend(line, false);
lines.push(line);
});
return lines;
}
// ---------------------------------------------------------------- pipe report
export type PipeGroupKey = 'diameter' | 'material' | 'installDecade' | 'block' | 'pipeKind' | 'roughness';
export interface PipeGroupRow {
key: string;
count: number;
length: number;
share: number;
/** Highest |velocity| of the group over the run (result needed). */
maxVelocity?: number;
/** Mean age in years from installYear (assets needed). */
meanAge?: number;
}
/** Group label of a pipe for the report / calibration groups. */
export function pipeGroupKey(model: NetworkModel, pipeId: string, by: PipeGroupKey): string {
const p = model.pipes[pipeId];
const a = model.assets?.links[pipeId] ?? {};
const yr = Number(a.installYear);
switch (by) {
case 'diameter': return String(p.diameter);
case 'roughness': return String(p.roughness);
case 'installDecade': return Number.isFinite(yr) && yr > 1800 ? `${Math.floor(yr / 10) * 10}년대` : '(미입력)';
default: return a[by] !== undefined && String(a[by]).trim() !== '' ? String(a[by]) : '(미입력)';
}
}
/** Pipe IDs per group ('all' = one group). */
export function pipeGroups(model: NetworkModel, by: PipeGroupKey | 'all'): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const id of Object.keys(model.pipes)) (out[by === 'all' ? '전체' : pipeGroupKey(model, id, by)] ??= []).push(id);
return out;
}
export function pipeReport(model: NetworkModel, by: PipeGroupKey, result?: SimulationResult | null, year = new Date().getFullYear()): { rows: PipeGroupRow[]; totalLength: number; count: number } {
const groups = new Map<string, { count: number; length: number; vmax: number; hasV: boolean; ageSum: number; ageN: number }>();
let total = 0;
const pipes = Object.values(model.pipes);
for (const p of pipes) {
const a = model.assets?.links[p.id] ?? {};
const yr = Number(a.installYear);
const key = pipeGroupKey(model, p.id, by);
const g = groups.get(key) ?? { count: 0, length: 0, vmax: 0, hasV: false, ageSum: 0, ageN: 0 };
g.count++;
g.length += p.length;
total += p.length;
const lr = result?.links[p.id];
if (lr) {
const vs = lr.timeSeries?.velocity?.length ? lr.timeSeries.velocity : [lr.velocity];
for (const v of vs) g.vmax = Math.max(g.vmax, Math.abs(v));
g.hasV = true;
}
if (Number.isFinite(yr) && yr > 1800 && yr <= year) {
g.ageSum += year - yr;
g.ageN++;
}
groups.set(key, g);
}
const rows = [...groups.entries()].map(([key, g]) => ({
key,
count: g.count,
length: g.length,
share: total > 0 ? (100 * g.length) / total : 0,
maxVelocity: g.hasV ? g.vmax : undefined,
meanAge: g.ageN ? g.ageSum / g.ageN : undefined,
}));
const numeric = by === 'diameter' || by === 'roughness';
rows.sort((a, b) => (numeric ? Number(a.key) - Number(b.key) : a.key === '(미입력)' ? 1 : b.key === '(미입력)' ? -1 : a.key.localeCompare(b.key, 'ko')));
return { rows, totalLength: total, count: pipes.length };
}