SPipe 데모 v2 (2026-10-30) 서버 배포본

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

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
// 소블록 고립확인: 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': '경계 모두 폐쇄 (공급 확인)',
};