/* * 자원 속성의 **효과 등록부** — 이 속성이 무엇을 바꾸나. * * ── 왜 있나 ───────────────────────────────────────────────────────────────── * 속성은 **열려 있다**: 템플릿·마스터가 아무 id 로나 사실을 실어 보낼 수 있고, 커널은 그것을 그대로 * 나르고 화면은 단위와 함께 보인다(표준이 속성 어휘를 정하지 않으므로 그것이 옳다). * * 그런데 **효과는 닫혀 있다**: 값이 계산·배정·판정을 바꾸려면 그 규칙을 코드가 알아야 한다. 지금 * 효과가 있는 것은 둘뿐이다. 문제는 그 사실이 **어디에도 드러나지 않는다**는 것이었다 — 사용자는 * 속성을 넣으면 무언가 달라질 것으로 기대하는데, 실제로는 표에 한 줄이 늘 뿐이다. * * 그래서 이 등록부는 **지어내지 않고 드러낸다**: 읽는 코드가 있는 것만 적고, 없는 것은 "표시만 된다" * 고 화면이 말한다. 새 효과를 코드로 붙이는 사람은 **여기 한 줄을 더한다** — 그러지 않으면 사용자는 * 그 효과가 있는지 알 수 없다. * * ⚠️ 이 파일은 등록부이고 판정이 아니다. 값을 실제로 읽는 곳은 각자다(아래 `readBy` 가 그 자리를 * 가리킨다) — 여기에 계산을 옮겨 오면 규칙이 두 벌이 된다. */ /** 효과가 있는 속성 하나. */ export interface PropertyEffect { /** 속성 id — 선언에 적히는 그 이름(`ResourceProperty.id`). */ id: string /** 어느 축의 속성인가 — 그 축의 항목에만 이 효과를 말한다. */ axes: string[] /** 무엇을 바꾸나 — 화면이 읽는 i18n 키(언어중립: 서버는 사람 말을 만들지 않는다). */ effect: string /** 실제로 읽는 코드 — 지어낸 효과를 등록하지 않기 위한 표식이다(없는 자리를 가리키면 리뷰가 잡는다). */ readBy: string } export const PROPERTY_EFFECTS: PropertyEffect[] = [ { id: 'speed', axes: ['equipment'], effect: 'twin.propeffect.speed', readBy: 'headless-twin/server/engine/travel-estimator.ts (buildTravelEstimator)' }, { id: 'perBaseUnit', axes: ['materialDefinitions'], effect: 'twin.propeffect.perBaseUnit', readBy: '@operato/twin-kernel conversionFactorOf' }, /* * ── 에너지 속성 (2026-08-18) ────────────────────────────────────────────── * * 이 값들은 **오래전부터 읽히고 있었다** — 커널이 부하·피크·배터리를 그것으로 계산하고, 호스트가 * 금액을 그것으로 만든다. 그런데 등록부에 없어서 화면은 그 값들에 대해 「표시만 됩니다」라고 말했다. * 들어온 것이 없는 거짓말이다: 사용자는 계약전력을 넣고도 「이 값은 아무것도 바꾸지 않는다」는 문장을 읽었다. * * 효과를 새로 만든 것이 아니라 **있는 효과를 드러낸다.** `readBy` 는 실제로 읽는 자리를 가리킨다. */ { id: 'contract.kW', axes: ['locations'], effect: 'twin.propeffect.contractKW', readBy: '@operato/twin-kernel EmsKernel.declaredContractKW (계약 대비 판정·초과 주의)' }, { id: 'power.ratedKW', axes: ['equipment'], effect: 'twin.propeffect.ratedKW', readBy: '@operato/twin-kernel EmsKernel.deriveEquipmentLoad (가동 중 부하)' }, { id: 'power.standbyKW', axes: ['equipment'], effect: 'twin.propeffect.standbyKW', readBy: '@operato/twin-kernel EmsKernel.deriveEquipmentLoad (대기 중 부하)' }, { id: 'storage.capacityKWh', axes: ['equipment'], effect: 'twin.propeffect.capacityKWh', readBy: '@operato/twin-kernel EmsKernel (축전지 방전 가능량)' }, { id: 'dispatch.dischargeAboveKW', axes: ['equipment'], effect: 'twin.propeffect.dischargeAboveKW', readBy: '@operato/twin-kernel EmsKernel (방전 시작 문턱)' }, { id: 'dispatch.maxDischargeKW', axes: ['equipment'], effect: 'twin.propeffect.maxDischargeKW', readBy: '@operato/twin-kernel EmsKernel (인버터 방전 상한)' }, { id: 'dispatch.reserveSoc', axes: ['equipment'], effect: 'twin.propeffect.reserveSoc', readBy: '@operato/twin-kernel EmsKernel (이 아래로는 방전하지 않는다)' }, { id: 'storage.initialSoc', axes: ['equipment'], effect: 'twin.propeffect.initialSoc', readBy: '@operato/twin-kernel EmsKernel (시작 충전율 — 예측·what-if 의 씨앗)' }, { id: 'generation.ratedKW', axes: ['equipment'], effect: 'twin.propeffect.genRatedKW', readBy: '@operato/twin-kernel EmsKernel.deriveGeneration (정격 발전)' }, { id: 'generation.dailyProfile', axes: ['equipment'], effect: 'twin.propeffect.genDailyProfile', readBy: '@operato/twin-kernel generationFractionAt (시각별 발전 비율)' }, /* 요금은 커널이 읽지 않는다 — 금액은 저널을 계산하는 호스트가 만든다. readBy 가 그 사실을 말한다. */ { id: 'tariff.demandChargePerKW', axes: ['locations'], effect: 'twin.propeffect.demandChargePerKW', readBy: 'headless-twin/server/engine/kpi-query.ts (수요요금 계산)' }, { id: 'tariff.energyChargePerKWh', axes: ['locations'], effect: 'twin.propeffect.energyChargePerKWh', readBy: 'headless-twin/server/engine/kpi-query.ts (전력량 요금 계산)' }, /* 판정 기준 — 값이 경보의 유무를 바꾼다(가장 눈에 보이는 효과다). */ { id: 'attention.congestionRatio', axes: ['locations'], effect: 'twin.propeffect.congestionRatio', readBy: '@operato/twin-kernel deriveAttentions (병목 판정 기준)' }, { id: 'attention.scrapRate', axes: ['equipment'], effect: 'twin.propeffect.scrapRate', readBy: '@operato/twin-kernel deriveAttentions (불량 과다 판정 기준)' }, { id: 'attention.scrapMinSamples', axes: ['equipment'], effect: 'twin.propeffect.scrapMinSamples', readBy: '@operato/twin-kernel deriveAttentions (불량률을 재기 시작할 표본 수)' }, /* * ── 공정 소요시간 (2026-08-18) ──────────────────────────────────────────── * * 실측: 운영 중인 트윈 35개 중 **창고·야드는 공정 명세 행이 0개**였다 — 그 트윈들의 시간은 전부 커널 * 상수에서 왔고, 예측은 「같은 조건에서의 상대 비교」에 머물렀다(절대 시각 주장 불가). 이 선언이 * 그 사다리를 한 칸 올린다: 실측이 없어도 **현장이 정한 시간**으로 굴러간다. * * 실측(이력에서 배운 값)은 여전히 이 선언을 이긴다 — 화면이 그 사실을 함께 말한다. */ { id: 'operation.duration', axes: ['operations'], effect: 'twin.propeffect.operationDuration', readBy: '@operato/twin-kernel FlowEngine.durationOf ← declareDurations (engine/local-declarations.ts 가 실어 보낸다)' }, /* * ── 공정 모수 (2026-08-19) ──────────────────────────────────────────────── * 양품률의 기본값은 커널 소스의 상수(0.8)였고, **그 상수가 불량 과다 주목 신호까지 만들었다** — 초기 * 라인이든 성숙한 라인이든 같은 수로 판정된다. 셋업도 상수였다. 둘 다 현장이 말할 수 있어야 한다. */ { id: 'operation.yield', axes: ['operations'], effect: 'twin.propeffect.operationYield', readBy: '@operato/twin-kernel MesKernel(OP_PARAM.yield) ← declareParameters (양품/불량 판정과 산출량)' }, { id: 'operation.setupDuration', axes: ['operations'], effect: 'twin.propeffect.operationSetup', readBy: '@operato/twin-kernel MesKernel(OP_PARAM.setupDuration) ← declareParameters (가동 앞에 붙는 셋업)' }, { id: 'tariff.currency', axes: ['locations'], effect: 'twin.propeffect.currency', readBy: 'headless-twin/server/engine/kpi-query.ts (금액 표기 통화)' } ] /** * 이 축의 속성 효과 — `속성 id → i18n 키`. * * 없는 속성은 **여기 없다**(빈 값으로 채우지 않는다) — 화면이 "등록된 효과가 없다" 를 스스로 말한다. */ export function propertyEffectsOf(axis: string): Record { const out: Record = {} for (const e of PROPERTY_EFFECTS) if (e.axes.includes(axis)) out[e.id] = e.effect return out } /** 등록부의 축 이름 → 선언이 겨누는 대상 이름. 대상이 아닌 축(자재 정의 등)은 여기 없다. */ const AXIS_TO_TARGET: Record = { locations: 'location', equipment: 'equipment', operations: 'operation' } /** * 이 속성을 **어느 대상에 얹을 수 있나** — 등록부의 축이 그대로 답한다. * * ── 왜 필요한가 (2026-08-18) ──────────────────────────────────────────────── * 지게차에 계약전력을 선언할 수 있었다. 문은 받아 주고 화면은 「넣었습니다」라고 말하는데, 그 값을 읽는 * 코드는 자리(수전)만 본다 — 아무 일도 일어나지 않고 그 사실이 어디에도 나오지 않았다. 효과가 어느 축의 * 것인지는 이미 등록부가 알고 있으므로, 그 지식을 검사에 쓴다(두 곳에 적지 않는다). * * 등록부에 없는 속성은 **비어 있는 목록이 아니라 `undefined`** 다: 「어디에도 못 얹는다」와 「우리가 * 제한을 모른다」는 다르다(후자는 받아 준다 — 속성은 열려 있다는 규약을 지킨다). */ export function declarationTargetsOf(propertyId: string): string[] | undefined { const e = PROPERTY_EFFECTS.find(x => x.id === propertyId) if (!e) return undefined const targets = e.axes.map(a => AXIS_TO_TARGET[a]).filter((t): t is string => !!t) return targets.length ? targets : undefined }