// 소블록 고립확인: for every small block, the links that cross its boundary. // A well-formed 블록 has a single open inflow point (usually metered); every // other boundary connection should be closed (EPANET models gate valves as a // pipe's CLOSED status, so open boundary pipes are counted, not judged). import type { NetworkModel } from './inpModel'; import type { SimulationResult } from './types'; import type { FeatureCollection, Geometry, Position } from 'geojson'; export interface BoundaryLink { id: string; kind: 'pipe' | 'valve' | 'pump'; /** The neighbouring block ('' = node without a block). */ otherBlock: string; closed: boolean; /** Largest |flow| over the result (model units), if a result is given. */ maxFlow?: number; /** Net direction relative to the block over the result. */ direction?: 'in' | 'out' | 'both' | 'none'; } export type BlockStatus = 'single-inlet' | 'multi-open' | 'closed-off'; export interface BlockReport { block: string; nodeCount: number; hasSource: boolean; boundary: BoundaryLink[]; openCount: number; openPipes: number; activeFlows: number; status: BlockStatus; } const FLOW_EPS = 1e-6; export function blocksFromAssets(model: NetworkModel, key = 'block'): Record { const out: Record = {}; for (const [id, rec] of Object.entries(model.assets?.nodes ?? {})) { const v = rec[key]; if (v !== undefined && String(v).trim()) out[id] = String(v).trim(); } return out; } function ringContains(ring: Position[], x: number, y: number): boolean { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i]; const [xj, yj] = ring[j]; if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1e-300) + xi) inside = !inside; } return inside; } function polygonContains(rings: Position[][], x: number, y: number): boolean { if (!rings.length || !ringContains(rings[0], x, y)) return false; for (let h = 1; h < rings.length; h++) if (ringContains(rings[h], x, y)) return false; // hole return true; } function geometryContains(g: Geometry | null, x: number, y: number): boolean { if (!g) return false; if (g.type === 'Polygon') return polygonContains(g.coordinates, x, y); if (g.type === 'MultiPolygon') return g.coordinates.some((p) => polygonContains(p, x, y)); if (g.type === 'GeometryCollection') return g.geometries.some((s) => geometryContains(s, x, y)); return false; } /** * Block of each node from a polygon layer (WGS84 GeoJSON). `toLngLat` converts * model coordinates; `nameField` picks the block name (feature index if empty). */ export function blocksFromPolygons( model: NetworkModel, fc: FeatureCollection, toLngLat: (x: number, y: number) => [number, number], nameField?: string ): Record { const polys = fc.features .map((f, i) => ({ f, name: String((nameField && f.properties?.[nameField]) ?? f.properties?.name ?? `블록${i + 1}`) })) .filter(({ f }) => f.geometry && (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon')); const out: Record = {}; for (const n of Object.values(model.nodes)) { const [lon, lat] = toLngLat(n.x, n.y); const hit = polys.find(({ f }) => geometryContains(f.geometry, lon, lat)); if (hit) out[n.id] = hit.name; } return out; } function linkClosed(model: NetworkModel, id: string, own?: string): boolean { return (model.extras?.statusOverrides[id] ?? own ?? '').toUpperCase() === 'CLOSED'; } export function checkBlockIsolation(model: NetworkModel, blockOf: Record, result?: SimulationResult | null): BlockReport[] { const reports = new Map(); const get = (b: string) => { let r = reports.get(b); if (!r) { r = { block: b, nodeCount: 0, hasSource: false, boundary: [], openCount: 0, openPipes: 0, activeFlows: 0, status: 'closed-off' }; reports.set(b, r); } return r; }; for (const [id, b] of Object.entries(blockOf)) { const node = model.nodes[id]; if (!node) continue; const r = get(b); r.nodeCount++; if (node.type !== 'JUNCTION') r.hasSource = true; } const links: Array<{ id: string; kind: BoundaryLink['kind']; a: string; b: string; own?: string }> = [ ...Object.values(model.pipes).map((p) => ({ id: p.id, kind: 'pipe' as const, a: p.node1, b: p.node2, own: p.status })), ...Object.values(model.valves || {}).map((v) => ({ id: v.id, kind: 'valve' as const, a: v.node1, b: v.node2, own: v.status })), ...Object.values(model.pumps).map((p) => ({ id: p.id, kind: 'pump' as const, a: p.node1, b: p.node2 })), ]; for (const l of links) { const ba = blockOf[l.a] ?? ''; const bb = blockOf[l.b] ?? ''; if (ba === bb) continue; const series = result?.links[l.id]?.timeSeries?.flow ?? (result?.links[l.id] ? [result.links[l.id].flow] : undefined); const closed = linkClosed(model, l.id, l.own); // Flow is positive node1 -> node2; seen from block X it is inflow when X owns node2. for (const [mine, other, sign] of [[ba, bb, -1], [bb, ba, 1]] as const) { if (!mine) continue; const entry: BoundaryLink = { id: l.id, kind: l.kind, otherBlock: other, closed }; if (series) { const signed = series.map((q) => q * sign); entry.maxFlow = Math.max(...series.map((q) => Math.abs(q))); const anyIn = signed.some((q) => q > FLOW_EPS); const anyOut = signed.some((q) => q < -FLOW_EPS); entry.direction = anyIn && anyOut ? 'both' : anyIn ? 'in' : anyOut ? 'out' : 'none'; } get(mine).boundary.push(entry); } } for (const r of reports.values()) { const open = r.boundary.filter((x) => !x.closed); r.openCount = open.length; r.openPipes = open.filter((x) => x.kind === 'pipe').length; r.activeFlows = open.filter((x) => x.direction && x.direction !== 'none').length; r.status = r.openCount === 0 ? 'closed-off' : r.openCount === 1 ? 'single-inlet' : 'multi-open'; } return [...reports.values()].sort((a, b) => a.block.localeCompare(b.block, 'ko', { numeric: true })); } export const BLOCK_STATUS_LABEL: Record = { 'single-inlet': '단일 유입 (정상)', 'multi-open': '다중 연결 (고립 미흡)', 'closed-off': '경계 모두 폐쇄 (공급 확인)', };