/* * 전력 계통도 — **선언된 것만 그린다.** 순수. * * ── 왜 필요한가 (2026-08-18) ──────────────────────────────────────────────── * 분기별 막대는 「어디가 많이 쓰나」에 답하지만 **어디에 걸려 있나**에 답하지 못한다. 실제로 부딪힌 * 세 가지가 전부 계통 질문이었다: * · 「하위 계량이 덮지 않는 327 kWh」 — 그 몫이 수전 직결 부하인지, 계량 없는 구간인지 표로는 모른다. * · 커넥터는 결선을 모르는 부하를 **수전에 매단다**(정직한 선택). 그런데 그 사실이 화면에 없었다. * · 「계량기를 어디에 더 달아야 하나」 — 어두운 구간이 보여야 답할 수 있다. * * ── 무엇을 그리지 않나 ───────────────────────────────────────────────────── * **보호 계통 단선도(SLD)가 아니다.** 차단기 정격·보호 협조·상(phase)·결선 방식은 모델에 없고, 그럴듯한 * 단선도를 그리면 갖지 않은 공학적 충실도를 주장하게 된다. 여기서 그리는 것은 **트윈이 선언한 관계** * (자리의 계층 + 설비의 소속)와 **계량이 실제로 덮는 범위**뿐이다. 한계는 소비처가 화면에 적는다. * * ── 알리지 않고 빠뜨리지 않는다 ───────────────────────────────────────────────── * 자리를 모르는 설비, 계량기가 없는 자리, 계량이 덮지 않는 몫은 **각자 자기 칸으로** 나온다. 그림에서 * 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다. */ import { EMS_LOCATION_TYPES, electricalUpstreamOf } from '@operato/ops-contract' /** 계통의 한 구간(수전·분기·구역) — 자리 하나가 곧 구간이다. */ export interface TopologySegment { id: string /** 카탈로그 자리 타입(`incoming`·`feeder`·`submeter-zone`). */ type: string label?: string /** 이 자리에 달린 계량 지점 id 들 — 비어 있으면 **어두운 구간**이다(값을 알 길이 없다). */ meters: string[] /** 이 구간의 전력량과 근거 — 폴드가 낸 것만 싣는다(우리가 다시 계산하지 않는다). */ kWh?: number basis?: 'measured' | 'apportioned' /** * **피크가 찍힌 순간** 이 구간이 얼마였나(kW) — 요금이 걸린 수다. * * 전력량이 큰 구간과 피크를 만든 구간은 다를 수 있다(하루 종일 조금씩 쓰는 부하 vs 잠깐 크게 쓰는 부하). * 그림에 둘을 함께 두면 「어느 분기를 깎아야 피크가 내려가나」가 바로 읽힌다. */ peakKW?: number /** 이 자리에 매달린 부하(설비) — 종류를 그대로 싣는다. */ loads: { id: string; kind: string }[] /** * 계통 설비(차단기·태양광·축전지) — 부하가 아니라 계통의 일부다. * * `live` 는 **지금 값**이다(구간 값이 아니다): 태양광이 지금 몇 kW 를 만들고, 축전지가 얼마를 내보내며 * 충전율이 얼마인가. 원천이 보내 주는 사실이라 우리가 만들지 않는다 — 보내지 않은 필드는 없다. * * 시각(`measuredAt`)을 함께 나른다: 계측이 끊기면 **멈춘 값을 지금 값으로 그리게** 되고, 그것이 화면이 * 낼 수 있는 가장 나쁜 거짓이다(값이 그럴듯할수록 위험하다). */ gear: { id: string kind: string live?: { generatedKW?: number; exportKW?: number; chargeKW?: number; dischargeKW?: number; soc?: number; measuredAt?: string } }[] children: TopologySegment[] } export interface EnergyTopology { roots: TopologySegment[] /** * 자리를 모르는 설비 — 집이 없거나, 없는 자리를 가리킨다. 숨기면 그림의 설비 수가 모델과 달라진다. */ unplaced: { id: string; kind: string }[] /** 계량기가 없는 자리 id 들 — 「여기에 달면 무엇을 알 수 있나」의 답이 되는 목록이다. */ unmetered: string[] /** * 결선을 모르는 부하가 매달린 자리 — 커넥터가 「어느 분기가 이 설비에 전기를 주는지 모른다」고 판단해 * 수전에 매단 경우다. 그 자리의 몫이 분기로 내려가지 않는 이유가 이것이다. */ loadsOnIncoming: number } /** * 이 설비의 **지금 값** — 스냅샷에서 그 설비를 찾아 에너지 축의 값만 옮긴다. * * 종류가 아는 필드만 옮긴다: 태양광에 충전율을 붙이거나 축전지에 발전량을 붙이면, 화면은 그것을 사실로 * 그리고 사람은 그 설비가 그런 일을 한다고 읽는다(커널이 종류별로 필드를 가려 받는 것과 같은 규율). */ function liveOf( state: readonly any[] | undefined | null, id: string, kind: string ): { live: NonNullable } | undefined { const e = (state ?? []).find(x => String(x?.id ?? '') === id) if (!e) return undefined const num = (v: unknown): number | undefined => (Number.isFinite(Number(v)) ? Number(v) : undefined) const live: NonNullable = {} if (kind === 'pv-array') { const g = num(e.generatedKW) const x = num(e.exportKW) if (g !== undefined) live.generatedKW = g if (x !== undefined) live.exportKW = x } else if (kind === 'battery') { const soc = num(e.soc) const dis = num(e.dischargeKW) const cha = num(e.chargeKW) if (soc !== undefined) live.soc = soc if (dis !== undefined) live.dischargeKW = dis if (cha !== undefined) live.chargeKW = cha } if (!Object.keys(live).length) return undefined /* 값이 있으면 시각도 함께 — 없으면 붙이지 않는다(모르는 시각을 지금으로 만들지 않는다). */ if (e.measuredAt) live.measuredAt = String(e.measuredAt) return { live } } const ELECTRICAL = new Set(EMS_LOCATION_TYPES as readonly string[]) /** 부하가 아닌 계통 설비 — 이것들의 소비를 부하로 세면 총량이 이중으로 나뉜다. */ const GEAR = new Set(['meter', 'breaker', 'pv-array', 'battery']) /** * 모델 + 폴드 결과 → 계통 트리. * * @param model 트윈 모델(자리 계층 `parentId`, 설비 소속 `homeLocation`). * @param energy 폴드가 낸 분기별 몫(있으면 노드에 싣는다 — 없으면 값 없이 구조만 그린다). */ export function buildEnergyTopology( model: { locations?: any[]; equipment?: any[] } | undefined | null, energy?: { byFeeder?: { key: string; kWh: number; basis?: 'measured' | 'apportioned'; peakKW?: number }[] } | null, /** * 지금의 설비 상태(스냅샷의 `equipment`) — 발전·저장의 **지금 값**을 기호 옆에 적기 위해서다. * * 없으면 값 없이 구조만 그린다. 여기서 값을 만들지 않는다: 원천이 보내지 않은 것은 모르는 것이다. */ equipmentState?: readonly any[] | null ): EnergyTopology { const locations: any[] = (model?.locations ?? []).filter(n => n?.id && ELECTRICAL.has(String(n.type))) const byId = new Map() for (const n of locations) { byId.set(String(n.id), { id: String(n.id), type: String(n.type), ...(n.name ? { label: String(n.name) } : {}), meters: [], loads: [], gear: [], children: [] }) } const share = new Map((energy?.byFeeder ?? []).map(f => [f.key, f])) for (const [id, segment] of byId) { const s = share.get(id) if (s) { segment.kWh = s.kWh if (s.basis) segment.basis = s.basis if (typeof s.peakKW === 'number') segment.peakKW = s.peakKW } } /* 설비를 자기 자리에 붙인다 — 계량기는 「이 구간을 잰다」는 뜻이라 따로 모은다. */ const unplaced: { id: string; kind: string }[] = [] let loadsOnIncoming = 0 for (const e of (model?.equipment ?? []) as any[]) { if (!e?.id) continue const kind = String(e.kind ?? '') const home = String(e.homeLocation ?? e.homeLocationId ?? '') const at = byId.get(home) if (!at) { /* 전기 계통 밖의 자리에 사는 설비도 여기서는 「자리를 모른다」로 모은다 — 이 그림의 축은 계통이다. */ unplaced.push({ id: String(e.id), kind }) continue } if (kind === 'meter') at.meters.push(String(e.id)) else if (GEAR.has(kind)) at.gear.push({ id: String(e.id), kind, ...(liveOf(equipmentState, String(e.id), kind) ?? {}) }) else { at.loads.push({ id: String(e.id), kind }) if (at.type === 'incoming') loadsOnIncoming++ } } /* * 계층을 잇는 것은 **전기 상류**다 — 공간 부모(공장·구역)는 이 축이 아니다. 상류의 뜻은 커널이 읽는다 * (옛 세대는 그 안에서 흡수된다) — 여기서 다시 판정하면 화면과 계산이 다른 계통을 말하게 된다. */ const roots: TopologySegment[] = [] const all = model?.locations ?? [] for (const n of locations) { const segment = byId.get(String(n.id))! const up = electricalUpstreamOf(all as any, String(n.id)) const parent = up ? byId.get(up) : undefined if (parent && parent !== segment) parent.children.push(segment) else roots.push(segment) } const unmetered = [...byId.values()].filter(n => !n.meters.length).map(n => n.id) return { roots, unplaced, unmetered, loadsOnIncoming } }