- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
213 lines
9.7 KiB
TypeScript
213 lines
9.7 KiB
TypeScript
// Immutable edits of NetworkModel.extras (structured INP sections) for the
|
|
// property editors and the demand table. Every function returns a new model.
|
|
import type { NetworkModel } from './inpModel';
|
|
import {
|
|
emptyExtras,
|
|
type DemandCategory,
|
|
type KeyValue,
|
|
type MapLabel,
|
|
type MixingDef,
|
|
type ModelExtras,
|
|
type PumpEnergy,
|
|
type SourceDef,
|
|
} from './inpExtras.ts';
|
|
|
|
function withExtras(model: NetworkModel, fn: (ex: ModelExtras) => ModelExtras): NetworkModel {
|
|
return { ...model, extras: fn(model.extras ?? emptyExtras()) };
|
|
}
|
|
|
|
function setKey<T>(rec: Record<string, T>, id: string, value: T | null | undefined): Record<string, T> {
|
|
const out = { ...rec };
|
|
if (value === null || value === undefined) delete out[id];
|
|
else out[id] = value;
|
|
return out;
|
|
}
|
|
|
|
/** Demand categories of a junction: [DEMANDS] rows, or its single base demand. */
|
|
export function getDemandCategories(model: NetworkModel, nodeId: string): DemandCategory[] {
|
|
const cats = model.extras?.demands[nodeId];
|
|
if (cats?.length) return cats;
|
|
const n = model.nodes[nodeId];
|
|
return n ? [{ demand: n.demand ?? 0, pattern: n.pattern || undefined }] : [];
|
|
}
|
|
|
|
/** Total base demand (sum of categories). */
|
|
export function totalBaseDemand(model: NetworkModel, nodeId: string): number {
|
|
return getDemandCategories(model, nodeId).reduce((a, c) => a + c.demand, 0);
|
|
}
|
|
|
|
/**
|
|
* Replace a junction's demand categories. One unnamed category is written back
|
|
* as the plain [JUNCTIONS] demand (no [DEMANDS] rows); otherwise [DEMANDS]
|
|
* holds them all and the junction line mirrors the first (primary) one.
|
|
*/
|
|
export function setDemandCategories(model: NetworkModel, nodeId: string, cats: DemandCategory[]): NetworkModel {
|
|
const node = model.nodes[nodeId];
|
|
if (!node || node.type !== 'JUNCTION') throw new Error(`접합점 ${nodeId}가 없습니다.`);
|
|
const clean = cats.filter((c) => Number.isFinite(c.demand)).map((c) => ({
|
|
demand: c.demand,
|
|
pattern: c.pattern?.trim() || undefined,
|
|
category: c.category?.trim() || undefined,
|
|
}));
|
|
const simple = clean.length <= 1 && !clean[0]?.category;
|
|
const first = clean[0] ?? { demand: 0 };
|
|
const next = {
|
|
...model,
|
|
nodes: { ...model.nodes, [nodeId]: { ...node, demand: first.demand, pattern: first.pattern ?? '' } },
|
|
};
|
|
return withExtras(next, (ex) => ({ ...ex, demands: setKey(ex.demands, nodeId, simple ? null : clean) }));
|
|
}
|
|
|
|
export const setEmitter = (m: NetworkModel, id: string, c: number | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, emitters: setKey(ex.emitters, id, c !== null && c > 0 ? c : null) }));
|
|
|
|
export const setInitialQuality = (m: NetworkModel, id: string, q: number | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, quality: setKey(ex.quality, id, q !== null && q !== 0 ? q : null) }));
|
|
|
|
export const setSource = (m: NetworkModel, id: string, s: SourceDef | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, sources: setKey(ex.sources, id, s) }));
|
|
|
|
export const setMixing = (m: NetworkModel, id: string, mix: MixingDef | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, mixing: setKey(ex.mixing, id, mix && mix.model !== 'MIXED' ? mix : null) }));
|
|
|
|
export const setTankReaction = (m: NetworkModel, id: string, k: number | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, tankReactions: setKey(ex.tankReactions, id, k) }));
|
|
|
|
export const setStatusOverride = (m: NetworkModel, id: string, v: string | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, statusOverrides: setKey(ex.statusOverrides, id, v?.trim() ? v.trim().toUpperCase() : null) }));
|
|
|
|
export function setPumpEnergy(m: NetworkModel, id: string, pe: PumpEnergy | null): NetworkModel {
|
|
const empty = !pe || (!pe.efficiency && pe.price === undefined && !pe.pattern);
|
|
return withExtras(m, (ex) => ({ ...ex, energy: { ...ex.energy, pumps: setKey(ex.energy.pumps, id, empty ? null : pe) } }));
|
|
}
|
|
|
|
/** Set/remove one `KEY value` entry, matching the key case-insensitively. */
|
|
export function upsertKeyValue(list: KeyValue[], key: string, value: string | null): KeyValue[] {
|
|
const i = list.findIndex((kv) => kv.key.toUpperCase() === key.toUpperCase());
|
|
if (value === null || value.trim() === '') return i < 0 ? list : list.filter((_, k) => k !== i);
|
|
if (i < 0) return [...list, { key, value }];
|
|
return list.map((kv, k) => (k === i ? { key: kv.key, value } : kv));
|
|
}
|
|
|
|
export function getKeyValue(list: KeyValue[] | undefined, key: string): string | undefined {
|
|
return list?.find((kv) => kv.key.toUpperCase() === key.toUpperCase())?.value;
|
|
}
|
|
|
|
export const setEnergyGlobal = (m: NetworkModel, key: string, value: string | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, energy: { ...ex.energy, global: upsertKeyValue(ex.energy.global, key, value) } }));
|
|
|
|
export const setReactionGlobal = (m: NetworkModel, key: string, value: string | null) =>
|
|
withExtras(m, (ex) => ({ ...ex, reactionsGlobal: upsertKeyValue(ex.reactionsGlobal, key, value) }));
|
|
|
|
export const setLabels = (m: NetworkModel, labels: MapLabel[]) => withExtras(m, (ex) => ({ ...ex, labels }));
|
|
|
|
// ---------------------------------------------------------------- bulk demand tools
|
|
|
|
/**
|
|
* Parse rows pasted from Excel (tab separated) or a CSV: node ID, demand,
|
|
* [pattern], [category]. A header row is skipped. Returns rows + bad line numbers.
|
|
*/
|
|
export function parseDemandRows(text: string): { rows: Array<{ id: string; demand: number; pattern?: string; category?: string }>; bad: number[] } {
|
|
const rows: Array<{ id: string; demand: number; pattern?: string; category?: string }> = [];
|
|
const bad: number[] = [];
|
|
text.split(/\r?\n/).forEach((line, i) => {
|
|
if (!line.trim()) return;
|
|
const cells = (line.includes('\t') ? line.split('\t') : line.split(',')).map((c) => c.trim().replace(/^"|"$/g, ''));
|
|
const [id, d, pattern, category] = cells;
|
|
const demand = Number(d);
|
|
if (!id || !Number.isFinite(demand)) {
|
|
if (i > 0 || Number.isFinite(Number(cells[1]))) bad.push(i + 1);
|
|
return; // first non-numeric row = header
|
|
}
|
|
rows.push({ id, demand, pattern: pattern || undefined, category: category || undefined });
|
|
});
|
|
return { rows, bad };
|
|
}
|
|
|
|
/**
|
|
* Apply parsed rows. mode 'replace': each listed node's categories become
|
|
* exactly its rows; 'add': rows are appended as extra categories.
|
|
*/
|
|
export function applyDemandRows(
|
|
model: NetworkModel,
|
|
rows: Array<{ id: string; demand: number; pattern?: string; category?: string }>,
|
|
mode: 'replace' | 'add',
|
|
toModelFlow: (v: number) => number
|
|
): { model: NetworkModel; updated: string[]; unknown: string[] } {
|
|
const byNode = new Map<string, DemandCategory[]>();
|
|
const unknown: string[] = [];
|
|
for (const r of rows) {
|
|
if (model.nodes[r.id]?.type !== 'JUNCTION') {
|
|
unknown.push(r.id);
|
|
continue;
|
|
}
|
|
const list = byNode.get(r.id) ?? [];
|
|
list.push({ demand: toModelFlow(r.demand), pattern: r.pattern, category: r.category });
|
|
byNode.set(r.id, list);
|
|
}
|
|
let m = model;
|
|
for (const [id, cats] of byNode) {
|
|
const next = mode === 'add' ? [...getDemandCategories(m, id).filter((c) => c.demand !== 0 || c.category), ...cats] : cats;
|
|
m = setDemandCategories(m, id, next);
|
|
}
|
|
return { model: m, updated: [...byNode.keys()], unknown };
|
|
}
|
|
|
|
/**
|
|
* 수요 배분: spread `total` (model flow units) over junctions as a new
|
|
* category, proportionally to their current demand ('demand') or equally.
|
|
*/
|
|
export function distributeDemand(
|
|
model: NetworkModel,
|
|
nodeIds: string[],
|
|
total: number,
|
|
by: 'demand' | 'equal',
|
|
category: string,
|
|
pattern?: string
|
|
): NetworkModel {
|
|
const ids = nodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION');
|
|
if (!ids.length) throw new Error('배분할 접합점이 없습니다.');
|
|
const weights = ids.map((id) => (by === 'equal' ? 1 : Math.max(0, totalBaseDemand(model, id))));
|
|
const sum = weights.reduce((a, b) => a + b, 0);
|
|
if (by === 'demand' && sum <= 0) throw new Error('기존 수요가 모두 0이라 수요 비례 배분을 할 수 없습니다. 균등 배분을 쓰세요.');
|
|
let m = model;
|
|
ids.forEach((id, k) => {
|
|
const share = (total * weights[k]) / (by === 'equal' ? ids.length : sum);
|
|
const cats = getDemandCategories(m, id).filter((c) => c.demand !== 0 || c.category);
|
|
m = setDemandCategories(m, id, [...(cats.length ? cats : [{ demand: 0 }]), { demand: Math.round(share * 1e6) / 1e6, pattern, category }]);
|
|
});
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* 이미터(누수) 배분: emitter coefficients whose total outflow at `refPressure`
|
|
* equals `totalLeak`, split by the pipe length each junction serves (half of
|
|
* every attached pipe) -- leakage is commonly taken as proportional to length.
|
|
* C_i = Q_i / P_ref^exponent.
|
|
*/
|
|
export function distributeEmitters(
|
|
model: NetworkModel,
|
|
nodeIds: string[],
|
|
totalLeak: number,
|
|
refPressure: number,
|
|
exponent = model.options.emitterExponent ?? 0.5
|
|
): { model: NetworkModel; coefficients: Record<string, number> } {
|
|
if (!(refPressure > 0)) throw new Error('기준 압력은 0보다 커야 합니다.');
|
|
const ids = new Set(nodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION'));
|
|
const served: Record<string, number> = {};
|
|
for (const p of Object.values(model.pipes)) {
|
|
for (const n of [p.node1, p.node2]) if (ids.has(n)) served[n] = (served[n] ?? 0) + p.length / 2;
|
|
}
|
|
const total = Object.values(served).reduce((a, b) => a + b, 0);
|
|
if (total <= 0) throw new Error('선택한 절점에 연결된 관로 연장이 없습니다.');
|
|
const coefficients: Record<string, number> = {};
|
|
let m = model;
|
|
for (const [id, len] of Object.entries(served)) {
|
|
const q = (totalLeak * len) / total;
|
|
const c = Math.round((q / Math.pow(refPressure, exponent)) * 1e6) / 1e6;
|
|
coefficients[id] = c;
|
|
m = setEmitter(m, id, c);
|
|
}
|
|
return { model: m, coefficients };
|
|
}
|