- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
// 소블록 고립확인: 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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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<string, string> {
|
|
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<string, string> = {};
|
|
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<string, string>, result?: SimulationResult | null): BlockReport[] {
|
|
const reports = new Map<string, BlockReport>();
|
|
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<BlockStatus, string> = {
|
|
'single-inlet': '단일 유입 (정상)',
|
|
'multi-open': '다중 연결 (고립 미흡)',
|
|
'closed-off': '경계 모두 폐쇄 (공급 확인)',
|
|
};
|