// 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> { const key = (p: [number, number]) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`; const ends = new Map(); 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> = []; 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 { const out: Record = {}; 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(); 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 }; }