/* * 이동시간 추정기 — **거리 × 속도.** 커널의 `TRAVEL_MS` 상수를 대체하는 유일한 정당한 길. * * 커널은 좌표를 모른다(설계: 좌표-free — "얼마 걸리나" 만 소비하고 "어떤 경로로" 는 씬 소관). * 그래서 거리 기반 계산은 **호스트가 추정기로 주입**한다(`DurationEstimator` 시임). * * 좌표는 이미 있다: 마스터의 `RefLocation.footprint` → `masterToTwin` 이 `model.layout`(노드 중심)으로 * 낸다. 없던 것은 **속도**다 — 속도 없이 거리만으로는 시간이 나오지 않는다. 그래서 설비 속성 * (ISA-95 `EquipmentSpecificationProperty`)으로 받는다. * * 정직 규율: **셋 중 하나라도 모르면 답하지 않는다**(undefined → 커널이 명세·상수로 떨어진다). * ① 두 노드의 좌표 ② 그 작업을 할 자원 종류의 속도 ③ 좌표의 단위가 미터인지 * 그리고 같은 노드 안의 작업(처리·체류)은 이동이 아니므로 다루지 않는다 — 거리 0 을 "0초" 로 * 만들면 공정 시간이 사라진다. */ import type { MeasuredEstimate } from './measured-estimator.js' /** * UN/CEFACT 권고 20 공통코드 → m/s 변환. 모르는 코드는 변환하지 않는다(발명 금지). * * **내보내는 이유**: 선언 화면·AI 가 「어떤 단위를 받나」를 말해야 하고, 그 목록이 두 곳에 적히면 * 화면이 받아 준 단위를 추정기가 모르는 일이 생긴다(그때 공정 시간이 오류 없이 사라진다). */ export const SPEED_TO_MPS: Record = { MTS: 1, // metre per second KMH: 1000 / 3600, // kilometre per hour '2M': 0.3048, // foot per second HM: 0.44704 // mile per hour } /** 우리가 읽는 canonical 설비 속성 ID — 표준이 속성 ID 어휘를 정하지 않으므로 한 곳에서 정한다. */ export const EQUIPMENT_PROPERTY = { speed: 'speed' } as const /** 추정기 문맥 — 커널 `DurationContext` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */ export interface DurationCtx { kind: string fromNode: string toNode: string resourceKind?: string } /** * 추정기 — 숫자(평균만) 또는 추정치 객체(퍼짐까지). 커널 `DurationEstimator` 와 같은 모양. * 거리 계산은 숫자를 내고, 실측은 관측된 퍼짐을 함께 낸다. */ export interface DurationEstimatorLike { estimate(ctx: DurationCtx): number | MeasuredEstimate | undefined } export interface TravelEstimatorInput { /** 노드 중심 좌표(masterToTwin 산출 `model.layout`). */ layout?: Record /** 무버(자원) — kind 와 속성. `model.equipment`. */ equipment?: { id: string; kind: string; properties?: { id: string; value: string; uom?: string }[] }[] /** 좌표 단위(공간 `content.unit`). 미터가 아니면 계산하지 않는다(환산을 추측하지 않는다). */ unit?: string } export interface TravelEstimatorResult { estimator?: { estimate(ctx: DurationCtx): number | undefined } /** 왜 못 만들었나(만들었으면 비어 있음) — 알리지 않고 없는 상태로 두지 않는다. */ reasons: string[] /** 속도를 읽어낸 자원 종류들 — 무엇이 계산 대상인지 드러낸다. */ speedsByKind: Record } /** 자원 종류별 속도(m/s) — 같은 종류에 값이 여럿이면 첫 유효값. 단위 없는 속도는 받지 않는다. */ function readSpeeds(equipment: TravelEstimatorInput['equipment'], reasons: string[]): Record { const out: Record = {} for (const m of equipment ?? []) { if (out[m.kind] !== undefined) continue const p = m.properties?.find(x => x.id === EQUIPMENT_PROPERTY.speed) if (!p) continue const n = Number(p.value) if (!Number.isFinite(n) || n <= 0) { reasons.push(`equipment "${m.id}": speed "${p.value}" is not a positive number`); continue } /* 단위가 없으면 m/s 라고 **가정하지 않는다** — 3 이 초속 3m 인지 시속 3km 인지 뜻이 완전히 다르다. */ if (!p.uom) { reasons.push(`equipment "${m.id}": speed has no unit of measure (expected UN/CEFACT code such as MTS or KMH)`); continue } const factor = SPEED_TO_MPS[p.uom] if (factor === undefined) { reasons.push(`equipment "${m.id}": unknown speed unit "${p.uom}"`); continue } out[m.kind] = n * factor } return out } /** * 이동시간 추정기를 만든다 — 만들 수 없으면 **이유와 함께** 비운다. * `estimator` 가 undefined 면 커널은 명세·상수로 굴러가고, `reasons` 가 왜 그런지 말한다. */ export function buildTravelEstimator(input: TravelEstimatorInput): TravelEstimatorResult { const reasons: string[] = [] const layout = input.layout const unit = input.unit ?? 'm' if (unit !== 'm') reasons.push(`space unit is "${unit}", not metres — travel time is not computed (no assumed conversion)`) if (!layout || !Object.keys(layout).length) reasons.push('no location coordinates (model.layout empty) — travel time cannot be derived from distance') const speedsByKind = readSpeeds(input.equipment, reasons) if (!Object.keys(speedsByKind).length) reasons.push('no resource declares a speed property — distance alone cannot produce a duration') if (reasons.length) return { reasons, speedsByKind } const lay = layout as Record return { reasons, speedsByKind, estimator: { estimate(ctx) { /* 같은 자리에서 벌어지는 일(처리·체류)은 이동이 아니다 — 다루지 않고 넘긴다. */ if (!ctx.fromNode || !ctx.toNode || ctx.fromNode === ctx.toNode) return undefined const a = lay[ctx.fromNode] const b = lay[ctx.toNode] if (!a || !b) return undefined // 좌표를 모르는 노드(관측으로 알게 된 자리 등) const speed = ctx.resourceKind ? speedsByKind[ctx.resourceKind] : undefined if (!speed) return undefined // 자원 종류를 모르거나 그 종류의 속도가 없다 const dist = Math.hypot(b.x - a.x, b.y - a.y) if (!Number.isFinite(dist) || dist <= 0) return undefined return Math.round((dist / speed) * 1000) } } } } /** * 추정기 사슬 — 앞선 것이 답하면 그 값, 아니면 다음. **실측 → 계산 → (커널이) 명세 → 상수** 순서를 * 이 사슬과 커널 `durationOf` 가 함께 만든다. 앞에 둘수록 강한 근거다. */ export function chainEstimators(estimators: (DurationEstimatorLike | undefined)[]): DurationEstimatorLike | undefined { const live = estimators.filter(Boolean) as DurationEstimatorLike[] if (!live.length) return undefined if (live.length === 1) return live[0] return { estimate(ctx) { for (const e of live) { const v = e.estimate(ctx) if (v !== undefined) return v } return undefined } } }