SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -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': '경계 모두 폐쇄 (공급 확인)',
|
||||
};
|
||||
Reference in new issue
Block a user