// 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(rec: Record, id: string, value: T | null | undefined): Record { 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(); 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 } { if (!(refPressure > 0)) throw new Error('기준 압력은 0보다 커야 합니다.'); const ids = new Set(nodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION')); const served: Record = {}; 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 = {}; 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 }; }