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,212 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in new issue
Block a user