- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
// 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 };
|
||
}
|