@things-factory/headless-twin 10.1.277 → 10.1.279
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -7
- package/dist-server/engine/attention-digest.js.map +0 -1
- package/dist-server/engine/canonical-ingest.js.map +0 -1
- package/dist-server/engine/checkpoint-fold.js.map +0 -1
- package/dist-server/engine/command-routing.js.map +0 -1
- package/dist-server/engine/declared-stimulus.js.map +0 -1
- package/dist-server/engine/energy-topology.js.map +0 -1
- package/dist-server/engine/event-time.js.map +0 -1
- package/dist-server/engine/index.js.map +0 -1
- package/dist-server/engine/ingest-dedupe.js.map +0 -1
- package/dist-server/engine/ingest-health.js.map +0 -1
- package/dist-server/engine/integration-coverage.js.map +0 -1
- package/dist-server/engine/integration-probes.js.map +0 -1
- package/dist-server/engine/integration-runner.js.map +0 -1
- package/dist-server/engine/integration-target-profile.js.map +0 -1
- package/dist-server/engine/kpi-baseline.js.map +0 -1
- package/dist-server/engine/kpi-fold.js.map +0 -1
- package/dist-server/engine/kpi-query.js.map +0 -1
- package/dist-server/engine/kpi-target.js.map +0 -1
- package/dist-server/engine/live-attentions.js.map +0 -1
- package/dist-server/engine/live-feed-registry.js.map +0 -1
- package/dist-server/engine/load-meter.js.map +0 -1
- package/dist-server/engine/local-declarations.js.map +0 -1
- package/dist-server/engine/log.js.map +0 -1
- package/dist-server/engine/loop-lag.js.map +0 -1
- package/dist-server/engine/measured-estimator.js.map +0 -1
- package/dist-server/engine/measured-yield.js.map +0 -1
- package/dist-server/engine/model-basis.js.map +0 -1
- package/dist-server/engine/model-gap.js.map +0 -1
- package/dist-server/engine/model-refusal.js.map +0 -1
- package/dist-server/engine/model-vocabulary.js.map +0 -1
- package/dist-server/engine/oee-accumulator.js.map +0 -1
- package/dist-server/engine/operation-basis.js.map +0 -1
- package/dist-server/engine/producer-guard.js.map +0 -1
- package/dist-server/engine/property-effects.js.map +0 -1
- package/dist-server/engine/read-time.js.map +0 -1
- package/dist-server/engine/restart-policy.js.map +0 -1
- package/dist-server/engine/runtime-key.js.map +0 -1
- package/dist-server/engine/spec-coverage.js.map +0 -1
- package/dist-server/engine/stage-path.js.map +0 -1
- package/dist-server/engine/state-axes.js.map +0 -1
- package/dist-server/engine/structure-diff.js.map +0 -1
- package/dist-server/engine/travel-estimator.js.map +0 -1
- package/dist-server/engine/twin-engine.js.map +0 -1
- package/dist-server/engine/twin-state-channel.js.map +0 -1
- package/dist-server/engine/warm-start.js.map +0 -1
- package/dist-server/index.js.map +0 -1
- package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js.map +0 -1
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +0 -1
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +0 -1
- package/dist-server/migrations/1786300000000-IndexEventTypeByTime.js.map +0 -1
- package/dist-server/migrations/1786400000000-IndexEventCreatedAt.js.map +0 -1
- package/dist-server/migrations/1786500000000-CreateTwinSubjectEvents.js.map +0 -1
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +0 -1
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +0 -1
- package/dist-server/migrations/1786800000000-CreateTwinReferenceReconciliations.js.map +0 -1
- package/dist-server/migrations/index.js.map +0 -1
- package/dist-server/routes.js.map +0 -1
- package/dist-server/service/actuation/actuation-advisor.js.map +0 -1
- package/dist-server/service/actuation/actuation-outcome.js.map +0 -1
- package/dist-server/service/actuation/actuation-rule-resolver.js.map +0 -1
- package/dist-server/service/actuation/actuation-rule.js.map +0 -1
- package/dist-server/service/actuation/approval-gateway.js.map +0 -1
- package/dist-server/service/actuation/catch-up-effects.js.map +0 -1
- package/dist-server/service/actuation/command-dispatcher.js.map +0 -1
- package/dist-server/service/actuation/command-store.js.map +0 -1
- package/dist-server/service/actuation/dispatch-after-approval.js.map +0 -1
- package/dist-server/service/actuation/effect-match.js.map +0 -1
- package/dist-server/service/actuation/index.js.map +0 -1
- package/dist-server/service/actuation/retry-plan.js.map +0 -1
- package/dist-server/service/actuation/rule-evaluate.js.map +0 -1
- package/dist-server/service/actuation/rule-loop.js.map +0 -1
- package/dist-server/service/actuation/rule-runner.js.map +0 -1
- package/dist-server/service/actuation/rule-wiring.js.map +0 -1
- package/dist-server/service/actuation/settle-effects.js.map +0 -1
- package/dist-server/service/actuation/sweep-dispatch.js.map +0 -1
- package/dist-server/service/actuation/twin-command-resolver.js.map +0 -1
- package/dist-server/service/actuation/twin-command.js.map +0 -1
- package/dist-server/service/actuation/twin-now.js.map +0 -1
- package/dist-server/service/index.js.map +0 -1
- package/dist-server/service/reference/actuation-routing.js.map +0 -1
- package/dist-server/service/reference/actuation-target.js.map +0 -1
- package/dist-server/service/reference/actuation-verification.js.map +0 -1
- package/dist-server/service/reference/attention-lane.js.map +0 -1
- package/dist-server/service/reference/connection-portability-resolver.js.map +0 -1
- package/dist-server/service/reference/connection-portability.js.map +0 -1
- package/dist-server/service/reference/control-routing.js.map +0 -1
- package/dist-server/service/reference/discovery-result.js.map +0 -1
- package/dist-server/service/reference/fill-cadence.js.map +0 -1
- package/dist-server/service/reference/fill-dedupe.js.map +0 -1
- package/dist-server/service/reference/fill-loop.js.map +0 -1
- package/dist-server/service/reference/fill-span.js.map +0 -1
- package/dist-server/service/reference/hook-contract.js.map +0 -1
- package/dist-server/service/reference/hook-rejected.js.map +0 -1
- package/dist-server/service/reference/hook-store.js.map +0 -1
- package/dist-server/service/reference/index.js.map +0 -1
- package/dist-server/service/reference/ingest-space.js.map +0 -1
- package/dist-server/service/reference/knob-defaults.js.map +0 -1
- package/dist-server/service/reference/live-cadence.js.map +0 -1
- package/dist-server/service/reference/live-feed-lease.js.map +0 -1
- package/dist-server/service/reference/live-ingest.js.map +0 -1
- package/dist-server/service/reference/reference-adapter.js.map +0 -1
- package/dist-server/service/reference/reference-assessment.js.map +0 -1
- package/dist-server/service/reference/reference-fill-run.js.map +0 -1
- package/dist-server/service/reference/reference-fill.js.map +0 -1
- package/dist-server/service/reference/reference-hook.js.map +0 -1
- package/dist-server/service/reference/reference-link-groups.js.map +0 -1
- package/dist-server/service/reference/reference-links.js.map +0 -1
- package/dist-server/service/reference/reference-live.js.map +0 -1
- package/dist-server/service/reference/reference-master.js.map +0 -1
- package/dist-server/service/reference/reference-probe.js.map +0 -1
- package/dist-server/service/reference/reference-progress-channel.js.map +0 -1
- package/dist-server/service/reference/reference-progress-subscription.js.map +0 -1
- package/dist-server/service/reference/reference-progress.js.map +0 -1
- package/dist-server/service/reference/reference-reconciliation-query.js.map +0 -1
- package/dist-server/service/reference/reference-reconciliation-writer.js.map +0 -1
- package/dist-server/service/reference/reference-reconciliation.js.map +0 -1
- package/dist-server/service/reference/reference-registry.js.map +0 -1
- package/dist-server/service/reference/reference-resolver.js.map +0 -1
- package/dist-server/service/reference/template-registry.js.map +0 -1
- package/dist-server/service/reference/twin-reference-reconciliation.js.map +0 -1
- package/dist-server/service/reference/twin-reference.js.map +0 -1
- package/dist-server/service/twin-attention/index.js.map +0 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +0 -1
- package/dist-server/service/twin-audit/command-audit.js.map +0 -1
- package/dist-server/service/twin-audit/index.js.map +0 -1
- package/dist-server/service/twin-audit/twin-audit-event.js.map +0 -1
- package/dist-server/service/twin-audit/twin-audit-query.js.map +0 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +0 -1
- package/dist-server/service/twin-backfill/backfill-shape.js.map +0 -1
- package/dist-server/service/twin-backfill/index.js.map +0 -1
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +0 -1
- package/dist-server/service/twin-control/index.js.map +0 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +0 -1
- package/dist-server/service/twin-event/index.js.map +0 -1
- package/dist-server/service/twin-event/journal-count.js.map +0 -1
- package/dist-server/service/twin-event/twin-event-keys.js.map +0 -1
- package/dist-server/service/twin-event/twin-event-type.js.map +0 -1
- package/dist-server/service/twin-event/twin-event.js.map +0 -1
- package/dist-server/service/twin-forecast/forecast-metrics.js.map +0 -1
- package/dist-server/service/twin-forecast/forecast-tuning.js.map +0 -1
- package/dist-server/service/twin-forecast/gap-analytics.js.map +0 -1
- package/dist-server/service/twin-forecast/index.js.map +0 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +0 -1
- package/dist-server/service/twin-ingest-window/index.js.map +0 -1
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +0 -1
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +0 -1
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +0 -1
- package/dist-server/service/twin-instance/index.js.map +0 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +0 -1
- package/dist-server/service/twin-journal/event-type-split-shape.js.map +0 -1
- package/dist-server/service/twin-journal/event-type-split.js.map +0 -1
- package/dist-server/service/twin-journal/index.js.map +0 -1
- package/dist-server/service/twin-journal/journal-order.js.map +0 -1
- package/dist-server/service/twin-journal/twin-journal-query.js.map +0 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +0 -1
- package/dist-server/service/twin-lifecycle/index.js.map +0 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +0 -1
- package/dist-server/service/twin-metrics/index.js.map +0 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +0 -1
- package/dist-server/service/twin-model/axis-journal-evidence.js.map +0 -1
- package/dist-server/service/twin-model/choose-recipe.js.map +0 -1
- package/dist-server/service/twin-model/epcis-coverage.js.map +0 -1
- package/dist-server/service/twin-model/external-resolver.js.map +0 -1
- package/dist-server/service/twin-model/iec61850-coverage.js.map +0 -1
- package/dist-server/service/twin-model/index.js.map +0 -1
- package/dist-server/service/twin-model/isa95-coverage.js.map +0 -1
- package/dist-server/service/twin-model/item-ref.js.map +0 -1
- package/dist-server/service/twin-model/name-index.js.map +0 -1
- package/dist-server/service/twin-model/project-structure.js.map +0 -1
- package/dist-server/service/twin-model/standard-coverage.js.map +0 -1
- package/dist-server/service/twin-model/status-tally.js.map +0 -1
- package/dist-server/service/twin-model/twin-equipment.js.map +0 -1
- package/dist-server/service/twin-model/twin-lineage-query.js.map +0 -1
- package/dist-server/service/twin-model/twin-location.js.map +0 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +0 -1
- package/dist-server/service/twin-model/twin-model-mutation.js.map +0 -1
- package/dist-server/service/twin-model/twin-model-query.js.map +0 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +0 -1
- package/dist-server/service/twin-model/twin-operation.js.map +0 -1
- package/dist-server/service/twin-readiness/index.js.map +0 -1
- package/dist-server/service/twin-readiness/twin-readiness-query.js.map +0 -1
- package/dist-server/service/twin-space/area-resolver.js.map +0 -1
- package/dist-server/service/twin-space/index.js.map +0 -1
- package/dist-server/service/twin-space/move-space.js.map +0 -1
- package/dist-server/service/twin-space/representation-role.js.map +0 -1
- package/dist-server/service/twin-space/space-integrity.js.map +0 -1
- package/dist-server/service/twin-space/twin-area.js.map +0 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +0 -1
- package/dist-server/service/twin-space/twin-space-representation.js.map +0 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +0 -1
- package/dist-server/service/twin-space/twin-space.js.map +0 -1
- package/dist-server/service/twin-state/index.js.map +0 -1
- package/dist-server/service/twin-state/twin-state-subscription.js.map +0 -1
- package/dist-server/service/twin-structure/index.js.map +0 -1
- package/dist-server/service/twin-structure/twin-structure.js.map +0 -1
- package/dist-server/service/twin-subject/event-subjects.js.map +0 -1
- package/dist-server/service/twin-subject/index.js.map +0 -1
- package/dist-server/service/twin-subject/item-resolver.js.map +0 -1
- package/dist-server/service/twin-subject/subject-query-shape.js.map +0 -1
- package/dist-server/service/twin-subject/twin-subject-backfill.js.map +0 -1
- package/dist-server/service/twin-subject/twin-subject-event.js.map +0 -1
- package/dist-server/service/twin-subject/twin-subject-fast-path.js.map +0 -1
- package/dist-server/service/twin-subject/twin-subject-query.js.map +0 -1
- package/dist-server/service/twin-subject/twin-subject-writer.js.map +0 -1
- package/dist-server/service/twin-target/index.js.map +0 -1
- package/dist-server/service/twin-target/twin-target-resolver.js.map +0 -1
- package/dist-server/service/twin-target/twin-target.js.map +0 -1
- package/dist-shared/axis-read.js.map +0 -1
- package/dist-shared/entity-delta.js.map +0 -1
- package/dist-shared/kpi-broadcast.js.map +0 -1
- package/dist-shared/touched-items.js.map +0 -1
- package/dist-shared/twin-level.js.map +0 -1
- package/server/engine/attention-digest.ts +0 -102
- package/server/engine/canonical-ingest.ts +0 -511
- package/server/engine/checkpoint-fold.ts +0 -12
- package/server/engine/command-routing.ts +0 -67
- package/server/engine/declared-stimulus.ts +0 -66
- package/server/engine/energy-topology.ts +0 -189
- package/server/engine/event-time.ts +0 -96
- package/server/engine/index.ts +0 -51
- package/server/engine/ingest-dedupe.ts +0 -24
- package/server/engine/ingest-health.ts +0 -24
- package/server/engine/integration-coverage.ts +0 -147
- package/server/engine/integration-probes.ts +0 -144
- package/server/engine/integration-runner.ts +0 -95
- package/server/engine/integration-target-profile.ts +0 -103
- package/server/engine/kpi-baseline.ts +0 -202
- package/server/engine/kpi-fold.ts +0 -1516
- package/server/engine/kpi-query.ts +0 -1210
- package/server/engine/kpi-target.ts +0 -248
- package/server/engine/live-attentions.ts +0 -30
- package/server/engine/live-feed-registry.ts +0 -59
- package/server/engine/load-meter.ts +0 -393
- package/server/engine/local-declarations.ts +0 -779
- package/server/engine/log.ts +0 -72
- package/server/engine/loop-lag.ts +0 -120
- package/server/engine/measured-estimator.ts +0 -91
- package/server/engine/measured-yield.ts +0 -89
- package/server/engine/model-basis.ts +0 -162
- package/server/engine/model-gap.ts +0 -168
- package/server/engine/model-refusal.ts +0 -31
- package/server/engine/model-vocabulary.ts +0 -82
- package/server/engine/oee-accumulator.ts +0 -195
- package/server/engine/operation-basis.ts +0 -131
- package/server/engine/producer-guard.ts +0 -64
- package/server/engine/property-effects.ts +0 -216
- package/server/engine/read-time.ts +0 -77
- package/server/engine/restart-policy.ts +0 -55
- package/server/engine/runtime-key.ts +0 -58
- package/server/engine/spec-coverage.ts +0 -105
- package/server/engine/stage-path.ts +0 -172
- package/server/engine/state-axes.ts +0 -60
- package/server/engine/structure-diff.ts +0 -88
- package/server/engine/travel-estimator.ts +0 -138
- package/server/engine/twin-engine.ts +0 -4979
- package/server/engine/twin-state-channel.ts +0 -39
- package/server/engine/warm-start.ts +0 -265
- package/server/index.ts +0 -90
- package/server/migrations/1786000000000-RenameTwinInstanceBoardToModel.ts +0 -43
- package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +0 -54
- package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +0 -53
- package/server/migrations/1786300000000-IndexEventTypeByTime.ts +0 -48
- package/server/migrations/1786400000000-IndexEventCreatedAt.ts +0 -38
- package/server/migrations/1786500000000-CreateTwinSubjectEvents.ts +0 -93
- package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +0 -66
- package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +0 -82
- package/server/migrations/1786800000000-CreateTwinReferenceReconciliations.ts +0 -39
- package/server/migrations/index.ts +0 -25
- package/server/routes.ts +0 -75
- package/server/service/actuation/actuation-advisor.ts +0 -131
- package/server/service/actuation/actuation-outcome.ts +0 -97
- package/server/service/actuation/actuation-rule-resolver.ts +0 -147
- package/server/service/actuation/actuation-rule.ts +0 -139
- package/server/service/actuation/approval-gateway.ts +0 -44
- package/server/service/actuation/catch-up-effects.ts +0 -152
- package/server/service/actuation/command-dispatcher.ts +0 -474
- package/server/service/actuation/command-store.ts +0 -236
- package/server/service/actuation/dispatch-after-approval.ts +0 -71
- package/server/service/actuation/effect-match.ts +0 -124
- package/server/service/actuation/index.ts +0 -27
- package/server/service/actuation/retry-plan.ts +0 -145
- package/server/service/actuation/rule-evaluate.ts +0 -352
- package/server/service/actuation/rule-loop.ts +0 -160
- package/server/service/actuation/rule-runner.ts +0 -216
- package/server/service/actuation/rule-wiring.ts +0 -84
- package/server/service/actuation/settle-effects.ts +0 -115
- package/server/service/actuation/sweep-dispatch.ts +0 -179
- package/server/service/actuation/twin-command-resolver.ts +0 -387
- package/server/service/actuation/twin-command.ts +0 -232
- package/server/service/actuation/twin-now.ts +0 -97
- package/server/service/index.ts +0 -100
- package/server/service/reference/actuation-routing.ts +0 -192
- package/server/service/reference/actuation-target.ts +0 -163
- package/server/service/reference/actuation-verification.ts +0 -85
- package/server/service/reference/attention-lane.ts +0 -21
- package/server/service/reference/connection-portability-resolver.ts +0 -171
- package/server/service/reference/connection-portability.ts +0 -329
- package/server/service/reference/control-routing.ts +0 -62
- package/server/service/reference/discovery-result.ts +0 -95
- package/server/service/reference/fill-cadence.ts +0 -29
- package/server/service/reference/fill-dedupe.ts +0 -66
- package/server/service/reference/fill-loop.ts +0 -153
- package/server/service/reference/fill-span.ts +0 -31
- package/server/service/reference/hook-contract.ts +0 -364
- package/server/service/reference/hook-rejected.ts +0 -53
- package/server/service/reference/hook-store.ts +0 -28
- package/server/service/reference/index.ts +0 -18
- package/server/service/reference/ingest-space.ts +0 -104
- package/server/service/reference/knob-defaults.ts +0 -59
- package/server/service/reference/live-cadence.ts +0 -129
- package/server/service/reference/live-feed-lease.ts +0 -56
- package/server/service/reference/live-ingest.ts +0 -94
- package/server/service/reference/reference-adapter.ts +0 -985
- package/server/service/reference/reference-assessment.ts +0 -250
- package/server/service/reference/reference-fill-run.ts +0 -179
- package/server/service/reference/reference-fill.ts +0 -223
- package/server/service/reference/reference-hook.ts +0 -479
- package/server/service/reference/reference-link-groups.ts +0 -21
- package/server/service/reference/reference-links.ts +0 -77
- package/server/service/reference/reference-live.ts +0 -420
- package/server/service/reference/reference-master.ts +0 -999
- package/server/service/reference/reference-probe.ts +0 -264
- package/server/service/reference/reference-progress-channel.ts +0 -26
- package/server/service/reference/reference-progress-subscription.ts +0 -78
- package/server/service/reference/reference-progress.ts +0 -95
- package/server/service/reference/reference-reconciliation-query.ts +0 -28
- package/server/service/reference/reference-reconciliation-writer.ts +0 -38
- package/server/service/reference/reference-reconciliation.ts +0 -55
- package/server/service/reference/reference-registry.ts +0 -17
- package/server/service/reference/reference-resolver.ts +0 -969
- package/server/service/reference/template-registry.ts +0 -85
- package/server/service/reference/twin-reference-reconciliation.ts +0 -27
- package/server/service/reference/twin-reference.ts +0 -190
- package/server/service/twin-attention/index.ts +0 -3
- package/server/service/twin-attention/twin-attention-query.ts +0 -70
- package/server/service/twin-audit/command-audit.ts +0 -114
- package/server/service/twin-audit/index.ts +0 -5
- package/server/service/twin-audit/twin-audit-event.ts +0 -158
- package/server/service/twin-audit/twin-audit-query.ts +0 -72
- package/server/service/twin-backfill/backfill-period-facts.ts +0 -168
- package/server/service/twin-backfill/backfill-shape.ts +0 -86
- package/server/service/twin-backfill/index.ts +0 -3
- package/server/service/twin-backfill/twin-backfill-resolver.ts +0 -35
- package/server/service/twin-control/index.ts +0 -3
- package/server/service/twin-control/twin-control-mutation.ts +0 -150
- package/server/service/twin-event/index.ts +0 -3
- package/server/service/twin-event/journal-count.ts +0 -53
- package/server/service/twin-event/twin-event-keys.ts +0 -215
- package/server/service/twin-event/twin-event-type.ts +0 -68
- package/server/service/twin-event/twin-event.ts +0 -250
- package/server/service/twin-forecast/forecast-metrics.ts +0 -106
- package/server/service/twin-forecast/forecast-tuning.ts +0 -40
- package/server/service/twin-forecast/gap-analytics.ts +0 -139
- package/server/service/twin-forecast/index.ts +0 -3
- package/server/service/twin-forecast/twin-forecast-query.ts +0 -486
- package/server/service/twin-ingest-window/index.ts +0 -7
- package/server/service/twin-ingest-window/twin-ingest-window-query.ts +0 -125
- package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +0 -57
- package/server/service/twin-ingest-window/twin-ingest-window.ts +0 -113
- package/server/service/twin-instance/index.ts +0 -3
- package/server/service/twin-instance/twin-instance.ts +0 -199
- package/server/service/twin-journal/event-type-split-shape.ts +0 -112
- package/server/service/twin-journal/event-type-split.ts +0 -144
- package/server/service/twin-journal/index.ts +0 -3
- package/server/service/twin-journal/journal-order.ts +0 -20
- package/server/service/twin-journal/twin-journal-query.ts +0 -615
- package/server/service/twin-lifecycle/domain-catalog.ts +0 -51
- package/server/service/twin-lifecycle/index.ts +0 -3
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +0 -285
- package/server/service/twin-metrics/index.ts +0 -3
- package/server/service/twin-metrics/twin-metrics-query.ts +0 -172
- package/server/service/twin-model/axis-journal-evidence.ts +0 -57
- package/server/service/twin-model/choose-recipe.ts +0 -31
- package/server/service/twin-model/epcis-coverage.ts +0 -251
- package/server/service/twin-model/external-resolver.ts +0 -173
- package/server/service/twin-model/iec61850-coverage.ts +0 -111
- package/server/service/twin-model/index.ts +0 -25
- package/server/service/twin-model/isa95-coverage.ts +0 -320
- package/server/service/twin-model/item-ref.ts +0 -80
- package/server/service/twin-model/name-index.ts +0 -94
- package/server/service/twin-model/project-structure.ts +0 -213
- package/server/service/twin-model/standard-coverage.ts +0 -35
- package/server/service/twin-model/status-tally.ts +0 -37
- package/server/service/twin-model/twin-equipment.ts +0 -191
- package/server/service/twin-model/twin-lineage-query.ts +0 -327
- package/server/service/twin-model/twin-location.ts +0 -147
- package/server/service/twin-model/twin-model-item-query.ts +0 -1029
- package/server/service/twin-model/twin-model-mutation.ts +0 -503
- package/server/service/twin-model/twin-model-query.ts +0 -792
- package/server/service/twin-model/twin-model-tree-query.ts +0 -211
- package/server/service/twin-model/twin-operation.ts +0 -157
- package/server/service/twin-readiness/index.ts +0 -3
- package/server/service/twin-readiness/twin-readiness-query.ts +0 -96
- package/server/service/twin-space/area-resolver.ts +0 -75
- package/server/service/twin-space/index.ts +0 -8
- package/server/service/twin-space/move-space.ts +0 -262
- package/server/service/twin-space/representation-role.ts +0 -15
- package/server/service/twin-space/space-integrity.ts +0 -293
- package/server/service/twin-space/twin-area.ts +0 -63
- package/server/service/twin-space/twin-space-area.ts +0 -66
- package/server/service/twin-space/twin-space-representation.ts +0 -99
- package/server/service/twin-space/twin-space-resolver.ts +0 -582
- package/server/service/twin-space/twin-space.ts +0 -148
- package/server/service/twin-state/index.ts +0 -3
- package/server/service/twin-state/twin-state-subscription.ts +0 -62
- package/server/service/twin-structure/index.ts +0 -3
- package/server/service/twin-structure/twin-structure.ts +0 -96
- package/server/service/twin-subject/event-subjects.ts +0 -162
- package/server/service/twin-subject/index.ts +0 -10
- package/server/service/twin-subject/item-resolver.ts +0 -92
- package/server/service/twin-subject/subject-query-shape.ts +0 -84
- package/server/service/twin-subject/twin-subject-backfill.ts +0 -196
- package/server/service/twin-subject/twin-subject-event.ts +0 -101
- package/server/service/twin-subject/twin-subject-fast-path.ts +0 -346
- package/server/service/twin-subject/twin-subject-query.ts +0 -210
- package/server/service/twin-subject/twin-subject-writer.ts +0 -189
- package/server/service/twin-target/index.ts +0 -5
- package/server/service/twin-target/twin-target-resolver.ts +0 -142
- package/server/service/twin-target/twin-target.ts +0 -97
- package/server/types/global.d.ts +0 -22
- package/shared/axis-read.ts +0 -28
- package/shared/entity-delta.ts +0 -335
- package/shared/kpi-broadcast.ts +0 -13
- package/shared/touched-items.ts +0 -73
- package/shared/twin-level.ts +0 -48
- package/test/ack-shape-alignment.test.ts +0 -97
- package/test/actuation-advisor.test.ts +0 -55
- package/test/actuation-approval-door.test.ts +0 -411
- package/test/actuation-dispatch.test.ts +0 -209
- package/test/actuation-effect.test.ts +0 -162
- package/test/actuation-outcome.test.ts +0 -152
- package/test/actuation-rule-runner.test.ts +0 -281
- package/test/actuation-rule.test.ts +0 -266
- package/test/actuation-seam.test.ts +0 -255
- package/test/actuation-settle.test.ts +0 -164
- package/test/actuation-target.test.ts +0 -145
- package/test/actuation-verification-state.test.ts +0 -139
- package/test/adopt-structure-live.test.ts +0 -133
- package/test/aggregate-not-findone.test.ts +0 -112
- package/test/area-resolver-db.test.ts +0 -110
- package/test/attention-digest.test.ts +0 -161
- package/test/axis-declaration-passthrough-db.test.ts +0 -152
- package/test/axis-journal-evidence.test.ts +0 -71
- package/test/axis-read.test.ts +0 -188
- package/test/backfill-shape.test.ts +0 -89
- package/test/boot-resume.test.ts +0 -287
- package/test/broadcast-cost-baseline.test.ts +0 -201
- package/test/broadcast-period.test.ts +0 -58
- package/test/broadcast-queue-overflow.test.ts +0 -244
- package/test/canonical-ingest-vocabularies.test.ts +0 -451
- package/test/canonical-quantity-door.test.ts +0 -61
- package/test/capability-mapping.test.ts +0 -89
- package/test/checkpoint-refuses-empty.test.ts +0 -103
- package/test/choose-recipe.test.ts +0 -117
- package/test/column-type-portability.test.ts +0 -122
- package/test/command-routing.test.ts +0 -61
- package/test/command-store-writes-columns.test.ts +0 -126
- package/test/connection-portability-doors.test.ts +0 -87
- package/test/connector-capability-declaration.test.ts +0 -195
- package/test/contract-layer-guard.test.ts +0 -48
- package/test/control-capability.test.ts +0 -98
- package/test/cursor-stall-not-read-failure.test.ts +0 -126
- package/test/declaration-reaches-model.test.ts +0 -226
- package/test/declared-location-types.test.ts +0 -89
- package/test/declared-stimulus.test.ts +0 -88
- package/test/dedupe-transformation.test.ts +0 -112
- package/test/discovery-result.test.ts +0 -75
- package/test/dispatch-after-approval-source.test.ts +0 -81
- package/test/duration-estimators.test.ts +0 -144
- package/test/energy-topology.test.ts +0 -113
- package/test/entity-delta.test.ts +0 -352
- package/test/equipment-identity-reaches.test.ts +0 -57
- package/test/event-subjects.test.ts +0 -148
- package/test/event-time-column.test.ts +0 -67
- package/test/event-type-split.test.ts +0 -178
- package/test/external-resolver.test.ts +0 -177
- package/test/fact-scope-wiring.test.ts +0 -96
- package/test/fill-dedupe.test.ts +0 -59
- package/test/fill-loop.test.ts +0 -35
- package/test/first-envelope.test.ts +0 -153
- package/test/fold-gap-speaks.test.ts +0 -53
- package/test/fold-shift-dst.test.ts +0 -110
- package/test/forecast-metrics.test.ts +0 -157
- package/test/forecast-tuning.test.ts +0 -42
- package/test/gap-analytics.test.ts +0 -113
- package/test/generated-not-counted.test.ts +0 -99
- package/test/guard-roots.ts +0 -76
- package/test/home-in-area.test.ts +0 -90
- package/test/hook-address.test.ts +0 -28
- package/test/hook-rejected-shape.test.ts +0 -86
- package/test/hook-response-sum.test.ts +0 -193
- package/test/hook-sequence.test.ts +0 -333
- package/test/ingest-bench.test.ts +0 -70
- package/test/ingest-dedupe.test.ts +0 -56
- package/test/ingest-expected-quiet.test.ts +0 -111
- package/test/ingest-health-engine.test.ts +0 -248
- package/test/ingest-health-wiring.test.ts +0 -120
- package/test/ingest-health.test.ts +0 -306
- package/test/ingest-history.test.ts +0 -247
- package/test/ingest-idempotent.test.ts +0 -211
- package/test/ingest-reconcile-four-ways.test.ts +0 -97
- package/test/ingest-rules-header.test.ts +0 -100
- package/test/ingest-running-guard.test.ts +0 -136
- package/test/ingest-space.test.ts +0 -78
- package/test/ingest-window-rolls-unobserved.test.ts +0 -72
- package/test/ingest-wiring-guard.test.ts +0 -197
- package/test/instance-cache-lifecycle.test.ts +0 -110
- package/test/intake-mapping-parity.test.ts +0 -175
- package/test/integration-probes.test.ts +0 -103
- package/test/integration-runner.test.ts +0 -95
- package/test/item-ref.test.ts +0 -78
- package/test/journal-order-axis.test.ts +0 -80
- package/test/journal-read-discipline.test.ts +0 -186
- package/test/journal-retention.test.ts +0 -275
- package/test/journal-sort-axis.test.ts +0 -142
- package/test/journal-write-door.test.ts +0 -110
- package/test/journal-write-trend.test.ts +0 -115
- package/test/kernel-kind-guard.test.ts +0 -98
- package/test/knob-defaults.test.ts +0 -72
- package/test/kpi-baseline-db.test.ts +0 -216
- package/test/kpi-baseline.test.ts +0 -196
- package/test/kpi-fold.test.ts +0 -1456
- package/test/kpi-query-bench.test.ts +0 -130
- package/test/lens-own-places.test.ts +0 -50
- package/test/lineage-survives-restart.test.ts +0 -234
- package/test/live-cadence.test.ts +0 -170
- package/test/live-cursor-wiring.test.ts +0 -87
- package/test/live-feed-lease.test.ts +0 -63
- package/test/live-feed-registry.test.ts +0 -75
- package/test/live-kernel-facts.test.ts +0 -161
- package/test/live-mirror-parity.test.ts +0 -286
- package/test/load-meter.test.ts +0 -328
- package/test/local-declarations.test.ts +0 -662
- package/test/log-stamp.test.ts +0 -59
- package/test/loop-lag.test.ts +0 -82
- package/test/master-to-twin.test.ts +0 -279
- package/test/measured-yield.test.ts +0 -90
- package/test/merge-streams.test.ts +0 -70
- package/test/mirror-resumes-from-checkpoint.test.ts +0 -192
- package/test/model-basis.test.ts +0 -141
- package/test/model-gap.test.ts +0 -153
- package/test/model-refusal.test.ts +0 -75
- package/test/model-vocabulary.test.ts +0 -114
- package/test/move-space.test.ts +0 -149
- package/test/mutation-gate.test.ts +0 -122
- package/test/oee-accumulator.test.ts +0 -204
- package/test/oee-site-calendar.test.ts +0 -146
- package/test/open-doors.test.ts +0 -197
- package/test/operation-basis.test.ts +0 -127
- package/test/operational-vocabulary.test.ts +0 -108
- package/test/operations-capability-db.test.ts +0 -165
- package/test/order-and-transaction-split.test.ts +0 -107
- package/test/order-fold-host.test.ts +0 -112
- package/test/producer-guard.test.ts +0 -66
- package/test/project-structure-db.test.ts +0 -226
- package/test/projection-reaches-screen.test.ts +0 -277
- package/test/projection-reads-declared.test.ts +0 -109
- package/test/property-effects.test.ts +0 -128
- package/test/provenance-promotion.test.ts +0 -99
- package/test/read-failure-ledger.test.ts +0 -106
- package/test/read-failure-visible.test.ts +0 -145
- package/test/read-time.test.ts +0 -118
- package/test/reference-fill.test.ts +0 -235
- package/test/reference-grounding.test.ts +0 -70
- package/test/reference-hook.test.ts +0 -177
- package/test/reference-links.test.ts +0 -56
- package/test/reference-reconciliation.test.ts +0 -39
- package/test/registry-key-guard.test.ts +0 -80
- package/test/rejection-carries-event-id.test.ts +0 -101
- package/test/rename-twin.test.ts +0 -89
- package/test/resolve-ts-siblings.mjs +0 -52
- package/test/restart-policy.test.ts +0 -111
- package/test/resync-origin-site.test.ts +0 -83
- package/test/retry-plan.test.ts +0 -139
- package/test/revision-axis.test.ts +0 -110
- package/test/runtime-key.test.ts +0 -66
- package/test/save-intake-collision.test.ts +0 -91
- package/test/scale-twin-bench.test.ts +0 -103
- package/test/secrets-at-rest.test.ts +0 -135
- package/test/snapshot-freshness.test.ts +0 -60
- package/test/source-outcome-audit.test.ts +0 -104
- package/test/space-integrity.test.ts +0 -223
- package/test/space-representation.test.ts +0 -66
- package/test/space-scope-includes-stopped.test.ts +0 -55
- package/test/spec-coverage.test.ts +0 -113
- package/test/stage-path.test.ts +0 -95
- package/test/standard-coverage.test.ts +0 -148
- package/test/state-axes.test.ts +0 -74
- package/test/status-tally.test.ts +0 -55
- package/test/streamline-e2e.test.ts +0 -76
- package/test/structure-revision-db.test.ts +0 -315
- package/test/subject-fast-path.test.ts +0 -148
- package/test/subject-read-model.test.ts +0 -175
- package/test/sweep-dispatch.test.ts +0 -191
- package/test/tenant-registry-db.test.ts +0 -149
- package/test/time-range.test.ts +0 -152
- package/test/touched-items.test.ts +0 -61
- package/test/twin-audit.test.ts +0 -83
- package/test/twin-event-keys.test.ts +0 -193
- package/test/twin-model-item-db.test.ts +0 -319
- package/test/twin-model-parent-relation-db.test.ts +0 -125
- package/test/twin-model-tree-db.test.ts +0 -209
- package/test/twin-origin-resync.test.ts +0 -133
- package/test/twin-state-channel.test.ts +0 -111
- package/test/vocabulary-guard.test.ts +0 -43
- package/test/warm-start-seam.test.ts +0 -162
- package/test/warm-start.test.ts +0 -297
- package/test/withheld-door.test.ts +0 -95
- package/test/yield-loop.test.ts +0 -199
- package/tsconfig.json +0 -15
- package/tsconfig.shared.json +0 -23
- package/tsconfig.shared.tsbuildinfo +0 -1
- package/tsconfig.tsbuildinfo +0 -1
package/test/kpi-fold.test.ts
DELETED
|
@@ -1,1456 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* 업무 KPI 폴드 검증 — 저널을 계산해 시간창 지표를 만드는 규칙을 고정한다.
|
|
3
|
-
*
|
|
4
|
-
* 무엇을 지키는가:
|
|
5
|
-
* ① **성과는 완료 시점으로 센다** — 시작 시점으로 세면 진행 중인 것이 섞여 처리량이 부풀려진다.
|
|
6
|
-
* ② **셈에서 빠진 것을 밝힌다**(unpaired) — 시작 이벤트를 못 찾은 완료를 알리지 않고 버리면 평균이 거짓이 된다.
|
|
7
|
-
* ③ **없는 것을 0 으로 꾸미지 않는다** — 저널이 비었으면 eventsSeen 0 으로 그 사실이 보인다.
|
|
8
|
-
* ④ 시간축은 eventTime 차분 — sim 클록 오프셋이 있어도 차분은 정확하다.
|
|
9
|
-
*/
|
|
10
|
-
import { test } from 'node:test'
|
|
11
|
-
import assert from 'node:assert/strict'
|
|
12
|
-
|
|
13
|
-
import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts'
|
|
14
|
-
import { judgeTargets, judgeGroupTargets, mergeTargets, scopedTargets } from '../server/engine/kpi-target.ts'
|
|
15
|
-
|
|
16
|
-
const T0 = Date.parse('2026-07-30T00:00:00Z')
|
|
17
|
-
const at = (sec: number) => new Date(T0 + sec * 1000).toISOString()
|
|
18
|
-
|
|
19
|
-
/** task.status 전이 한 줄. */
|
|
20
|
-
const task = (taskId: string, status: string, sec: number, resourceRef?: string): KpiEvent => ({
|
|
21
|
-
eventType: 'task.status',
|
|
22
|
-
eventTime: at(sec),
|
|
23
|
-
payload: { data: { taskId, status, resourceRef } }
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
/** 축을 가진 전이 한 줄 — kind·orderId·toNode 까지(실제 TaskStatusDelta 모양). */
|
|
27
|
-
const taskAt = (taskId: string, status: string, sec: number, facets: Record<string, unknown>): KpiEvent => ({
|
|
28
|
-
eventType: 'task.status',
|
|
29
|
-
eventTime: at(sec),
|
|
30
|
-
payload: { data: { taskId, status, ...facets } }
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const order = (orderId: string, status: string, sec: number): KpiEvent => ({
|
|
34
|
-
eventType: 'order.status',
|
|
35
|
-
eventTime: at(sec),
|
|
36
|
-
payload: { data: { orderId, status } }
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
const WINDOW = { fromMs: T0, toMs: T0 + 3600 * 1000 } // 1시간
|
|
40
|
-
|
|
41
|
-
test('처리량: 창 안에 완료된 작업만 센다(진행 중은 세지 않는다)', () => {
|
|
42
|
-
const r = foldKpi(
|
|
43
|
-
[
|
|
44
|
-
task('t1', 'created', 0),
|
|
45
|
-
task('t1', 'in-progress', 10),
|
|
46
|
-
task('t1', 'completed', 30),
|
|
47
|
-
task('t2', 'created', 20),
|
|
48
|
-
task('t2', 'in-progress', 25) // 완료 없음 — 진행 중
|
|
49
|
-
],
|
|
50
|
-
WINDOW
|
|
51
|
-
)
|
|
52
|
-
assert.equal(r.throughput.tasks, 1)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('처리량: 창 밖에 완료된 것은 세지 않는다 — 창 경계는 완료 시각으로 판정', () => {
|
|
56
|
-
const r = foldKpi(
|
|
57
|
-
[task('t1', 'created', -100), task('t1', 'in-progress', -90), task('t1', 'completed', -10)],
|
|
58
|
-
WINDOW
|
|
59
|
-
)
|
|
60
|
-
assert.equal(r.throughput.tasks, 0)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('리드타임·작업시간·대기시간을 각각 낸다 — 셋은 다른 질문에 답한다', () => {
|
|
64
|
-
/* 생성 0s → 착수 10s → 완료 40s : 리드 40s, 작업 30s, 대기 10s */
|
|
65
|
-
const r = foldKpi([task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 40)], WINDOW)
|
|
66
|
-
assert.equal(r.leadTime.avgMs, 40000)
|
|
67
|
-
assert.equal(r.workTime.avgMs, 30000)
|
|
68
|
-
assert.equal(r.waitTime.avgMs, 10000)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('분위수: p50·p90 을 낸다(작은 표본에서 과장하지 않는다)', () => {
|
|
72
|
-
const events: KpiEvent[] = []
|
|
73
|
-
/* 완료 소요를 1..10초로 열 건 */
|
|
74
|
-
for (let i = 1; i <= 10; i++) {
|
|
75
|
-
events.push(task(`t${i}`, 'created', 100 + i * 20))
|
|
76
|
-
events.push(task(`t${i}`, 'in-progress', 100 + i * 20))
|
|
77
|
-
events.push(task(`t${i}`, 'completed', 100 + i * 20 + i))
|
|
78
|
-
}
|
|
79
|
-
const r = foldKpi(events, WINDOW)
|
|
80
|
-
assert.equal(r.workTime.count, 10)
|
|
81
|
-
assert.equal(r.workTime.p50Ms, 6000)
|
|
82
|
-
assert.equal(r.workTime.p90Ms, 10000)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('시작 이벤트를 못 찾은 완료는 unpaired 로 밝힌다 — 평균에서 오류 없이 빠지지 않게', () => {
|
|
86
|
-
const r = foldKpi([task('t1', 'completed', 30)], WINDOW)
|
|
87
|
-
assert.equal(r.throughput.tasks, 1, '완료는 센다')
|
|
88
|
-
assert.equal(r.leadTime.count, 0, '리드타임은 계산할 수 없다')
|
|
89
|
-
assert.equal(r.unpaired.leadTime, 1)
|
|
90
|
-
assert.equal(r.unpaired.workTime, 1)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('창보다 앞선 시작도 짝지어진다 — 호출부가 앞 구간을 넉넉히 넘기면 리드타임이 살아난다', () => {
|
|
94
|
-
const r = foldKpi([task('t1', 'created', -600), task('t1', 'in-progress', -300), task('t1', 'completed', 60)], WINDOW)
|
|
95
|
-
assert.equal(r.leadTime.avgMs, 660000)
|
|
96
|
-
assert.equal(r.unpaired.leadTime, 0)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('자원 점유: 작업에 붙어 있던 시간의 합과 창 대비 비율', () => {
|
|
100
|
-
const r = foldKpi(
|
|
101
|
-
[
|
|
102
|
-
task('t1', 'created', 0),
|
|
103
|
-
task('t1', 'in-progress', 0, 'fk-1'),
|
|
104
|
-
task('t1', 'completed', 1800, 'fk-1'), // 30분
|
|
105
|
-
task('t2', 'created', 0),
|
|
106
|
-
task('t2', 'in-progress', 0, 'fk-2'),
|
|
107
|
-
task('t2', 'completed', 900, 'fk-2') // 15분
|
|
108
|
-
],
|
|
109
|
-
WINDOW
|
|
110
|
-
)
|
|
111
|
-
assert.deepEqual(
|
|
112
|
-
r.utilization.map(u => [u.resourceId, u.busyMs]),
|
|
113
|
-
[
|
|
114
|
-
['fk-1', 1800000],
|
|
115
|
-
['fk-2', 900000]
|
|
116
|
-
],
|
|
117
|
-
'점유가 큰 자원부터'
|
|
118
|
-
)
|
|
119
|
-
assert.equal(Math.round(r.utilization[0].ratio * 100), 50, '1시간 창에서 30분 = 50%')
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
/*
|
|
123
|
-
* ── 오더 완료: **동의어를 받지 않는다**(2026-08-23 에 바뀐 규칙) ─────────────
|
|
124
|
-
*
|
|
125
|
-
* 이 시험은 예전에 「도메인 어휘를 넓게 받는다(completed/fulfilled/done)」였다 — 그것이 **동의어 목록**
|
|
126
|
-
* 이었고, 그 방식이 낸 대가가 둘이었다.
|
|
127
|
-
*
|
|
128
|
-
* ① 원본의 낱말이 목록에 없으면 완료가 0 이 된다(첫 실 시스템의 `FINISHED` 가 그랬다).
|
|
129
|
-
* ② **시뮬 트윈에서는 언제나 0 이었다** — 시뮬은 `fulfilled` 만 올리고 `status` 는 바꾸지 않는다.
|
|
130
|
-
*
|
|
131
|
-
* 판정은 이제 커널이 한다(`isOrderTerminal`): 1차 근거는 **양**, 낱말은 닫힌 둘(`completed`·`cancelled`).
|
|
132
|
-
* 표준이 그 축을 갈라 두었다 — `RequestState` 는 표준도 열거하지 않고, 「됐나」는 실적에서 읽는다.
|
|
133
|
-
*/
|
|
134
|
-
test('오더 완료: 닫힌 종결 낱말만 받는다 — 동의어는 추론하지 않는다', () => {
|
|
135
|
-
const r = foldKpi(
|
|
136
|
-
[order('o1', 'completed', 10), order('o2', 'cancelled', 20), order('o3', 'in-progress', 30)],
|
|
137
|
-
WINDOW
|
|
138
|
-
)
|
|
139
|
-
assert.equal(r.throughput.orders, 2, 'completed · cancelled 둘')
|
|
140
|
-
const r2 = foldKpi([order('o4', 'fulfilled', 10), order('o5', 'done', 20), order('o6', 'FINISHED', 30)], WINDOW)
|
|
141
|
-
assert.equal(r2.throughput.orders, 0, '동의어로 보이는 낱말은 세지 않는다 — 커넥터가 옮길 일이다')
|
|
142
|
-
assert.deepEqual(r2.orderStatusNotCounted, { fulfilled: 1, done: 1, finished: 1 }, '대신 그 사실을 말한다')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('오더 완료: 양이 답한다 — 낱말이 진행 중이라 말해도', () => {
|
|
146
|
-
/*
|
|
147
|
-
* 시뮬을 되살리는 자리다. 시뮬 오더는 이행돼도 상태가 `created` 로 남으므로, 낱말로 묻는 판정은
|
|
148
|
-
* 그 완료를 한 건도 세지 못했다.
|
|
149
|
-
*/
|
|
150
|
-
const qty = (orderId: string, status: string, sec: number, requested: number, fulfilled: number): KpiEvent => ({
|
|
151
|
-
eventType: 'order.status',
|
|
152
|
-
eventTime: at(sec),
|
|
153
|
-
payload: { data: { orderId, status, requested, fulfilled } }
|
|
154
|
-
})
|
|
155
|
-
const r = foldKpi([qty('o1', 'created', 10, 10, 10), qty('o2', 'created', 20, 10, 9)], WINDOW)
|
|
156
|
-
assert.equal(r.throughput.orders, 1, '10 중 10 은 끝났고 10 중 9 는 아니다')
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
/*
|
|
160
|
-
* ── 받는 오더는 처리량이 아니다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────
|
|
161
|
-
*
|
|
162
|
-
* 입고 · 반품 입고는 세우는 순간 `fulfilled >= requested` 다 — 받았다는 사실이 곧 그 오더의 완료이기
|
|
163
|
-
* 때문이다. 그대로 세면 도착마다 처리량이 한 건씩 부풀고, 그 수는 화면에서 「많이 해냈다」로 읽힌다.
|
|
164
|
-
*/
|
|
165
|
-
test('★ 입고 · 반품 입고는 처리량에서 빠진다 — 받은 것은 해낸 것이 아니다', () => {
|
|
166
|
-
const kinded = (orderId: string, kind: string, sec: number): KpiEvent => ({
|
|
167
|
-
eventType: 'order.status',
|
|
168
|
-
eventTime: at(sec),
|
|
169
|
-
payload: { data: { orderId, kind, status: 'received', requested: 10, fulfilled: 10 } }
|
|
170
|
-
})
|
|
171
|
-
const out = (orderId: string, sec: number): KpiEvent => ({
|
|
172
|
-
eventType: 'order.status',
|
|
173
|
-
eventTime: at(sec),
|
|
174
|
-
payload: { data: { orderId, kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 } }
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const r = foldKpi([kinded('i1', 'inbound', 10), kinded('r1', 'return', 20), out('o1', 30)], WINDOW)
|
|
178
|
-
|
|
179
|
-
assert.equal(r.throughput.orders, 1, '나간 오더 하나만 센다')
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => {
|
|
183
|
-
const r = foldKpi([order('o1', 'completed', 10)], WINDOW)
|
|
184
|
-
assert.equal(r.throughput.orders, 1)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
test('오더 완료: 같은 오더를 한 번만 센다 — 폴링 원본이 다시 보내도', () => {
|
|
188
|
-
/*
|
|
189
|
-
* 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 있다(결함이 아니라 폴링의 성질).
|
|
190
|
-
* 사건마다 세면 처리량이 알리지 않고 부풀려진다 — 작업 쪽은 커널의 짝맞춤이 이미 그 규율을 갖고 있었고
|
|
191
|
-
* 오더만 없었다.
|
|
192
|
-
*/
|
|
193
|
-
const r = foldKpi([order('o1', 'completed', 10), order('o1', 'completed', 20), order('o2', 'completed', 30)], WINDOW)
|
|
194
|
-
assert.equal(r.throughput.orders, 2, '오더 둘 — 사건은 셋이다')
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
test('저널이 비면 0 이 아니라 "본 이벤트 없음" 으로 드러난다', () => {
|
|
198
|
-
const r = foldKpi([], WINDOW)
|
|
199
|
-
assert.equal(r.eventsSeen, 0)
|
|
200
|
-
assert.equal(r.eventsInWindow, 0)
|
|
201
|
-
assert.equal(r.throughput.tasks, 0)
|
|
202
|
-
assert.equal(r.leadTime.count, 0)
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
test('시각이 없거나 깨진 줄은 버린다 — 0 으로 세지 않는다', () => {
|
|
206
|
-
const r = foldKpi(
|
|
207
|
-
[
|
|
208
|
-
{ eventType: 'task.status', payload: { data: { taskId: 'x', status: 'completed' } } },
|
|
209
|
-
{ eventType: 'task.status', eventTime: 'not-a-date', payload: { data: { taskId: 'y', status: 'completed' } } }
|
|
210
|
-
],
|
|
211
|
-
WINDOW
|
|
212
|
-
)
|
|
213
|
-
assert.equal(r.eventsSeen, 0)
|
|
214
|
-
assert.equal(r.throughput.tasks, 0)
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태 차이에 걸리지 않게', () => {
|
|
218
|
-
const r = foldKpi(
|
|
219
|
-
[
|
|
220
|
-
{ eventType: 'task.status', eventTime: at(0), payload: { taskId: 't1', status: 'in-progress' } },
|
|
221
|
-
{ eventType: 'task.status', eventTime: at(20), payload: { taskId: 't1', status: 'completed' } }
|
|
222
|
-
],
|
|
223
|
-
WINDOW
|
|
224
|
-
)
|
|
225
|
-
assert.equal(r.workTime.avgMs, 20000)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => {
|
|
229
|
-
/* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건).
|
|
230
|
-
* 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */
|
|
231
|
-
const r = foldKpi(
|
|
232
|
-
[
|
|
233
|
-
task('t1', 'created', 0),
|
|
234
|
-
task('t1', 'in-progress', 10, 'fk-1'),
|
|
235
|
-
task('t1', 'completed', 40, 'fk-1'),
|
|
236
|
-
task('t1', 'completed', 45, 'fk-1') // 재전송
|
|
237
|
-
],
|
|
238
|
-
WINDOW
|
|
239
|
-
)
|
|
240
|
-
assert.equal(r.throughput.tasks, 1, '한 건이다')
|
|
241
|
-
assert.equal(r.workTime.count, 1)
|
|
242
|
-
assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준')
|
|
243
|
-
assert.deepEqual(
|
|
244
|
-
r.utilization.map(u => [u.resourceId, u.busyMs]),
|
|
245
|
-
[['fk-1', 35000]],
|
|
246
|
-
'점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)'
|
|
247
|
-
)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => {
|
|
251
|
-
const r = foldKpi(
|
|
252
|
-
[
|
|
253
|
-
task('t1', 'created', 0),
|
|
254
|
-
task('t1', 'in-progress', 10),
|
|
255
|
-
task('t1', 'in-progress', 15), // 재전송
|
|
256
|
-
task('t1', 'completed', 40)
|
|
257
|
-
],
|
|
258
|
-
WINDOW
|
|
259
|
-
)
|
|
260
|
-
assert.equal(r.workTime.avgMs, 30000, '처음 착수(10s) 기준')
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
/* ── 구간 쪼개기(추세) ─────────────────────────────────────────────────────
|
|
264
|
-
* 화면의 추세선이 여기서 나온다. 조회는 한 번이고 창만 바꿔 다시 계산한다.
|
|
265
|
-
*/
|
|
266
|
-
test('구간 쪼개기: N 등분해 구간별 처리량을 낸다', () => {
|
|
267
|
-
/* 1시간 창을 4등분(15분씩). 각 구간에 한 건씩 완료되게 놓는다. */
|
|
268
|
-
const events: KpiEvent[] = []
|
|
269
|
-
for (let i = 0; i < 4; i++) {
|
|
270
|
-
const sec = i * 900 + 100 // 100s, 1000s, 1900s, 2800s
|
|
271
|
-
events.push(task(`t${i}`, 'created', sec - 30), task(`t${i}`, 'in-progress', sec - 20), task(`t${i}`, 'completed', sec))
|
|
272
|
-
}
|
|
273
|
-
const buckets = foldKpiBuckets(events, WINDOW, 4)
|
|
274
|
-
assert.equal(buckets.length, 4)
|
|
275
|
-
assert.deepEqual(buckets.map(b => b.tasks), [1, 1, 1, 1])
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
test('구간 쪼개기: 구간 합이 전체 창의 처리량과 같다 — 경계에서 새거나 겹치지 않는다', () => {
|
|
279
|
-
const events: KpiEvent[] = []
|
|
280
|
-
for (let i = 0; i < 20; i++) {
|
|
281
|
-
const sec = 50 + i * 170
|
|
282
|
-
events.push(task(`t${i}`, 'created', sec - 10), task(`t${i}`, 'in-progress', sec - 5), task(`t${i}`, 'completed', sec))
|
|
283
|
-
}
|
|
284
|
-
const whole = foldKpi(events, WINDOW)
|
|
285
|
-
const buckets = foldKpiBuckets(events, WINDOW, 6)
|
|
286
|
-
const sum = buckets.reduce((a, b) => a + b.tasks, 0)
|
|
287
|
-
assert.equal(sum, whole.throughput.tasks)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
test('구간 쪼개기: 마지막 구간이 창의 끝까지 덮는다 — 나눗셈 오차로 마지막 완료가 빠지지 않게', () => {
|
|
291
|
-
/* 3등분(나누어떨어지지 않는 창) + 창의 마지막 순간에 완료 */
|
|
292
|
-
const win = { fromMs: WINDOW.fromMs, toMs: WINDOW.fromMs + 1000 * 1000 } // 1000초
|
|
293
|
-
const events = [task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 1000)]
|
|
294
|
-
const buckets = foldKpiBuckets(events, win, 3)
|
|
295
|
-
assert.equal(buckets[buckets.length - 1].toMs, win.toMs)
|
|
296
|
-
assert.equal(buckets.reduce((a, b) => a + b.tasks, 0), 1)
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
test('구간 쪼개기: 1 이하면 쪼개지 않는다(추세 없음)', () => {
|
|
300
|
-
assert.deepEqual(foldKpiBuckets([], WINDOW, 1), [])
|
|
301
|
-
assert.deepEqual(foldKpiBuckets([], WINDOW, 0), [])
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('구간 쪼개기: 구간별 작업시간 중앙값을 낸다 — "느려지고 있는가" 를 본다', () => {
|
|
305
|
-
const events: KpiEvent[] = [
|
|
306
|
-
/* 앞 구간: 10초 작업 */
|
|
307
|
-
task('a', 'created', 100), task('a', 'in-progress', 100), task('a', 'completed', 110),
|
|
308
|
-
/* 뒤 구간: 40초 작업 */
|
|
309
|
-
task('b', 'created', 2000), task('b', 'in-progress', 2000), task('b', 'completed', 2040)
|
|
310
|
-
]
|
|
311
|
-
const buckets = foldKpiBuckets(events, WINDOW, 2)
|
|
312
|
-
assert.equal(buckets[0].workP50Ms, 10000)
|
|
313
|
-
assert.equal(buckets[1].workP50Ms, 40000)
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
/* ── 관점 축(groupBy) ───────────────────────────────────────────────────────
|
|
317
|
-
* 왜 계산 층에 있는가: 고정된 한 렌즈만 낼 수 있으면 "구역별로는?" 이라는 질문에 화면도 AI 도 답할 수
|
|
318
|
-
* 없다. **사실 계산이 다각도를 못 내면 AI 도 못 낸다** — 그래서 축은 폴드의 능력이어야 한다.
|
|
319
|
-
*/
|
|
320
|
-
const three = (id: string, sec: number, facets: Record<string, unknown>): KpiEvent[] => [
|
|
321
|
-
taskAt(id, 'created', sec - 20, facets),
|
|
322
|
-
taskAt(id, 'in-progress', sec - 10, facets),
|
|
323
|
-
taskAt(id, 'completed', sec, facets)
|
|
324
|
-
]
|
|
325
|
-
|
|
326
|
-
test('축 없이는 groups 를 내지 않는다 — 묻지 않은 것을 실어 보내지 않는다', () => {
|
|
327
|
-
const r = foldKpi(three('t1', 100, { kind: 'move' }), WINDOW)
|
|
328
|
-
assert.equal(r.groups, undefined)
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
test('작업 종류별로 쪼갠다 — 전체 통계와 같은 규칙으로', () => {
|
|
332
|
-
const events = [
|
|
333
|
-
...three('a', 100, { kind: 'move' }),
|
|
334
|
-
...three('b', 200, { kind: 'move' }),
|
|
335
|
-
...three('c', 300, { kind: 'pick' })
|
|
336
|
-
]
|
|
337
|
-
const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
|
|
338
|
-
assert.equal(r.groups?.by, 'taskKind')
|
|
339
|
-
assert.deepEqual(
|
|
340
|
-
r.groups?.items.map(g => [g.key, g.tasks]),
|
|
341
|
-
[
|
|
342
|
-
['move', 2],
|
|
343
|
-
['pick', 1]
|
|
344
|
-
],
|
|
345
|
-
'건수 큰 축부터'
|
|
346
|
-
)
|
|
347
|
-
const move = r.groups!.items[0]
|
|
348
|
-
assert.equal(move.workTime.avgMs, 10000, '착수→완료 10초')
|
|
349
|
-
assert.equal(move.leadTime.avgMs, 20000, '생성→완료 20초')
|
|
350
|
-
assert.equal(move.busyMs, 20000, '두 건 × 10초')
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이 새지 않는다', () => {
|
|
354
|
-
const events = [
|
|
355
|
-
...three('a', 100, { kind: 'move', orderId: 'o1', toNode: 'dock-1', resourceRef: 'fk-1' }),
|
|
356
|
-
...three('b', 200, { kind: 'pick', orderId: 'o1', toNode: 'rack-1', resourceRef: 'fk-2' }),
|
|
357
|
-
...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
|
|
358
|
-
]
|
|
359
|
-
const whole = foldKpi(events, WINDOW)
|
|
360
|
-
for (const by of ['resource', 'taskKind', 'location', 'order'] as const) {
|
|
361
|
-
const r = foldKpi(events, WINDOW, { groupBy: by })
|
|
362
|
-
const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
|
|
363
|
-
assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
|
|
364
|
-
}
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
test('축의 값이 없던 건은 unknown 으로 모인다 — 알리지 않고 버리지 않는다', () => {
|
|
368
|
-
const events = [...three('a', 100, { kind: 'move', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'move' })]
|
|
369
|
-
const r = foldKpi(events, WINDOW, { groupBy: 'resource' })
|
|
370
|
-
assert.deepEqual(r.groups?.items.map(g => g.key).sort(), ['fk-1', 'unknown'])
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
test('축의 값은 다른 전이에서 와도 읽는다 — 완료 이벤트에 kind 가 없어도 축이 비지 않는다', () => {
|
|
374
|
-
/* 생성에만 kind 가 실리는 구현이 실제로 있다. 완료 줄만 보면 축이 통째로 unknown 이 된다. */
|
|
375
|
-
const events = [
|
|
376
|
-
taskAt('a', 'created', 80, { kind: 'putaway', orderId: 'o9' }),
|
|
377
|
-
taskAt('a', 'in-progress', 90, {}),
|
|
378
|
-
taskAt('a', 'completed', 100, {})
|
|
379
|
-
]
|
|
380
|
-
const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
|
|
381
|
-
assert.deepEqual(r.groups?.items.map(g => g.key), ['putaway'])
|
|
382
|
-
const byOrder = foldKpi(events, WINDOW, { groupBy: 'order' })
|
|
383
|
-
assert.deepEqual(byOrder.groups?.items.map(g => g.key), ['o9'])
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값은 마지막 것이 사실', () => {
|
|
387
|
-
const events = [
|
|
388
|
-
taskAt('a', 'created', 80, { toNode: 'dock-1' }),
|
|
389
|
-
taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
|
|
390
|
-
taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
|
|
391
|
-
]
|
|
392
|
-
const r = foldKpi(events, WINDOW, { groupBy: 'location' })
|
|
393
|
-
assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없으면 노드 id 로 대체하지 않는다', () => {
|
|
397
|
-
const events = [...three('a', 100, { toNode: 'rack-1' }), ...three('b', 200, { toNode: 'rack-2' })]
|
|
398
|
-
const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
|
|
399
|
-
assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
|
|
400
|
-
|
|
401
|
-
const mapped = foldKpi(events, WINDOW, { groupBy: 'area', locationArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
|
|
402
|
-
assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
test('축이 많으면 잘라내고 잘린 축의 수와 그 안의 건수를 함께 알린다 — 들어온 것이 없는 절단 금지', () => {
|
|
406
|
-
/* 축마다 건수를 다르게 둔다: 축의 수만 알리면 "그 외 20개" 가 20건인지 60건인지 알 수 없다. */
|
|
407
|
-
const events: KpiEvent[] = []
|
|
408
|
-
let t = 0
|
|
409
|
-
for (let i = 0; i < 25; i++) {
|
|
410
|
-
const n = i < 5 ? 3 : 1 // 앞 5개 축은 3건씩, 나머지 20개 축은 1건씩
|
|
411
|
-
for (let k = 0; k < n; k++) events.push(...three(`t${t++}`, 100 + t * 10, { orderId: `o${i}` }))
|
|
412
|
-
}
|
|
413
|
-
const r = foldKpi(events, WINDOW, { groupBy: 'order', groupLimit: 5 })
|
|
414
|
-
assert.equal(r.groups?.items.length, 5)
|
|
415
|
-
assert.equal(r.groups?.truncated, 20, '잘린 축의 수')
|
|
416
|
-
assert.equal(r.groups?.truncatedTasks, 20, '잘린 축들이 안고 있던 건수')
|
|
417
|
-
const whole = foldKpi(events, WINDOW)
|
|
418
|
-
assert.equal(
|
|
419
|
-
r.groups!.items.reduce((a, g) => a + g.tasks, 0) + r.groups!.truncatedTasks!,
|
|
420
|
-
whole.throughput.tasks,
|
|
421
|
-
'보여준 건수 + 잘린 건수 = 전체(축의 수가 아니라 건수로 맞는다)'
|
|
422
|
-
)
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
test('짝을 못 찾은 건도 축에서 센다 — 건수는 세고 소요만 비운다', () => {
|
|
426
|
-
const r = foldKpi([taskAt('a', 'completed', 100, { kind: 'move' })], WINDOW, { groupBy: 'taskKind' })
|
|
427
|
-
const g = r.groups!.items[0]
|
|
428
|
-
assert.equal(g.tasks, 1)
|
|
429
|
-
assert.equal(g.workTime.count, 0)
|
|
430
|
-
assert.equal(g.busyMs, 0)
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
/* ── 시간 비교 ──────────────────────────────────────────────────────────────
|
|
434
|
-
* "29초" 만으로는 좋아졌는지 나빠졌는지 알 수 없다. 창을 옮겨 한 번 더 계산 규칙을 고정한다.
|
|
435
|
-
*/
|
|
436
|
-
test('직전 구간: 같은 길이로 바로 앞 — 경계가 맞물린다(겹치거나 벌어지지 않게)', () => {
|
|
437
|
-
const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous')
|
|
438
|
-
assert.equal(w.toMs, WINDOW.fromMs, '앞 구간의 끝 = 현재 구간의 시작')
|
|
439
|
-
assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs, '길이가 같다')
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
test('어제 같은 시간: 정확히 24시간 전 같은 자리', () => {
|
|
443
|
-
const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'yesterday')
|
|
444
|
-
assert.equal(WINDOW.fromMs - w.fromMs, 24 * 60 * 60 * 1000)
|
|
445
|
-
assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs)
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
test('중앙값 차이: 현재 − 비교구간', () => {
|
|
449
|
-
const now = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
|
|
450
|
-
const prev = { count: 5, p50Ms: 30000, p90Ms: 0, avgMs: 0 }
|
|
451
|
-
assert.equal(deltaP50(now, prev), 10000, '10초 느려졌다')
|
|
452
|
-
assert.equal(deltaP50(prev, now), -10000, '부호로 방향을 말한다(좋고 나쁨은 판단하지 않는다)')
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
test('중앙값 차이: 한쪽이 결측이면 차이는 없다 — 0 으로 내면 "변화 없음" 이라는 거짓이 된다', () => {
|
|
456
|
-
const measured = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
|
|
457
|
-
const missing = { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
|
|
458
|
-
assert.equal(deltaP50(measured, missing), undefined)
|
|
459
|
-
assert.equal(deltaP50(missing, measured), undefined)
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
test('비교: 옮긴 창으로 계산하면 앞 구간의 성과가 나온다 — 같은 폴드, 창만 다르다', () => {
|
|
463
|
-
/* 현재 구간에 2건, 직전 구간에 1건. */
|
|
464
|
-
const events = [
|
|
465
|
-
...three('now-1', 100, { kind: 'move' }),
|
|
466
|
-
...three('now-2', 200, { kind: 'move' }),
|
|
467
|
-
...three('prev-1', -1800, { kind: 'move' })
|
|
468
|
-
]
|
|
469
|
-
const now = foldKpi(events, WINDOW)
|
|
470
|
-
const prev = foldKpi(events, shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous'))
|
|
471
|
-
assert.equal(now.throughput.tasks, 2)
|
|
472
|
-
assert.equal(prev.throughput.tasks, 1)
|
|
473
|
-
assert.equal(now.throughput.tasks - prev.throughput.tasks, 1, '처리량이 1건 늘었다')
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
test('창 앞 구간만 읽었으면 "측정된 기록 있음" 이 아니다 — lookback 을 성과로 착각하지 않게', () => {
|
|
477
|
-
/* 호출부는 창보다 앞까지 읽어 넘긴다(완료 작업의 시작이 창 밖일 수 있어서). 그 앞 구간 기록만
|
|
478
|
-
* 있는데 eventsSeen 으로 판단하면 "0건 처리" 라고 말하게 된다 — 실제로 가능했던 오류다. */
|
|
479
|
-
const before = [task('t1', 'created', -1200), task('t1', 'in-progress', -1100), task('t1', 'completed', -1000)]
|
|
480
|
-
const r = foldKpi(before, WINDOW)
|
|
481
|
-
assert.equal(r.eventsSeen, 3, '넘겨받은 것은 3줄')
|
|
482
|
-
assert.equal(r.eventsInWindow, 0, '창 안에는 아무것도 없다 → "기록 없음"')
|
|
483
|
-
assert.equal(r.throughput.tasks, 0)
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
test('창 안 기록만 센다 — 앞 구간이 섞여도 창 안 개수는 정확하다', () => {
|
|
487
|
-
const events = [
|
|
488
|
-
task('old', 'completed', -500), // 창 밖(앞)
|
|
489
|
-
...three('in-1', 100, { kind: 'move' }), // 창 안 3줄
|
|
490
|
-
task('late', 'completed', 4000) // 창 밖(뒤)
|
|
491
|
-
]
|
|
492
|
-
const r = foldKpi(events, WINDOW)
|
|
493
|
-
assert.equal(r.eventsSeen, 5)
|
|
494
|
-
assert.equal(r.eventsInWindow, 3)
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
/* ── 관점 × 비교 교차 ───────────────────────────────────────────────────────
|
|
498
|
-
* 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. 그 교차가 운영에서 가장
|
|
499
|
-
* 값 있는 질문이다.
|
|
500
|
-
*/
|
|
501
|
-
const g = (key: string, tasks: number, workMs = 10000) => {
|
|
502
|
-
const events = []
|
|
503
|
-
for (let i = 0; i < tasks; i++) {
|
|
504
|
-
const sec = 100 + i * 30
|
|
505
|
-
events.push(
|
|
506
|
-
taskAt(`${key}-${i}`, 'created', sec - 20, { kind: key }),
|
|
507
|
-
taskAt(`${key}-${i}`, 'in-progress', sec, { kind: key }),
|
|
508
|
-
taskAt(`${key}-${i}`, 'completed', sec + workMs / 1000, { kind: key })
|
|
509
|
-
)
|
|
510
|
-
}
|
|
511
|
-
return foldKpi(events, WINDOW, { groupBy: 'taskKind' }).groups!.items[0]
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
test('교차: 두 구간에 모두 있는 축은 delta 를 낸다', () => {
|
|
515
|
-
const c = crossGroups([g('pick', 10, 20000)], [g('pick', 7, 10000)])
|
|
516
|
-
const it = c.items[0]
|
|
517
|
-
assert.equal(it.prevTasks, 7)
|
|
518
|
-
assert.equal(it.deltaTasks, 3)
|
|
519
|
-
assert.equal(it.deltaWorkP50Ms, 10000, '작업시간이 10초 늘었다')
|
|
520
|
-
assert.deepEqual(c.disappeared, [])
|
|
521
|
-
})
|
|
522
|
-
|
|
523
|
-
test('교차: 지금만 있는 축은 isNew — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다', () => {
|
|
524
|
-
const c = crossGroups([g('putaway', 5)], [g('pick', 5)])
|
|
525
|
-
const nw = c.items.find(x => x.key === 'putaway')!
|
|
526
|
-
assert.equal(nw.isNew, true)
|
|
527
|
-
assert.equal(nw.deltaTasks, undefined)
|
|
528
|
-
assert.equal(nw.prevTasks, undefined)
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
test('교차: 사라진 축을 따로 알린다 — 멈춘 구역이 표에서 오류 없이 빠지지 않게', () => {
|
|
532
|
-
const c = crossGroups([g('pick', 5)], [g('pick', 5), g('putaway', 12)])
|
|
533
|
-
assert.deepEqual(c.disappeared, [{ key: 'putaway', tasks: 12 }])
|
|
534
|
-
})
|
|
535
|
-
|
|
536
|
-
test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어든 것을 섞지 않는다', () => {
|
|
537
|
-
const c = crossGroups([g('pick', 3), g('putaway', 4)], [])
|
|
538
|
-
assert.equal(c.items.every(x => x.isNew === true), true)
|
|
539
|
-
assert.deepEqual(c.disappeared, [])
|
|
540
|
-
})
|
|
541
|
-
|
|
542
|
-
/* ── 교대 축 ─────────────────────────────────────────────────────────────────
|
|
543
|
-
*
|
|
544
|
-
* 3교대처럼 하루를 끊김 없이 덮는 현장에서는 캘린더가 가용 시간을 줄이지 않는다. 그러면 교대 선언이
|
|
545
|
-
* 무엇을 주는가: **"언제" 를 묻는 축**이다. "어느 교대가 느린가" 는 3교대 공장에서 가장 먼저 나오는
|
|
546
|
-
* 질문인데, 그 축이 없으면 답이 없었다.
|
|
547
|
-
*
|
|
548
|
-
* **저널에는 교대가 없다 — 계산한다.** 이벤트에 교대 이름을 찍어 두면 나중에 교대 시간을 고쳤을 때
|
|
549
|
-
* **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 완료 시각 + 현장의 시각 기준 + 교대 선언에서 낸다.
|
|
550
|
-
*/
|
|
551
|
-
const SHIFTS_3 = [
|
|
552
|
-
{ id: 'A', fromTime: '06:00', toTime: '14:00' },
|
|
553
|
-
{ id: 'B', fromTime: '14:00', toTime: '22:00' },
|
|
554
|
-
{ id: 'C', fromTime: '22:00', toTime: '06:00', toDayOffset: 1 as const }
|
|
555
|
-
]
|
|
556
|
-
|
|
557
|
-
/** 하루치 창 — 세 교대가 모두 들어오게. */
|
|
558
|
-
const DAY = { fromMs: T0, toMs: T0 + 24 * 3600 * 1000 }
|
|
559
|
-
|
|
560
|
-
/** `hour` 시(UTC)에 완료되는 작업 한 건. */
|
|
561
|
-
const doneAtHour = (id: string, hour: number): KpiEvent[] => [
|
|
562
|
-
task(id, 'created', hour * 3600 - 60),
|
|
563
|
-
task(id, 'in-progress', hour * 3600 - 30),
|
|
564
|
-
task(id, 'completed', hour * 3600)
|
|
565
|
-
]
|
|
566
|
-
|
|
567
|
-
test('교대별로 쪼갠다 — 완료 시각에서 계산한다(저널에 없는 축)', () => {
|
|
568
|
-
const r = foldKpi(
|
|
569
|
-
[...doneAtHour('a1', 7), ...doneAtHour('a2', 9), ...doneAtHour('b1', 15), ...doneAtHour('c1', 23)],
|
|
570
|
-
DAY,
|
|
571
|
-
{ groupBy: 'shift', shifts: SHIFTS_3 }
|
|
572
|
-
)
|
|
573
|
-
const by = Object.fromEntries((r.groups?.items ?? []).map(g => [g.key, g.tasks]))
|
|
574
|
-
assert.deepEqual(by, { A: 2, B: 1, C: 1 })
|
|
575
|
-
assert.equal(r.groups?.items.reduce((s, g) => s + g.tasks, 0), r.throughput.tasks, '축의 합이 전체와 같다')
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
test('현장의 시각 기준으로 읽는다 — UTC 로 알리지 않고 가정하지 않는다', () => {
|
|
579
|
-
const events = [...doneAtHour('t1', 20)] // 절대 20시
|
|
580
|
-
const utc = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 })
|
|
581
|
-
const local = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3, utcOffsetMinutes: -420 })
|
|
582
|
-
assert.equal(utc.groups?.items[0].key, 'B', 'UTC 20시는 B 교대')
|
|
583
|
-
assert.equal(local.groups?.items[0].key, 'A', 'Rosarito(UTC−7) 기준으로는 13시 — A 교대다')
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
test('교대를 선언하지 않은 현장은 unknown — 없는 구분을 지어내지 않는다', () => {
|
|
587
|
-
const r = foldKpi([...doneAtHour('t1', 7)], DAY, { groupBy: 'shift' })
|
|
588
|
-
assert.deepEqual((r.groups?.items ?? []).map(g => g.key), ['unknown'])
|
|
589
|
-
})
|
|
590
|
-
|
|
591
|
-
test('교대 정의를 고치면 과거가 다시 세어진다 — 저널에 찍어 뒀다면 굳었을 것', () => {
|
|
592
|
-
const events = [...doneAtHour('t1', 7)]
|
|
593
|
-
const twoShift = [
|
|
594
|
-
{ id: 'DAY', fromTime: '07:00', toTime: '19:00' },
|
|
595
|
-
{ id: 'NIGHT', fromTime: '19:00', toTime: '07:00', toDayOffset: 1 as const }
|
|
596
|
-
]
|
|
597
|
-
assert.equal(foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }).groups?.items[0].key, 'A')
|
|
598
|
-
assert.equal(
|
|
599
|
-
foldKpi(events, DAY, { groupBy: 'shift', shifts: twoShift }).groups?.items[0].key,
|
|
600
|
-
'DAY',
|
|
601
|
-
'같은 저널, 다른 교대 정의 → 다시 센다'
|
|
602
|
-
)
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
/* ── 목표 대비 판정 ───────────────────────────────────────────────────────────
|
|
606
|
-
*
|
|
607
|
-
* 화면과 AI 는 "처리량 345건" 을 정확히 말하지만 **그것이 좋은지 나쁜지는 말할 수 없었다.** 근거가 앞
|
|
608
|
-
* 구간 비교뿐이라 "어제보다 나아졌다" 가 한계이고, 어제도 나빴으면 쓸모가 없다.
|
|
609
|
-
*
|
|
610
|
-
* 판정은 **사실 계산과 섞지 않는다** — 폴드는 목표를 모르고, 판정은 순수 함수로 따로 있다.
|
|
611
|
-
*/
|
|
612
|
-
const kpiOf = (events: KpiEvent[]) => foldKpi(events, DAY)
|
|
613
|
-
|
|
614
|
-
test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없다', () => {
|
|
615
|
-
const kpi = kpiOf([...doneAtHour('t1', 7), ...doneAtHour('t2', 8)])
|
|
616
|
-
const [more] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 2 }])
|
|
617
|
-
assert.equal(more.direction, 'higher')
|
|
618
|
-
assert.equal(more.met, true, '처리량은 목표 이상이면 만족')
|
|
619
|
-
|
|
620
|
-
const [faster] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 60_000 }])
|
|
621
|
-
assert.equal(faster.direction, 'lower', '시간은 낮을수록 좋다 — 입력이 아니라 지표가 안다')
|
|
622
|
-
assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초')
|
|
623
|
-
})
|
|
624
|
-
|
|
625
|
-
test('미달을 미달로 판정한다', () => {
|
|
626
|
-
const kpi = kpiOf([...doneAtHour('t1', 7)])
|
|
627
|
-
const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }])
|
|
628
|
-
assert.equal(v.met, false)
|
|
629
|
-
assert.equal(v.actual, 1)
|
|
630
|
-
assert.equal(v.ratio, 0.2, '비율은 항상 "1 이상이면 좋다" 로 정규화된다')
|
|
631
|
-
})
|
|
632
|
-
|
|
633
|
-
test('비율은 방향이 달라도 같은 뜻이다 — 한 화면에서 비교할 수 있게', () => {
|
|
634
|
-
const kpi = kpiOf([...doneAtHour('t1', 7)])
|
|
635
|
-
const [slow] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 15_000 }]) // 실제 30초, 목표 15초
|
|
636
|
-
assert.equal(slow.met, false)
|
|
637
|
-
assert.equal(slow.ratio, 0.5, '두 배 느리면 0.5 — 처리량이 절반일 때와 같은 숫자')
|
|
638
|
-
})
|
|
639
|
-
|
|
640
|
-
test('목표가 없으면 판정하지 않는다', () => {
|
|
641
|
-
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), []), [])
|
|
642
|
-
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), [])
|
|
643
|
-
})
|
|
644
|
-
|
|
645
|
-
test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 판정이 반대로 뒤집힌다', () => {
|
|
646
|
-
const empty = foldKpi([], DAY)
|
|
647
|
-
/* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면
|
|
648
|
-
시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 —
|
|
649
|
-
미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). */
|
|
650
|
-
assert.deepEqual(judgeTargets(empty, [{ metric: 'workTime.p50Ms', target: 15_000 }]), [])
|
|
651
|
-
assert.deepEqual(judgeTargets(empty, [{ metric: 'throughput.tasks', target: 10 }]), [], '기록 없음 ≠ 처리량 0')
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
test('모르는 지표 이름은 짐작해 매핑하지 않는다', () => {
|
|
655
|
-
assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), [{ metric: 'cycleTime.p50' as never, target: 1 }]), [])
|
|
656
|
-
})
|
|
657
|
-
|
|
658
|
-
/* ── 목표의 범위 (공간 vs 인스턴스) ──────────────────────────────────────────
|
|
659
|
-
*
|
|
660
|
-
* 이 규칙은 원래 DB 접근 뒤에 숨어 있어 **화면을 눌러 봐야만** 확인할 수 있었다. 로그인 뒤 화면을
|
|
661
|
-
* 자동으로 검증할 방법이 없는 프로젝트라 그런 규칙은 알리지 않고 미검증으로 쌓인다 — 그래서 밖으로 뺐다.
|
|
662
|
-
*/
|
|
663
|
-
const row = (metric: string, target: number, scope: { spaceId?: string; instanceId?: string }) => ({
|
|
664
|
-
metric,
|
|
665
|
-
target,
|
|
666
|
-
spaceId: scope.spaceId ?? null,
|
|
667
|
-
instanceId: scope.instanceId ?? null
|
|
668
|
-
})
|
|
669
|
-
|
|
670
|
-
test('공간 목표가 그 공간의 모든 트윈에 걸린다', () => {
|
|
671
|
-
const got = mergeTargets([row('throughput.tasks', 100, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: ['t1', 't2'] })
|
|
672
|
-
assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 100 }])
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
test('인스턴스 목표가 공간 목표를 덮는다 — 예외를 표현할 수 있어야 한다', () => {
|
|
676
|
-
const got = mergeTargets(
|
|
677
|
-
[row('throughput.tasks', 100, { spaceId: 'site-a' }), row('throughput.tasks', 40, { instanceId: 't2' })],
|
|
678
|
-
{ spaceId: 'site-a', instanceIds: ['t2'] }
|
|
679
|
-
)
|
|
680
|
-
assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 40 }], '"이 라인만 다르다" 를 말할 수 있어야 한다')
|
|
681
|
-
})
|
|
682
|
-
|
|
683
|
-
test('다른 공간·다른 인스턴스의 목표는 새어 들어오지 않는다', () => {
|
|
684
|
-
const got = mergeTargets(
|
|
685
|
-
[row('throughput.tasks', 999, { spaceId: 'site-b' }), row('workTime.p50Ms', 999, { instanceId: 'other' })],
|
|
686
|
-
{ spaceId: 'site-a', instanceIds: ['t1'] }
|
|
687
|
-
)
|
|
688
|
-
assert.deepEqual(got, [])
|
|
689
|
-
})
|
|
690
|
-
|
|
691
|
-
test('모르는 지표 이름·숫자 아닌 값은 목표로 세지 않는다', () => {
|
|
692
|
-
const got = mergeTargets(
|
|
693
|
-
[row('cycleTime.p50', 10, { spaceId: 'site-a' }), row('throughput.tasks', Number.NaN, { spaceId: 'site-a' })],
|
|
694
|
-
{ spaceId: 'site-a', instanceIds: [] }
|
|
695
|
-
)
|
|
696
|
-
assert.deepEqual(got, [], '평가할 수 없는 목표는 "목표 없음" 으로 읽혀 갭을 감춘다 — 아예 세지 않는다')
|
|
697
|
-
})
|
|
698
|
-
|
|
699
|
-
test('공간을 안 고르고 트윈 하나만 볼 때도 그 인스턴스 목표는 걸린다', () => {
|
|
700
|
-
const got = mergeTargets([row('waitTime.p50Ms', 5000, { instanceId: 't1' })], { instanceIds: ['t1'] })
|
|
701
|
-
assert.deepEqual(got, [{ metric: 'waitTime.p50Ms', target: 5000 }])
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
/* ── 축별 목표 ───────────────────────────────────────────────────────────────
|
|
705
|
-
*
|
|
706
|
-
* 구역·교대마다 목표가 다를 수 있다("이 라인만 40건"). 그런데 축 목표는 **전체 목표를 덮지 않는다** —
|
|
707
|
-
* 다른 질문이기 때문이다: "현장 전체 100건" 과 "용접 구역 30건" 은 동시에 참일 수 있다. 덮게 만들면
|
|
708
|
-
* 축 하나에 목표를 거는 순간 전체 판정이 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
|
|
709
|
-
*/
|
|
710
|
-
const arow = (metric: string, target: number, extra: Record<string, unknown> = {}) => ({
|
|
711
|
-
metric,
|
|
712
|
-
target,
|
|
713
|
-
spaceId: 'site-a',
|
|
714
|
-
instanceId: null,
|
|
715
|
-
axis: null,
|
|
716
|
-
axisKey: null,
|
|
717
|
-
...extra
|
|
718
|
-
})
|
|
719
|
-
|
|
720
|
-
test('축 목표와 전체 목표가 함께 산다 — 덮지 않는다', () => {
|
|
721
|
-
const got = scopedTargets(
|
|
722
|
-
[arow('throughput.tasks', 100), arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })],
|
|
723
|
-
{ spaceId: 'site-a', instanceIds: [], axis: 'area' }
|
|
724
|
-
)
|
|
725
|
-
assert.deepEqual(got.overall, [{ metric: 'throughput.tasks', target: 100 }], '전체 목표는 그대로 있다')
|
|
726
|
-
assert.deepEqual(got.byAxisKey.weld, [{ metric: 'throughput.tasks', target: 30 }])
|
|
727
|
-
})
|
|
728
|
-
|
|
729
|
-
test('지금 보는 축의 목표만 온다 — 다른 축의 목표가 섞이지 않는다', () => {
|
|
730
|
-
const got = scopedTargets(
|
|
731
|
-
[arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' }), arow('throughput.tasks', 50, { axis: 'shift', axisKey: 'A' })],
|
|
732
|
-
{ spaceId: 'site-a', instanceIds: [], axis: 'shift' }
|
|
733
|
-
)
|
|
734
|
-
assert.deepEqual(Object.keys(got.byAxisKey), ['A'])
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
test('축을 안 보고 있으면 축 목표는 오지 않는다 — 전체만', () => {
|
|
738
|
-
const got = scopedTargets([arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], {
|
|
739
|
-
spaceId: 'site-a',
|
|
740
|
-
instanceIds: []
|
|
741
|
-
})
|
|
742
|
-
assert.deepEqual(got.byAxisKey, {})
|
|
743
|
-
assert.deepEqual(got.overall, [])
|
|
744
|
-
})
|
|
745
|
-
|
|
746
|
-
test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않는다', () => {
|
|
747
|
-
const group = {
|
|
748
|
-
key: 'weld',
|
|
749
|
-
tasks: 12,
|
|
750
|
-
leadTime: { count: 12, p50Ms: 90_000 },
|
|
751
|
-
workTime: { count: 12, p50Ms: 30_000 },
|
|
752
|
-
waitTime: { count: 0, p50Ms: 0 }
|
|
753
|
-
}
|
|
754
|
-
const v = judgeGroupTargets(group, [
|
|
755
|
-
{ metric: 'throughput.tasks', target: 10 },
|
|
756
|
-
{ metric: 'workTime.p50Ms', target: 20_000 },
|
|
757
|
-
{ metric: 'waitTime.p50Ms', target: 5_000 },
|
|
758
|
-
{ metric: 'utilization.ratio', target: 0.8 }
|
|
759
|
-
])
|
|
760
|
-
assert.deepEqual(
|
|
761
|
-
v.map(x => [x.metric, x.met]),
|
|
762
|
-
[
|
|
763
|
-
['throughput.tasks', true],
|
|
764
|
-
['workTime.p50Ms', false]
|
|
765
|
-
],
|
|
766
|
-
'대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함'
|
|
767
|
-
)
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
/*
|
|
771
|
-
* ── 에너지 원단위 (§10 7단계) ───────────────────────────────────────────────
|
|
772
|
-
*
|
|
773
|
-
* 원단위는 분자(전력량)와 분모(산출)를 **같은 창**에서 나눈 값이다. 그래서 별도 질의가 아니라 이 폴드
|
|
774
|
-
* 안에 있다 — 창이 하나뿐이면 어긋날 수 없다.
|
|
775
|
-
*
|
|
776
|
-
* 분자는 저널의 **구간 마감 사건**에서 온다(커널 상태가 아니라 저널이므로 멈춘 트윈·과거 구간에도
|
|
777
|
-
* 답한다). 그 값이 파생임을 `basis` 가 말하고, 못 센 구간 수를 함께 낸다.
|
|
778
|
-
*/
|
|
779
|
-
const T = Date.parse('2026-08-14T00:00:00.000Z')
|
|
780
|
-
const q = 15 * 60_000
|
|
781
|
-
const win = (startMs: number, endMs: number, meanKW?: number) => ({
|
|
782
|
-
eventType: 'energy.demand.window',
|
|
783
|
-
eventTime: new Date(endMs).toISOString(),
|
|
784
|
-
payload: { data: { startMs, endMs, ...(meanKW !== undefined ? { meanKW } : {}) } }
|
|
785
|
-
})
|
|
786
|
-
const orderDone = (atMs: number) => ({
|
|
787
|
-
eventType: 'order.status',
|
|
788
|
-
eventTime: new Date(atMs).toISOString(),
|
|
789
|
-
payload: { data: { status: 'completed' } }
|
|
790
|
-
})
|
|
791
|
-
|
|
792
|
-
test('에너지 사건이 없으면 에너지 블록을 내지 않는다 — 없는 것을 만들지 않는다', () => {
|
|
793
|
-
const r = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
|
|
794
|
-
assert.equal(r.energy, undefined)
|
|
795
|
-
})
|
|
796
|
-
|
|
797
|
-
test('구간 마감에서 전력량과 대당 원단위를 낸다 — 같은 창이므로 어긋날 수 없다', () => {
|
|
798
|
-
const r = foldKpi(
|
|
799
|
-
[win(T, T + q, 400), win(T + q, T + 2 * q, 400), orderDone(T + 60_000), orderDone(T + q + 60_000)],
|
|
800
|
-
{ fromMs: T, toMs: T + 2 * q }
|
|
801
|
-
)
|
|
802
|
-
assert.equal(r.energy?.kWh, 200, '400kW × 0.25h × 2구간')
|
|
803
|
-
assert.equal(r.energy?.basis, 'mean-kw', '파생임을 밝힌다')
|
|
804
|
-
assert.equal(r.energy?.counted, 2)
|
|
805
|
-
assert.equal(r.energy?.intensity.value, 100, '200kWh ÷ 완료 2건')
|
|
806
|
-
assert.equal(r.energy?.intensity.unit, 'kWh/unit')
|
|
807
|
-
})
|
|
808
|
-
|
|
809
|
-
test('그 창에 완료가 없으면 원단위는 정의되지 않는다 — 전력량은 그대로 낸다', () => {
|
|
810
|
-
const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
|
|
811
|
-
assert.equal(r.energy?.kWh, 100, '전력량은 사실이다')
|
|
812
|
-
assert.equal(r.energy?.intensity.value, null)
|
|
813
|
-
assert.equal(r.energy?.intensity.reason, 'zero-denominator', '무한으로 내지 않는다')
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
test('계측이 끊긴 구간은 세지 않고 몇 개를 못 셌는지 말한다', () => {
|
|
817
|
-
const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q), orderDone(T + 60_000)], { fromMs: T, toMs: T + 2 * q })
|
|
818
|
-
assert.equal(r.energy?.counted, 1)
|
|
819
|
-
assert.equal(r.energy?.skipped, 1, '알리지 않고 빼면 이 합이 그 창의 전부인 줄 읽는다')
|
|
820
|
-
})
|
|
821
|
-
|
|
822
|
-
test('창에 걸친 구간은 세지 않는다 — 부분을 비례로 자르면 추정이 하나 더 생긴다', () => {
|
|
823
|
-
const r = foldKpi([win(T, T + q, 400), win(T + q, T + 2 * q, 400)], { fromMs: T, toMs: T + q + 60_000 })
|
|
824
|
-
assert.equal(r.energy?.counted, 1)
|
|
825
|
-
assert.equal(r.energy?.kWh, 100)
|
|
826
|
-
})
|
|
827
|
-
|
|
828
|
-
/*
|
|
829
|
-
* ── 창의 최대수요 (2026-08-17) ──────────────────────────────────────────────
|
|
830
|
-
*
|
|
831
|
-
* 전력량은 요금의 사용량 부분이고, 최대수요는 **기본요금과 계약 위반**을 정한다. 평균으로 갈음하면
|
|
832
|
-
* 계약을 넘긴 창이 「안 넘겼다」로 보고된다 — 15분 평균 400kW 인 창의 최대가 1,200kW 일 수 있다.
|
|
833
|
-
*/
|
|
834
|
-
const winMax = (startMs: number, endMs: number, meanKW: number, maxKW: number) => ({
|
|
835
|
-
eventType: 'energy.demand.window',
|
|
836
|
-
eventTime: new Date(endMs).toISOString(),
|
|
837
|
-
payload: { data: { startMs, endMs, meanKW, maxKW } }
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
test('창의 최대수요는 구간 최대들 중 가장 큰 값이다 — 평균으로 갈음하지 않는다', () => {
|
|
841
|
-
const r = foldKpi([winMax(T, T + q, 400, 620), winMax(T + q, T + 2 * q, 400, 1180)], { fromMs: T, toMs: T + 2 * q })
|
|
842
|
-
assert.equal(r.energy?.kWh, 200)
|
|
843
|
-
assert.equal(r.energy?.peakKW, 1180)
|
|
844
|
-
assert.equal(r.energy?.peakStartMs, T + q, '언제였는지도 답할 수 있어야 한다')
|
|
845
|
-
})
|
|
846
|
-
|
|
847
|
-
test('구간이 최대를 싣지 않으면 최대수요는 없다 — 0 도 평균도 아니다', () => {
|
|
848
|
-
const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
|
|
849
|
-
assert.equal(r.energy?.kWh, 100)
|
|
850
|
-
assert.equal(r.energy?.peakKW, undefined)
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
/*
|
|
854
|
-
* ── 에너지 목표 (2026-08-17) ────────────────────────────────────────────────
|
|
855
|
-
*
|
|
856
|
-
* 값은 폴드가 내고 있었는데 목표를 걸 수 있는 어휘에 없었다 — 그래서 화면은 「1,240kWh」를 보이면서도
|
|
857
|
-
* 그것이 계약 안쪽인지 말하지 못했다. 방향은 셋 다 「낮을수록 좋다」로 못 박혀 있다.
|
|
858
|
-
*/
|
|
859
|
-
test('최대수요 목표 — 계약 상한을 넘겼는지 판정한다', () => {
|
|
860
|
-
const kpi = foldKpi([winMax(T, T + q, 400, 1180)], { fromMs: T, toMs: T + q })
|
|
861
|
-
const [v] = judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }])
|
|
862
|
-
assert.equal(v.direction, 'lower', '많이 쓸수록 좋은 에너지 목표는 없다')
|
|
863
|
-
assert.equal(v.actual, 1180)
|
|
864
|
-
assert.equal(v.met, false, '계약 800kW 를 넘겼다')
|
|
865
|
-
})
|
|
866
|
-
|
|
867
|
-
test('전력량·원단위에도 목표를 건다', () => {
|
|
868
|
-
const kpi = foldKpi([win(T, T + q, 400), orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
|
|
869
|
-
const [kwh] = judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }])
|
|
870
|
-
assert.equal(kwh.actual, 100)
|
|
871
|
-
assert.equal(kwh.met, true)
|
|
872
|
-
const [per] = judgeTargets(kpi, [{ metric: 'energy.intensity', target: 80 }])
|
|
873
|
-
assert.equal(per.actual, 100, '100kWh ÷ 완료 1건')
|
|
874
|
-
assert.equal(per.met, false)
|
|
875
|
-
})
|
|
876
|
-
|
|
877
|
-
test('에너지를 못 잰 창은 판정하지 않는다 — 0 으로 읽으면 안 쓴 것이 만점이 된다', () => {
|
|
878
|
-
const kpi = foldKpi([orderDone(T + 60_000)], { fromMs: T, toMs: T + q })
|
|
879
|
-
assert.equal(judgeTargets(kpi, [{ metric: 'energy.kWh', target: 120 }]).length, 0)
|
|
880
|
-
assert.equal(judgeTargets(kpi, [{ metric: 'energy.peakKW', target: 800 }]).length, 0)
|
|
881
|
-
/* 원단위는 분모가 없어 `null` 로 온다 — 그것도 판정 대상이 아니다. */
|
|
882
|
-
const noOrders = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q })
|
|
883
|
-
assert.equal(noOrders.energy?.intensity.value, null)
|
|
884
|
-
assert.equal(judgeTargets(noOrders, [{ metric: 'energy.intensity', target: 80 }]).length, 0)
|
|
885
|
-
})
|
|
886
|
-
|
|
887
|
-
/*
|
|
888
|
-
* ── 분기별 몫 (2026-08-17) ──────────────────────────────────────────────────
|
|
889
|
-
*
|
|
890
|
-
* 저널의 에너지 사실은 **현장 총량 한 줄**이다. 그래서 자리별 몫은 잰 것이 아니라 **나눈 것**이고,
|
|
891
|
-
* 그 사실을 값과 함께 내야 한다. 이 검사가 지키는 것: 합 보존 · 근거 표시 · 모르는 자리를 지어내지
|
|
892
|
-
* 않기 · 선언이 없으면 분해하지 않기.
|
|
893
|
-
*/
|
|
894
|
-
const runs = (taskId: string, resource: string, fromSec: number, toSec: number) => [
|
|
895
|
-
{ eventType: 'task.status', eventTime: new Date(T + fromSec * 1000).toISOString(), payload: { data: { taskId, status: 'in-progress', resourceRef: resource } } },
|
|
896
|
-
{ eventType: 'task.status', eventTime: new Date(T + toSec * 1000).toISOString(), payload: { data: { taskId, status: 'completed', resourceRef: resource } } }
|
|
897
|
-
]
|
|
898
|
-
|
|
899
|
-
test('분기별 몫 — 가동시간에 비례해 나누고, 합은 총량을 지킨다', () => {
|
|
900
|
-
/* press 300초 · weld 100초 → 3:1. 총량 100kWh. */
|
|
901
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
|
|
902
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
|
|
903
|
-
assert.equal(r.energy?.kWh, 100)
|
|
904
|
-
const by = r.energy?.byFeeder ?? []
|
|
905
|
-
assert.equal(by.length, 2)
|
|
906
|
-
const press = by.find(x => x.key === 'feeder-press')!
|
|
907
|
-
const weld = by.find(x => x.key === 'feeder-weld')!
|
|
908
|
-
assert.ok(Math.abs(press.kWh - 75) < 1e-6, `press ${press.kWh}`)
|
|
909
|
-
assert.ok(Math.abs(weld.kWh - 25) < 1e-6, `weld ${weld.kWh}`)
|
|
910
|
-
/* 합 보존 — 이것이 깨지면 어딘가에서 전기가 사라지거나 생겨난 것이고, 그 위 모든 원단위가 거짓이다. */
|
|
911
|
-
const sum = by.reduce((a, x) => a + x.kWh, 0) + (r.energy?.unattributedKWh ?? 0)
|
|
912
|
-
assert.ok(Math.abs(sum - 100) < 1e-6, `합 ${sum}`)
|
|
913
|
-
})
|
|
914
|
-
|
|
915
|
-
test('몫에는 근거가 붙는다 — 여럿을 나눈 것이면 배분이라고 말한다', () => {
|
|
916
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300), ...runs('t2', 'weld-1', 0, 100)]
|
|
917
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' } })
|
|
918
|
-
const row = r.energy!.byFeeder![0]
|
|
919
|
-
assert.equal(row.basis, 'apportioned')
|
|
920
|
-
assert.equal(row.weightKind, 'runtimeMs')
|
|
921
|
-
assert.ok(row.weightShare > 0)
|
|
922
|
-
})
|
|
923
|
-
|
|
924
|
-
/*
|
|
925
|
-
* 선언된 부하가 **하나뿐인** 현장 — 커널은 「그 계량기는 그것만 잰다」고 읽는다(모델이 그렇게 말했다).
|
|
926
|
-
* 우리가 그것을 배분으로 낙인찍지 않는다: 처음에는 하드코딩으로 그렇게 했고, 그건 커널이 판정한 것을
|
|
927
|
-
* 화면에서 바꿔 말하는 짓이었다. 반대로 「돌아간 자원만 덮는다」고 거짓 선언을 넘기면 여럿인 현장에서도
|
|
928
|
-
* 이 경로로 빠져 배분이 측정으로 보고된다 — 그것이 실제로 처음의 결함이었다.
|
|
929
|
-
*/
|
|
930
|
-
test('선언된 부하가 하나면 커널의 판정을 그대로 옮긴다', () => {
|
|
931
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
|
|
932
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
|
|
933
|
-
assert.equal(r.energy!.byFeeder![0].basis, 'measured')
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
test('돌지 않은 설비는 0kWh 를 받지 않고, 몇 대가 빠졌는지 센다', () => {
|
|
937
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
|
|
938
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q }, {
|
|
939
|
-
resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld', 'paint-1': 'feeder-paint' }
|
|
940
|
-
})
|
|
941
|
-
const keys = (r.energy!.byFeeder ?? []).map(x => x.key)
|
|
942
|
-
assert.deepEqual(keys, ['feeder-press'], '가동 기록이 없는 자리는 행을 만들지 않는다(0 을 지어내지 않는다)')
|
|
943
|
-
assert.equal(r.energy!.excludedResources, 2, '빠진 설비 수를 밝힌다')
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
test('자리를 모르는 자원은 unknown 으로 모인다 — 빼지도, 아무 분기에 넣지도 않는다', () => {
|
|
947
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 200), ...runs('t2', 'ghost-1', 0, 200)]
|
|
948
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q }, { resourceFeeder: { 'press-1': 'feeder-press' } })
|
|
949
|
-
const by = r.energy!.byFeeder!
|
|
950
|
-
assert.ok(by.some(x => x.key === 'unknown'), '모르는 자리를 지어내지 않는다')
|
|
951
|
-
const sum = by.reduce((a, x) => a + x.kWh, 0)
|
|
952
|
-
assert.ok(Math.abs(sum - 100) < 1e-6, '모르는 몫까지 합해 총량을 지킨다')
|
|
953
|
-
})
|
|
954
|
-
|
|
955
|
-
test('선언이 없으면 분해하지 않는다 — 자리를 모르는 채 나눈 숫자는 아무 것의 몫도 아니다', () => {
|
|
956
|
-
const events = [win(T, T + q, 400), ...runs('t1', 'press-1', 0, 300)]
|
|
957
|
-
const r = foldKpi(events, { fromMs: T, toMs: T + q })
|
|
958
|
-
assert.equal(r.energy?.byFeeder, undefined)
|
|
959
|
-
assert.equal(r.energy?.kWh, 100, '총량은 그대로 낸다')
|
|
960
|
-
})
|
|
961
|
-
|
|
962
|
-
test('그 창에 아무도 돌지 않았으면 나누지 않고 이유를 낸다', () => {
|
|
963
|
-
/* 부하가 둘 이상 선언된 현장 — 나눌 대상은 있는데 몫이 없다. */
|
|
964
|
-
const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, {
|
|
965
|
-
resourceFeeder: { 'press-1': 'feeder-press', 'weld-1': 'feeder-weld' }
|
|
966
|
-
})
|
|
967
|
-
assert.equal(r.energy?.byFeeder, undefined, '가동 기록이 없으면 나눌 근거가 없다')
|
|
968
|
-
assert.equal(r.energy?.unattributedReason, 'no-weights')
|
|
969
|
-
assert.equal(r.energy?.unattributedKWh, 100, '나누지 못한 양은 그대로 남는다(0 으로 만들지 않는다)')
|
|
970
|
-
assert.equal(r.energy?.kWh, 100)
|
|
971
|
-
})
|
|
972
|
-
|
|
973
|
-
/*
|
|
974
|
-
* ── 금액 (2026-08-17) ───────────────────────────────────────────────────────
|
|
975
|
-
*
|
|
976
|
-
* 트윈은 「최대수요 328kW」까지 말하고 멈춰 있었다. 피크를 깎는 일이 돈이 되는 이유가 요금의 구조에
|
|
977
|
-
* 있으므로(사용량 + 기본요금) 단가를 선언하면 금액이 나온다. 계산은 커널이 하고 폴드는 나른다 —
|
|
978
|
-
* 이 검사는 **같은 창의 값에서 나오는지**와 **단가가 없을 때 지어내지 않는지**를 본다.
|
|
979
|
-
*/
|
|
980
|
-
test('단가를 선언하면 이 창의 금액이 나온다 — 전력량과 최대수요 각각에서', () => {
|
|
981
|
-
const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q }, {
|
|
982
|
-
tariff: { energyChargePerKWh: 0.1, demandChargePerKW: 20, currency: 'USD' }
|
|
983
|
-
})
|
|
984
|
-
assert.equal(r.energy?.kWh, 100)
|
|
985
|
-
assert.equal(r.energy?.peakKW, 1000)
|
|
986
|
-
const c: any = r.energy?.cost
|
|
987
|
-
assert.equal(c.energyCharge, 10, '100kWh × 0.1')
|
|
988
|
-
assert.equal(c.demandCharge, 20_000, '1,000kW × 20')
|
|
989
|
-
assert.equal(c.currency, 'USD')
|
|
990
|
-
assert.equal(c.demandBasis, 'window-peak-as-period-peak', '기본요금의 조건이 값과 함께 온다')
|
|
991
|
-
})
|
|
992
|
-
|
|
993
|
-
test('단가를 선언하지 않으면 금액을 만들지 않는다', () => {
|
|
994
|
-
const r = foldKpi([winMax(T, T + q, 400, 1000)], { fromMs: T, toMs: T + q })
|
|
995
|
-
assert.equal(r.energy?.cost, undefined)
|
|
996
|
-
})
|
|
997
|
-
|
|
998
|
-
test('최대수요를 못 잰 창은 기본요금을 지어내지 않는다', () => {
|
|
999
|
-
const r = foldKpi([win(T, T + q, 400)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20, energyChargePerKWh: 0.1 } })
|
|
1000
|
-
const c: any = r.energy?.cost
|
|
1001
|
-
assert.equal(c.energyCharge, 10)
|
|
1002
|
-
assert.equal(c.demandCharge, undefined)
|
|
1003
|
-
})
|
|
1004
|
-
|
|
1005
|
-
test('에너지 사실이 없는 창에는 금액도 없다 — 0 원이라고 말하지 않는다', () => {
|
|
1006
|
-
const r = foldKpi([orderDone(T + 1000)], { fromMs: T, toMs: T + q }, { tariff: { demandChargePerKW: 20 } })
|
|
1007
|
-
assert.equal(r.energy, undefined)
|
|
1008
|
-
})
|
|
1009
|
-
|
|
1010
|
-
/*
|
|
1011
|
-
* ── 깎인 양 (2026-08-17) ────────────────────────────────────────────────────
|
|
1012
|
-
*
|
|
1013
|
-
* 요금은 계통 수요로 매겨지고, 그 수요를 만든 것은 설비 부하다. 두 수의 간격이 곧 태양광·배터리가
|
|
1014
|
-
* 덜어낸 몫이다 — 그것이 피크 셰이빙의 값이다. **같은 구간의 두 수**여야 차이가 뜻을 갖는다.
|
|
1015
|
-
*/
|
|
1016
|
-
const winNet = (startMs: number, endMs: number, meanKW: number, maxKW: number, grossMaxKW?: number) => ({
|
|
1017
|
-
eventType: 'energy.demand.window',
|
|
1018
|
-
eventTime: new Date(endMs).toISOString(),
|
|
1019
|
-
payload: { data: { startMs, endMs, meanKW, maxKW, ...(grossMaxKW !== undefined ? { grossMaxKW } : {}) } }
|
|
1020
|
-
})
|
|
1021
|
-
|
|
1022
|
-
test('피크 구간의 총부하와 깎인 양을 낸다', () => {
|
|
1023
|
-
const r = foldKpi([winNet(T, T + q, 300, 400, 600), winNet(T + q, T + 2 * q, 500, 900, 1200)], { fromMs: T, toMs: T + 2 * q })
|
|
1024
|
-
assert.equal(r.energy?.peakKW, 900)
|
|
1025
|
-
assert.equal(r.energy?.peakGrossKW, 1200)
|
|
1026
|
-
assert.equal(r.energy?.shavedKW, 300, '1,200 − 900 — 그 순간 계통에서 덜어낸 몫')
|
|
1027
|
-
})
|
|
1028
|
-
|
|
1029
|
-
test('깎인 것이 없으면 그 값을 만들지 않는다 — 0 을 성과로 적지 않는다', () => {
|
|
1030
|
-
const r = foldKpi([winNet(T, T + q, 300, 400, 400)], { fromMs: T, toMs: T + q })
|
|
1031
|
-
assert.equal(r.energy?.peakGrossKW, 400)
|
|
1032
|
-
assert.equal(r.energy?.shavedKW, undefined)
|
|
1033
|
-
})
|
|
1034
|
-
|
|
1035
|
-
test('총부하를 싣지 않은 옛 구간은 그대로 — 순수요와 같다고 단정하지 않는다', () => {
|
|
1036
|
-
const r = foldKpi([winMax(T, T + q, 400, 900)], { fromMs: T, toMs: T + q })
|
|
1037
|
-
assert.equal(r.energy?.peakKW, 900)
|
|
1038
|
-
assert.equal(r.energy?.peakGrossKW, undefined)
|
|
1039
|
-
assert.equal(r.energy?.shavedKW, undefined)
|
|
1040
|
-
})
|
|
1041
|
-
|
|
1042
|
-
/*
|
|
1043
|
-
* ── 분기 계량이 있으면 배분하지 않는다 (2026-08-18) ───────────────────────────
|
|
1044
|
-
* 실 게이트웨이는 분기마다 계량기를 보낸다. 그런데 화면은 「나누지 못함」으로 답했다 — 폴드에 가동시간
|
|
1045
|
-
* 배분 경로만 있었기 때문이다. 미러 트윈에는 설비 가동 기록조차 없어(원천이 계량만 준다) 그 경로로는
|
|
1046
|
-
* 분기별 값이 영원히 나오지 않았다.
|
|
1047
|
-
*/
|
|
1048
|
-
const windowFact = (startMs: number, points: { id: string; kWh: number; root?: boolean }[], meanKW = 400) => ({
|
|
1049
|
-
eventType: 'energy.demand.window',
|
|
1050
|
-
eventTime: new Date(startMs + 900_000).toISOString(),
|
|
1051
|
-
instanceId: 'ems-1',
|
|
1052
|
-
payload: {
|
|
1053
|
-
data: {
|
|
1054
|
-
startMs,
|
|
1055
|
-
endMs: startMs + 900_000,
|
|
1056
|
-
meanKW,
|
|
1057
|
-
maxKW: meanKW,
|
|
1058
|
-
samples: 30,
|
|
1059
|
-
points: points.map(p => ({ id: p.id, kWh: p.kWh, ...(p.root ? { root: true } : {}) }))
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
})
|
|
1063
|
-
|
|
1064
|
-
test('분기별 값이 계량에서 온다 — 근거는 measured, 가동시간 이야기를 하지 않는다', () => {
|
|
1065
|
-
const t0 = Date.parse('2026-08-18T00:00:00.000Z')
|
|
1066
|
-
const r = foldKpi([windowFact(t0, [
|
|
1067
|
-
{ id: 'meter-incoming', kWh: 100, root: true },
|
|
1068
|
-
{ id: 'meter-a', kWh: 40 },
|
|
1069
|
-
{ id: 'meter-b', kWh: 30 }
|
|
1070
|
-
]) as any], { fromMs: t0, toMs: t0 + 900_000 }, {
|
|
1071
|
-
meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
|
|
1072
|
-
})
|
|
1073
|
-
const e = r.energy!
|
|
1074
|
-
const byKey = new Map((e.byFeeder ?? []).map(f => [f.key, f]))
|
|
1075
|
-
assert.equal(byKey.get('feeder-a')?.kWh, 40)
|
|
1076
|
-
assert.equal(byKey.get('feeder-a')?.basis, 'measured')
|
|
1077
|
-
assert.equal(byKey.get('feeder-a')?.weightKind, undefined, '나누지 않았으니 무엇으로 나눴다고 말하지 않는다')
|
|
1078
|
-
assert.equal(byKey.get('feeder-b')?.kWh, 30)
|
|
1079
|
-
})
|
|
1080
|
-
|
|
1081
|
-
test('하위 계량이 덮지 않는 나머지를 남긴다 — 어느 분기에 얹지 않는다', () => {
|
|
1082
|
-
const t0 = Date.parse('2026-08-18T01:00:00.000Z')
|
|
1083
|
-
/* 구간 평균 400kW × 0.25h = 100kWh 가 총량이고, 분기 합은 70 이다. */
|
|
1084
|
-
const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 30 }]) as any],
|
|
1085
|
-
{ fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
|
|
1086
|
-
const e = r.energy!
|
|
1087
|
-
assert.equal(Math.round(e.kWh!), 100)
|
|
1088
|
-
assert.equal(Math.round(e.unattributedKWh!), 30)
|
|
1089
|
-
assert.equal(e.unattributedReason, 'not-submetered', '「선언이 없다」가 아니라 「계량이 덮지 않는다」')
|
|
1090
|
-
const sum = (e.byFeeder ?? []).reduce((s, f) => s + f.kWh, 0) + (e.unattributedKWh ?? 0)
|
|
1091
|
-
assert.ok(Math.abs(sum - e.kWh!) < 1e-6, `합이 보존된다(${sum} vs ${e.kWh})`)
|
|
1092
|
-
})
|
|
1093
|
-
|
|
1094
|
-
test('분기 합이 뿌리를 넘으면 그 사실을 낸다 — 깎아 맞추지 않는다', () => {
|
|
1095
|
-
const t0 = Date.parse('2026-08-18T02:00:00.000Z')
|
|
1096
|
-
const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 90 }, { id: 'meter-b', kWh: 30 }]) as any],
|
|
1097
|
-
{ fromMs: t0, toMs: t0 + 900_000 }, { meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' } })
|
|
1098
|
-
const e = r.energy!
|
|
1099
|
-
assert.equal(Math.round(e.submeterExcessKWh!), 20, '120 − 100')
|
|
1100
|
-
assert.equal(e.byFeeder?.find(f => f.key === 'feeder-a')?.kWh, 90, '계량값은 그대로 남는다')
|
|
1101
|
-
})
|
|
1102
|
-
|
|
1103
|
-
test('계량 지도가 없으면 예전 배분 경로 그대로 — 거동을 바꾸지 않는다', () => {
|
|
1104
|
-
const t0 = Date.parse('2026-08-18T03:00:00.000Z')
|
|
1105
|
-
const r = foldKpi([windowFact(t0, [{ id: 'meter-a', kWh: 40 }]) as any],
|
|
1106
|
-
{ fromMs: t0, toMs: t0 + 900_000 }, { resourceFeeder: { 'press-1': 'feeder-a', 'press-2': 'feeder-b' } })
|
|
1107
|
-
const e = r.energy!
|
|
1108
|
-
/* 선언된 부하가 둘인데 가동 기록이 없으니 나눌 몫이 없다 — 총량은 그대로 남고 이유가 붙는다(예전 거동). */
|
|
1109
|
-
assert.equal(e.unattributedReason, 'no-weights')
|
|
1110
|
-
assert.equal(e.byFeeder, undefined, '계량 경로를 타지 않았다')
|
|
1111
|
-
})
|
|
1112
|
-
|
|
1113
|
-
test('피크를 만든 분기를 그 순간의 kW 로 답한다 — 전력량 큰 분기와 다를 수 있다', () => {
|
|
1114
|
-
const t0 = Date.parse('2026-08-18T06:00:00.000Z')
|
|
1115
|
-
const t1 = t0 + 900_000
|
|
1116
|
-
/* 두 구간: 앞 구간이 전력량은 크지만(a 가 꾸준히), 피크는 뒤 구간에서 b 가 만든다. */
|
|
1117
|
-
const w0 = {
|
|
1118
|
-
eventType: 'energy.demand.window',
|
|
1119
|
-
eventTime: new Date(t1).toISOString(),
|
|
1120
|
-
instanceId: 'ems-1',
|
|
1121
|
-
payload: { data: { startMs: t0, endMs: t1, meanKW: 400, maxKW: 500, samples: 30,
|
|
1122
|
-
points: [{ id: 'meter-a', kWh: 80 }, { id: 'meter-b', kWh: 20 }],
|
|
1123
|
-
pointsAtPeak: [{ id: 'meter-a', kW: 450 }, { id: 'meter-b', kW: 50 }] } }
|
|
1124
|
-
}
|
|
1125
|
-
const w1 = {
|
|
1126
|
-
eventType: 'energy.demand.window',
|
|
1127
|
-
eventTime: new Date(t1 + 900_000).toISOString(),
|
|
1128
|
-
instanceId: 'ems-1',
|
|
1129
|
-
payload: { data: { startMs: t1, endMs: t1 + 900_000, meanKW: 300, maxKW: 900, samples: 30,
|
|
1130
|
-
points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 35 }],
|
|
1131
|
-
pointsAtPeak: [{ id: 'meter-a', kW: 300 }, { id: 'meter-b', kW: 600 }] } }
|
|
1132
|
-
}
|
|
1133
|
-
const r = foldKpi([w0, w1] as any, { fromMs: t0, toMs: t1 + 900_000 }, {
|
|
1134
|
-
meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
|
|
1135
|
-
})
|
|
1136
|
-
const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
|
|
1137
|
-
assert.equal(byKey.get('feeder-a')?.kWh, 120, '전력량은 a 가 크다')
|
|
1138
|
-
/* 피크 값은 **최대수요를 낸 구간 하나**에서 온다 — 두 구간을 섞으면 다른 순간의 값을 더하는 것이다. */
|
|
1139
|
-
assert.equal(byKey.get('feeder-b')?.peakKW, 600, '피크를 만든 것은 b 다')
|
|
1140
|
-
assert.equal(byKey.get('feeder-a')?.peakKW, 300)
|
|
1141
|
-
})
|
|
1142
|
-
|
|
1143
|
-
test('피크 구간에 표본이 없던 분기는 피크 값을 만들지 않는다', () => {
|
|
1144
|
-
const t0 = Date.parse('2026-08-18T07:00:00.000Z')
|
|
1145
|
-
const w = {
|
|
1146
|
-
eventType: 'energy.demand.window',
|
|
1147
|
-
eventTime: new Date(t0 + 900_000).toISOString(),
|
|
1148
|
-
instanceId: 'ems-1',
|
|
1149
|
-
payload: { data: { startMs: t0, endMs: t0 + 900_000, meanKW: 200, maxKW: 300, samples: 10,
|
|
1150
|
-
points: [{ id: 'meter-a', kWh: 40 }, { id: 'meter-b', kWh: 10 }],
|
|
1151
|
-
pointsAtPeak: [{ id: 'meter-a', kW: 300 }] } }
|
|
1152
|
-
}
|
|
1153
|
-
const r = foldKpi([w] as any, { fromMs: t0, toMs: t0 + 900_000 }, {
|
|
1154
|
-
meterFeeder: { 'meter-a': 'feeder-a', 'meter-b': 'feeder-b' }
|
|
1155
|
-
})
|
|
1156
|
-
const byKey = new Map((r.energy?.byFeeder ?? []).map(f => [f.key, f]))
|
|
1157
|
-
assert.equal(byKey.get('feeder-a')?.peakKW, 300)
|
|
1158
|
-
assert.equal(byKey.get('feeder-b')?.peakKW, undefined, '그 순간 값을 모르면 만들지 않는다')
|
|
1159
|
-
})
|
|
1160
|
-
|
|
1161
|
-
/*
|
|
1162
|
-
* ── 완료로 읽지 못한 오더 상태 낱말을 **센다** (2026-08-23) ──────────────────
|
|
1163
|
-
*
|
|
1164
|
-
* 오더의 상태 어휘는 계약이 도메인에 넘긴 것이라(`picking`·`shipped`·`FINISHED`…) 이 폴드는 「완료로
|
|
1165
|
-
* 읽히는 표현」을 동의어 목록으로 받는다. 그 대가는 **목록에 없는 낱말이 알리지 않고 0 으로 세어진다**는 것이다.
|
|
1166
|
-
*
|
|
1167
|
-
* 실제로 그랬다: `finished` 가 빠져 있어 첫 실 시스템의 완료 오더가 전부 0 이었고, 사람이 화면을 보고
|
|
1168
|
-
* 의심해서야 드러났다. 목록을 넓히는 것으로는 다음 원본을 막지 못한다 — 그래서 **못 읽은 것을 센다.**
|
|
1169
|
-
*/
|
|
1170
|
-
test('완료로 센 낱말은 여기 오지 않는다 — 이 값은 세지 않은 것의 인구조사다', () => {
|
|
1171
|
-
const r = foldKpi(
|
|
1172
|
-
[order('o-1', 'completed', 10), order('o-2', 'CANCELLED', 20), order('o-3', 'cancelled', 30)],
|
|
1173
|
-
WINDOW
|
|
1174
|
-
)
|
|
1175
|
-
assert.equal(r.throughput.orders, 3, '대소문자를 가리지 않는다')
|
|
1176
|
-
assert.deepEqual(r.orderStatusNotCounted, {}, '완료로 센 낱말은 여기 오지 않는다')
|
|
1177
|
-
})
|
|
1178
|
-
|
|
1179
|
-
test('세지 않은 낱말이 건수와 함께 남는다 — 완료 0 의 원인이 곧 보이게', () => {
|
|
1180
|
-
const r = foldKpi(
|
|
1181
|
-
[order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 20), order('o-3', 'picking', 30)],
|
|
1182
|
-
WINDOW
|
|
1183
|
-
)
|
|
1184
|
-
assert.equal(r.throughput.orders, 0, '목록에 없는 낱말을 완료로 세지 않는다 — 추론하지 않는 것이 옳다')
|
|
1185
|
-
assert.deepEqual(
|
|
1186
|
-
r.orderStatusNotCounted,
|
|
1187
|
-
{ closed: 2, picking: 1 },
|
|
1188
|
-
'무엇을 몇 건 세지 않았는지 말한다 — picking 이 함께 오는 것은 정상이다(완료가 아니다)'
|
|
1189
|
-
)
|
|
1190
|
-
})
|
|
1191
|
-
|
|
1192
|
-
test('창 밖의 낱말은 세지 않는다 — 이 값도 창의 사실이다', () => {
|
|
1193
|
-
const r = foldKpi([order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 7200)], WINDOW)
|
|
1194
|
-
assert.deepEqual(r.orderStatusNotCounted, { closed: 1 }, '창 안의 것만 센다')
|
|
1195
|
-
})
|
|
1196
|
-
|
|
1197
|
-
test('상태를 말하지 않은 사건은 여기 오지 않는다 — 그것은 다른 결함이다', () => {
|
|
1198
|
-
/*
|
|
1199
|
-
* 빈 낱말을 여기 섞으면 이 값의 뜻이 흐려진다 — 상태가 없는 사건은 **다른 결함**이고(유입 문이 상태를
|
|
1200
|
-
* 필수로 받는다) 그 진단은 그 문의 몫이다.
|
|
1201
|
-
*/
|
|
1202
|
-
const r = foldKpi([{ eventType: 'order.status', eventTime: at(10), payload: { data: { orderId: 'o-1' } } }], WINDOW)
|
|
1203
|
-
assert.deepEqual(r.orderStatusNotCounted, {})
|
|
1204
|
-
})
|
|
1205
|
-
|
|
1206
|
-
/* ── 밖에서 마감되어 온 사용 구간과 청구서 (2026-08-29) ────────────────────
|
|
1207
|
-
*
|
|
1208
|
-
* ★ 계량 표본이 오지 않는 현장이 있다. 원본이 「이 구간에 X 썼다」를 이미 마감해 준다.
|
|
1209
|
-
* 그 값을 쓰지 않으면 그 현장의 화면은 영영 빈손이다.
|
|
1210
|
-
*
|
|
1211
|
-
* 두 회계를 **더하지 않는다** — 같은 전기를 두 번 센다.
|
|
1212
|
-
*/
|
|
1213
|
-
|
|
1214
|
-
const usagePeriod = (kWh: number, fromSec: number, toSec: number, extra: Record<string, unknown> = {}): KpiEvent => ({
|
|
1215
|
-
eventType: 'energy.usage.period',
|
|
1216
|
-
eventTime: at(toSec),
|
|
1217
|
-
payload: { data: { meterId: 'incoming-1', from: at(fromSec), to: at(toSec), kWh, ...extra } }
|
|
1218
|
-
})
|
|
1219
|
-
|
|
1220
|
-
test('★ 계량 표본이 없어도 사용 구간으로 답한다 — 근거를 밝힌다', () => {
|
|
1221
|
-
const k = foldKpi([usagePeriod(474.48, 0, 1800, { basis: 'provider' }), usagePeriod(471.6, 1800, 3600, { basis: 'provider' })], WINDOW)
|
|
1222
|
-
assert.equal(k.energy?.kWh, 474.48 + 471.6)
|
|
1223
|
-
assert.equal(k.energy?.kWhBasis, 'usage-period')
|
|
1224
|
-
assert.equal(k.energy?.usage?.counted, 2)
|
|
1225
|
-
assert.deepEqual(k.energy?.usage?.byBasis, [{ basis: 'provider', counted: 2 }])
|
|
1226
|
-
})
|
|
1227
|
-
|
|
1228
|
-
test('창에 걸친 구간은 세지 않는다 — 반만 세면 합이 창을 넘는다', () => {
|
|
1229
|
-
const k = foldKpi([usagePeriod(100, -1800, 1800)], WINDOW)
|
|
1230
|
-
assert.equal(k.energy?.usage, undefined)
|
|
1231
|
-
})
|
|
1232
|
-
|
|
1233
|
-
test('★ 구간마다 제 단가로 매긴다 — 단일 단가로 접지 않는다', () => {
|
|
1234
|
-
/* 새벽 87.3 · 낮 222.3 — 하나로 접으면 「피크를 깎아 얼마를 아끼나」가 사라진다. */
|
|
1235
|
-
const k = foldKpi(
|
|
1236
|
-
[
|
|
1237
|
-
usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW', basis: 'provider' }),
|
|
1238
|
-
usagePeriod(100, 1800, 3600, { unitPrice: 222.3, currency: 'KRW', basis: 'provider' })
|
|
1239
|
-
],
|
|
1240
|
-
WINDOW,
|
|
1241
|
-
{ tariff: { energyChargePerKWh: 140.2, currency: 'KRW' } } as any
|
|
1242
|
-
)
|
|
1243
|
-
assert.equal(k.energy?.usage?.charge, 100 * 87.3 + 100 * 222.3)
|
|
1244
|
-
assert.equal((k.energy?.cost as any)?.energyCharge, 100 * 87.3 + 100 * 222.3, '구간별 단가가 이긴다')
|
|
1245
|
-
assert.ok(!(k.energy?.cost as any)?.notModeled.includes('tou'))
|
|
1246
|
-
})
|
|
1247
|
-
|
|
1248
|
-
test('일부만 단가를 실었으면 요금을 만들지 않고 그 수를 낸다', () => {
|
|
1249
|
-
const k = foldKpi(
|
|
1250
|
-
[usagePeriod(100, 0, 1800, { unitPrice: 87.3, currency: 'KRW' }), usagePeriod(100, 1800, 3600)],
|
|
1251
|
-
WINDOW
|
|
1252
|
-
)
|
|
1253
|
-
assert.equal(k.energy?.usage?.charge, undefined, '있는 것만 더하면 그 합이 창의 요금인 척한다')
|
|
1254
|
-
assert.equal(k.energy?.usage?.unpriced, 1)
|
|
1255
|
-
})
|
|
1256
|
-
|
|
1257
|
-
test('★ 청구서는 사실로 나란히 둔다 — 창이 다르면 견주지 않는다', () => {
|
|
1258
|
-
const bill: KpiEvent = {
|
|
1259
|
-
eventType: 'energy.bill',
|
|
1260
|
-
eventTime: at(3600),
|
|
1261
|
-
payload: { data: { from: at(600), to: at(3000), total: 1_000_000, currency: 'KRW', billingDemandKW: 1650 } }
|
|
1262
|
-
}
|
|
1263
|
-
const k = foldKpi([bill], WINDOW)
|
|
1264
|
-
assert.equal(k.energy?.bill?.total, 1_000_000)
|
|
1265
|
-
assert.equal(k.energy?.bill?.billingDemandKW, 1650)
|
|
1266
|
-
assert.equal(k.energy?.bill?.matchesWindow, false, '한 달 청구서를 하루 창의 값과 빼면 아무것도 뜻하지 않는다')
|
|
1267
|
-
assert.equal(k.energy?.bill?.ourTotalMinusBilled, undefined)
|
|
1268
|
-
})
|
|
1269
|
-
|
|
1270
|
-
test('★ 창이 청구 주기와 같으면 우리 계산과의 차이를 낸다 — 0 이 아니면 우리가 틀렸다', () => {
|
|
1271
|
-
const bill: KpiEvent = {
|
|
1272
|
-
eventType: 'energy.bill',
|
|
1273
|
-
eventTime: at(3600),
|
|
1274
|
-
payload: { data: { from: at(0), to: at(3600), total: 100_000, currency: 'KRW' } }
|
|
1275
|
-
}
|
|
1276
|
-
const k = foldKpi([bill, usagePeriod(1_000, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
|
|
1277
|
-
tariff: { energyChargePerKWh: 87.3, currency: 'KRW' }
|
|
1278
|
-
} as any)
|
|
1279
|
-
assert.equal(k.energy?.bill?.matchesWindow, true)
|
|
1280
|
-
assert.equal(k.energy?.bill?.diff?.total, 1_000 * 87.3 - 100_000)
|
|
1281
|
-
})
|
|
1282
|
-
|
|
1283
|
-
test('★ 총액이 없는 청구서도 항목별로 견준다 — 실제 첫 청구서에 기본요금만 있었다', () => {
|
|
1284
|
-
const bill: KpiEvent = {
|
|
1285
|
-
eventType: 'energy.bill',
|
|
1286
|
-
eventTime: at(3600),
|
|
1287
|
-
payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW', billingDemandKW: 1650 } }
|
|
1288
|
-
}
|
|
1289
|
-
const k = foldKpi([bill], WINDOW, {
|
|
1290
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1_650, currency: 'KRW' }
|
|
1291
|
-
} as any)
|
|
1292
|
-
assert.equal(k.energy?.bill?.diff?.demandCharge, 0, '요금적용전력으로 매기면 청구서와 같다')
|
|
1293
|
-
assert.equal(k.energy?.bill?.diff?.total, undefined, '한쪽에 없는 항목은 견주지 않는다')
|
|
1294
|
-
})
|
|
1295
|
-
|
|
1296
|
-
test('요금적용전력을 선언하지 않으면 기본요금이 어긋나는 것이 드러난다', () => {
|
|
1297
|
-
const bill: KpiEvent = {
|
|
1298
|
-
eventType: 'energy.bill',
|
|
1299
|
-
eventTime: at(3600),
|
|
1300
|
-
payload: { data: { from: at(0), to: at(3600), demandCharge: 13_728_000, currency: 'KRW' } }
|
|
1301
|
-
}
|
|
1302
|
-
/* 선언이 없으면 커널은 창의 피크로 조건부 값을 낸다 — 그 차이가 그대로 나온다. */
|
|
1303
|
-
const peakWindow: KpiEvent = {
|
|
1304
|
-
eventType: 'energy.demand.window',
|
|
1305
|
-
eventTime: at(1800),
|
|
1306
|
-
payload: { data: { startMs: T0 + 900_000, endMs: T0 + 1_800_000, maxKW: 600, meanKW: 600, samples: 4 } }
|
|
1307
|
-
}
|
|
1308
|
-
const k = foldKpi([bill, peakWindow], WINDOW, {
|
|
1309
|
-
tariff: { demandChargePerKW: 8_320, currency: 'KRW' }
|
|
1310
|
-
} as any)
|
|
1311
|
-
const d = k.energy?.bill?.diff?.demandCharge
|
|
1312
|
-
assert.ok(d !== undefined && d !== 0, `어긋남이 드러나야 한다: ${d}`)
|
|
1313
|
-
})
|
|
1314
|
-
|
|
1315
|
-
/* ── 요금적용전력은 청구서가 먼저다 (2026-08-29) ───────────────────────────
|
|
1316
|
-
*
|
|
1317
|
-
* ★ 그 값은 최근 몇 달의 최고 수요로 **매달 다시 정해진다.** 선언에 박아 두면 사람이 매달
|
|
1318
|
-
* 갱신해야 하고, 안 하면 낡은 값으로 기본요금이 조용히 틀린다.
|
|
1319
|
-
*/
|
|
1320
|
-
|
|
1321
|
-
const billEvent = (fromSec: number, toSec: number, data: Record<string, unknown>): KpiEvent => ({
|
|
1322
|
-
eventType: 'energy.bill',
|
|
1323
|
-
eventTime: at(toSec),
|
|
1324
|
-
payload: { data: { from: at(fromSec), to: at(toSec), currency: 'KRW', ...data } }
|
|
1325
|
-
})
|
|
1326
|
-
|
|
1327
|
-
test('★ 창을 덮는 청구서가 있으면 그 값으로 매긴다', () => {
|
|
1328
|
-
const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
|
|
1329
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' }
|
|
1330
|
-
} as any)
|
|
1331
|
-
assert.equal(k.energy?.billingDemand?.kW, 1650)
|
|
1332
|
-
assert.equal(k.energy?.billingDemand?.from, 'bill')
|
|
1333
|
-
assert.equal((k.energy?.cost as any)?.demandCharge, 1650 * 8_320, '선언 1200 이 아니라 청구서 1650 으로 매긴다')
|
|
1334
|
-
})
|
|
1335
|
-
|
|
1336
|
-
test('★ 선언이 청구서와 다르면 낡았다고 말한다 — 감추면 아무도 다시 읽지 않는다', () => {
|
|
1337
|
-
const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
|
|
1338
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1200, currency: 'KRW' }
|
|
1339
|
-
} as any)
|
|
1340
|
-
assert.equal(k.energy?.billingDemand?.declaredKW, 1200)
|
|
1341
|
-
assert.equal(k.energy?.billingDemand?.stale, true)
|
|
1342
|
-
})
|
|
1343
|
-
|
|
1344
|
-
test('선언과 청구서가 같으면 낡았다고 하지 않는다', () => {
|
|
1345
|
-
const k = foldKpi([billEvent(0, 3600, { demandCharge: 1, billingDemandKW: 1650 })], WINDOW, {
|
|
1346
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
|
|
1347
|
-
} as any)
|
|
1348
|
-
assert.equal(k.energy?.billingDemand?.stale, undefined)
|
|
1349
|
-
})
|
|
1350
|
-
|
|
1351
|
-
test('★ 창에 청구서가 없으면 마지막 청구서의 값을 이어 쓴다 — 요금 제도가 그렇게 군다', () => {
|
|
1352
|
-
const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
|
|
1353
|
-
tariff: { demandChargePerKW: 8_320, currency: 'KRW' },
|
|
1354
|
-
lastBill: { to: at(-86400), billingDemandKW: 1650 }
|
|
1355
|
-
} as any)
|
|
1356
|
-
assert.equal(k.energy?.billingDemand?.kW, 1650)
|
|
1357
|
-
assert.equal(k.energy?.billingDemand?.from, 'carried-forward')
|
|
1358
|
-
assert.equal((k.energy?.cost as any)?.demandBasis, 'billing-demand')
|
|
1359
|
-
})
|
|
1360
|
-
|
|
1361
|
-
test('청구가 오지 않는 현장은 선언을 쓴다', () => {
|
|
1362
|
-
const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
|
|
1363
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 900, currency: 'KRW' }
|
|
1364
|
-
} as any)
|
|
1365
|
-
assert.equal(k.energy?.billingDemand?.from, 'declaration')
|
|
1366
|
-
assert.equal(k.energy?.billingDemand?.kW, 900)
|
|
1367
|
-
assert.equal(k.energy?.billingDemand?.stale, undefined)
|
|
1368
|
-
})
|
|
1369
|
-
|
|
1370
|
-
test('셋 다 없으면 이 칸을 만들지 않는다 — 빈 값을 지어내지 않는다', () => {
|
|
1371
|
-
const k = foldKpi([usagePeriod(100, 0, 3600, { unitPrice: 87.3, currency: 'KRW' })], WINDOW, {
|
|
1372
|
-
tariff: { demandChargePerKW: 8_320, currency: 'KRW' }
|
|
1373
|
-
} as any)
|
|
1374
|
-
assert.equal(k.energy?.billingDemand, undefined)
|
|
1375
|
-
})
|
|
1376
|
-
|
|
1377
|
-
test('★ 관측한 최고가 청구 기준을 넘으면 얼마나 넘었는지와 언제까지의 값인지를 낸다', () => {
|
|
1378
|
-
/* 기본요금은 이 값으로 다시 정해진다 — 지금 줄여도 되돌아가지 않는다. */
|
|
1379
|
-
const k = foldKpi([usagePeriod(400, 0, 1800, { maxKW: 1200 }), usagePeriod(400, 1800, 3600, { maxKW: 1700 })], WINDOW, {
|
|
1380
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
|
|
1381
|
-
} as any)
|
|
1382
|
-
const e = k.energy?.billingDemand?.exceeded
|
|
1383
|
-
assert.equal(e?.peakKW, 1700)
|
|
1384
|
-
assert.equal(e?.overKW, 50)
|
|
1385
|
-
assert.equal(e?.observedTo, at(3600), '이 최고값이 언제까지의 것인지')
|
|
1386
|
-
})
|
|
1387
|
-
|
|
1388
|
-
test('넘지 않으면 그 칸을 만들지 않는다', () => {
|
|
1389
|
-
const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1200 })], WINDOW, {
|
|
1390
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
|
|
1391
|
-
} as any)
|
|
1392
|
-
assert.equal(k.energy?.billingDemand?.exceeded, undefined)
|
|
1393
|
-
})
|
|
1394
|
-
|
|
1395
|
-
test('청구서가 말한 기준으로 넘었는지 본다 — 선언이 아니다', () => {
|
|
1396
|
-
const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1300 })], WINDOW, {
|
|
1397
|
-
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' },
|
|
1398
|
-
lastBill: { to: at(-86400), billingDemandKW: 1200 }
|
|
1399
|
-
} as any)
|
|
1400
|
-
assert.equal(k.energy?.billingDemand?.kW, 1200)
|
|
1401
|
-
assert.equal(k.energy?.billingDemand?.exceeded?.overKW, 100)
|
|
1402
|
-
})
|
|
1403
|
-
|
|
1404
|
-
/* ── 이용률 (2026-08-30) ───────────────────────────────────────────────────
|
|
1405
|
-
*
|
|
1406
|
-
* ★ 분모가 어느 정격인지가 값의 뜻을 바꾼다. 붙어 있는 원본은 직류로 나눠 2.2% 를 보여 주는데,
|
|
1407
|
-
* 교류로 나누면 4.08% 다. 같은 발전소를 두 화면에서 보는 사람이 두 배 다른 수를 본다.
|
|
1408
|
-
*
|
|
1409
|
-
* 그래서 커널은 교류를 먼저 쓰되(계통으로 나가는 양은 교류 정격을 넘을 수 없다) **어느 쪽으로
|
|
1410
|
-
* 나눴는지를 값과 함께 낸다.**
|
|
1411
|
-
*/
|
|
1412
|
-
|
|
1413
|
-
const generationPeriod = (kWh: number, fromSec: number, toSec: number): KpiEvent => ({
|
|
1414
|
-
eventType: 'energy.generation.period',
|
|
1415
|
-
eventTime: at(toSec),
|
|
1416
|
-
payload: {
|
|
1417
|
-
data: { equipmentId: 'inv-1', kWh, periodStart: at(fromSec), periodEnd: at(toSec), observedFrom: at(fromSec), observedTo: at(toSec) }
|
|
1418
|
-
}
|
|
1419
|
-
})
|
|
1420
|
-
|
|
1421
|
-
test('★ 이용률은 만든 양 ÷ (정격 × 그 기간의 시간)', () => {
|
|
1422
|
-
/* 한 시간에 63.8kWh · 정격 127.6kW → 50% */
|
|
1423
|
-
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
|
|
1424
|
-
generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
|
|
1425
|
-
} as any)
|
|
1426
|
-
const cf = k.energy?.generated?.capacityFactor
|
|
1427
|
-
assert.equal(cf?.value, 0.5)
|
|
1428
|
-
assert.equal(cf?.ratedKW, 127.6)
|
|
1429
|
-
assert.equal(cf?.hours, 1)
|
|
1430
|
-
})
|
|
1431
|
-
|
|
1432
|
-
test('★ 어느 정격으로 나눴는지 함께 낸다 — 두 화면의 수가 달라도 이유를 말할 수 있게', () => {
|
|
1433
|
-
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
|
|
1434
|
-
generationRated: { kW: 234.35, basis: 'dc', from: 'location' }
|
|
1435
|
-
} as any)
|
|
1436
|
-
assert.equal(k.energy?.generated?.capacityFactor?.basis, 'dc')
|
|
1437
|
-
assert.equal(k.energy?.generated?.capacityFactor?.from, 'location')
|
|
1438
|
-
})
|
|
1439
|
-
|
|
1440
|
-
test('★ 창 길이가 아니라 센 기간들의 길이로 나눈다', () => {
|
|
1441
|
-
/*
|
|
1442
|
-
* 한 시간짜리 기간 하나만 센 창에서 창 길이(1시간)로 나누는 것과 같지만, 기간이 창보다 짧으면
|
|
1443
|
-
* 달라진다. 창 길이로 나누면 그 기간이 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
1444
|
-
*/
|
|
1445
|
-
const k = foldKpi([generationPeriod(63.8, 0, 1800)], WINDOW, {
|
|
1446
|
-
generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
|
|
1447
|
-
} as any)
|
|
1448
|
-
assert.equal(k.energy?.generated?.capacityFactor?.hours, 0.5)
|
|
1449
|
-
assert.equal(k.energy?.generated?.capacityFactor?.value, 1, '30분에 정격만큼 만들었으면 100%다')
|
|
1450
|
-
})
|
|
1451
|
-
|
|
1452
|
-
test('정격이 없으면 이용률을 내지 않는다 — 0 이나 짐작을 만들지 않는다', () => {
|
|
1453
|
-
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW)
|
|
1454
|
-
assert.equal(k.energy?.generated?.capacityFactor, undefined)
|
|
1455
|
-
assert.equal(k.energy?.generated?.kWh, 63.8, '발전량 자체는 그대로 낸다')
|
|
1456
|
-
})
|