Files
pbl02/uploads/QLT04/QLT04_solution.ipynb
T
2026-10-08 17:15:05 +09:00

263 KiB

🎯 [QLT04] Data 기반 외주정비 품질검사 통합 솔루션

Google Colab 실습 환경 (품질·규격·검사 / Level 2)

Open In Colab


📌 1. 프로젝트 개요 및 배경

항목 내용
과정 분류 품질·규격·검사 (Level 2)
학습 소요 시간 10개 미션 (5일 / 총 27.0시간)
환경 사양 Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn

📖 프로젝트 목표

외주정비는 정비 대기, 외주 검사 대기, 재작업, 규격·제원 불일치가 겹치면서 장비 가동률을 떨어뜨리지만 요인별 영향이 한눈에 보이지 않아 어디에 정비 자원을 먼저 투입할지 정하기 어렵다. 본 프로젝트는 미 국방부 외주정비 품질보증 사례의 문제의식(규격 정합성·검사 병목·하자 연결)을 교육용 가상 자료로 재구성해, 정비 대기 요인 분류, 저가동 요인 분석, 재배치 시뮬레이션, 추세 대시보드, 사람 검토 워크플로우까지 프로토타입으로 구현하는 과제이다. 가상 장비는 유형 A·B·C와 가상 부대로만 구분하며 실제 장비·부대 자료는 쓰지 않는다. 최종 목표는 가동률 저하 요인과 외주 품질검사 병목을 근거와 함께 보여 주되, 정비 우선순위와 자원 투입 결정은 정비관·품질검사관·군수참모가 하도록 지원하는 도구를 완성하는 것이다.

⚠️ [교육용 가상 데이터셋 원칙]

  • 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 코드 내 난수 기반 가상 데이터로 생성됩니다.
  • 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.

In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn

import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False

print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")

🎯 미션 01. 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의

소요시간: 약 1.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

전술 장비(전차, 자주포) 일일 가동률 및 정비 대기 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 부대별 전술 장비(유형 A·B·C로 추상화한 가상 장비)의 일일 가동률과 정비 대기 자료의 구조와 입출력 명세를 정의하고, 외주정비 입고부터 품질검사·가동 복귀까지의 현행(AS-IS) 업무를 단계로 나누어 병목을 찾는다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률을 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: docs/qlt04_as_is_to_be_spec.md 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성

미션 PASS 조건

  • 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출

  • 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료

  • 검증 방법: 실무 프로세스와의 일치성 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""가상 장비 가동률·정비 대기 자료(필드 16개)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 정비 입고부터 외주 정비 검사·가동 복귀까지의 현행(AS-IS) 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다."""
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# 1) 스키마 정의: 장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률
SCHEMA = {
    "equip_id": {"type": str, "pattern": r"^E-\d{4}$", "desc": "장비 번호"},
    "unit": {"type": str, "enum": UNITS, "desc": "소속 부대(가상)"},
    "etype": {"type": str, "enum": ETYPES, "desc": "장비 유형(추상 구분)"},
    "age_years": {"type": (int, float), "min": 0, "max": 40, "desc": "장비 연령(년)"},
    "op_hours": {"type": (int, float), "min": 0, "max": 2160, "desc": "최근 90일 가동 시간"},
    "maint_orders": {"type": int, "min": 0, "max": 30, "desc": "정비 요청 건수"},
    "parts_wait_days": {"type": (int, float), "min": 0, "max": 90, "desc": "부품 대기 일수"},
    "labor_wait_days": {"type": (int, float), "min": 0, "max": 90, "desc": "정비병 부족 대기 일수"},
    "outsource_days": {"type": (int, float), "min": 0, "max": 90, "desc": "외주 정비 일수"},
    "inspect_wait_days": {"type": (int, float), "min": 0, "max": 90, "desc": "외주 검사 대기 일수"},
    "rework_count": {"type": int, "min": 0, "max": 30, "desc": "재작업(검사 불합격) 건수"},
    "spec_mismatch": {"type": int, "min": 0, "max": 20, "desc": "규격·제원 불일치 건수"},
    "defect_reports": {"type": int, "min": 0, "max": 50, "desc": "하자·불만 접수 건수"},
    "parts_stock_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "부품 재고 가용률(%)"},
    "mechanic_fill_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "정비병 충원율(%)"},
    "avail_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "가동률(%)"},
}


def check(name, v, r):
    if v is None or (isinstance(v, float) and np.isnan(v)):
        return [f"{name}: 필수 값 누락"]
    if not isinstance(v, r["type"]) or isinstance(v, bool):
        return [f"{name}: 자료형 오류({type(v).__name__})"]
    errs = []
    if "enum" in r and v not in r["enum"]:
        errs.append(f"{name}: 허용되지 않은 값 '{v}'")
    if "min" in r and v < r["min"]:
        errs.append(f"{name}: 최소값 {r['min']} 미만")
    if "max" in r and v > r["max"]:
        errs.append(f"{name}: 최대값 {r['max']} 초과")
    if "pattern" in r and not re.match(r["pattern"], v):
        errs.append(f"{name}: 형식 불일치 '{v}'")
    return errs


def validate(rec):
    errs = [f"{k}: 필드 누락" for k in SCHEMA if k not in rec]
    for k, r in SCHEMA.items():
        if k in rec:
            errs += check(k, rec[k], r)
    if not errs and rec["rework_count"] > rec["maint_orders"]:                 # 필드 간 논리 규칙
        errs.append("rework_count: 정비 요청 건수보다 재작업 건수가 많음")
    if not errs and rec["maint_orders"] == 0 and (rec["parts_wait_days"] + rec["labor_wait_days"] + rec["outsource_days"] + rec["inspect_wait_days"]) > 0:
        errs.append("maint_orders: 정비 요청이 없는데 대기 일수가 있음")
    if not errs and rec["parts_wait_days"] + rec["labor_wait_days"] + rec["outsource_days"] + rec["inspect_wait_days"] > 90 + 5:
        errs.append("wait_days: 대기 일수 합계가 관측 기간(90일)을 초과")
    return errs


def to_record(row):
    return {k: (row[k].item() if hasattr(row[k], "item") else row[k]) for k in SCHEMA}


fleet = make_fleet(100, seed=7)
records = [to_record(r) for _, r in fleet.iterrows()]
ok = sum(not validate(r) for r in records)
print(f"[스키마 검증] 장비 {len(records)}대 중 통과 {ok}대, 오류 {len(records) - ok}대 / 필드 {len(SCHEMA)}개")
print(f"평균 가동률 {fleet['avail_pct'].mean():.1f}%, 가동률 70% 미만 {int((fleet['avail_pct'] < 70).sum())}대, 외주 정비 경험 장비 {int((fleet['outsource_days'] > 0).sum())}대, 규격·제원 불일치 1건 이상 {int((fleet['spec_mismatch'] > 0).sum())}대")

# 2) 오류 주입 시험: 형식·허용값·범위·자료형·누락·논리 규칙 위반
bad = dict(records[2], equip_id="E12", unit="미상부대", age_years=-3, op_hours="많음")
bad2 = dict(records[5], maint_orders=2, rework_count=5)
bad3 = dict(records[0], maint_orders=0, parts_wait_days=5)
bad4 = {k: v for k, v in records[7].items() if k != "avail_pct"}
bad5 = dict(records[9], avail_pct=None)
print("\n[오류 주입 결과]")
for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4) + validate(bad5):
    print(" -", e)

# 3) AS-IS 업무 단계 분해: [교육용가상자료] 외주 정비 1건의 단계별 소요일(작업 + 대기)
rng = np.random.default_rng(3)
STEPS = [("정비 요청 접수·고장 접수", 0.5, 1), ("고장 진단·정비 계획 수립", 1, 1), ("부품 청구·조달", 1, 6), ("정비병 배정", 0.5, 3),
         ("부대 정비 수행", 2, 1), ("외주 정비 의뢰·입고", 1, 4), ("외주 정비 수행", 6, 2), ("외주 검사 대기", 0, 5),
         ("외주 정비 품질검사(규격·제원 대조)", 1, 1), ("불합격 시 재작업·하자 조치", 3, 3), ("출고 승인·부대 인수", 0.5, 2), ("가동 복귀 확인·이력 기록", 0.5, 1)]
TOBE = {2: ("부품 소요 예측·사전 청구(미션 4)", 0.4), 3: ("정비병 부하 기반 배정 추천(미션 5)", 0.5), 7: ("검사 일정 자동 조율(미션 5)", 0.4),
        8: ("규격·제원 자동 대조 사전 점검(미션 3)", 0.5), 9: ("재작업 원인 자동 분류(미션 2·4)", 0.6), 11: ("이력 자동 기록(미션 2)", 0.3)}
rows = []
for i, (name, work, wait) in enumerate(STEPS):
    w = rng.gamma(4, max(work, 0.01) / 4, 30); q = rng.gamma(2, wait / 2, 30)
    f = TOBE.get(i, ("유지(사람 판단)", 1.0))
    rows.append({"단계": f"{i + 1:02d}. {name}", "작업(일)": w.mean() if work else 0.0, "대기(일)": q.mean(), "AS-IS(일)": (w.mean() if work else 0.0) + q.mean(),
                 "TO-BE 방식": f[0], "TO-BE(일)": ((w.mean() if work else 0.0) + q.mean()) * f[1]})
proc = pd.DataFrame(rows)
proc["병목"] = np.where(proc["대기(일)"] >= proc["대기(일)"].sort_values().iloc[-4], "병목", "")           # 대기일 상위 4개 단계를 병목으로 본다
print("\n[AS-IS / TO-BE 업무 단계 비교]")
print(proc.round(1).to_string(index=False))
n_bn = int((proc["병목"] == "병목").sum())
asis, tobe = proc["AS-IS(일)"].sum(), proc["TO-BE(일)"].sum()
print(f"\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})")
print(f"외주 정비 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.")

# 4) 입출력 데이터 명세 요약
print("\n[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력")
print("[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)")

plt.figure(figsize=(9, 5))
y = np.arange(len(proc))
plt.barh(y - 0.2, proc["AS-IS(일)"], 0.4, label="AS-IS", color="tab:gray")
plt.barh(y + 0.2, proc["TO-BE(일)"], 0.4, label="TO-BE", color="tab:blue")
plt.yticks(y, [f"step {i + 1}{' *' if b else ''}" for i, b in enumerate(proc["병목"])])
plt.gca().invert_yaxis(); plt.xlabel("days per case"); plt.legend()
plt.title("Outsourced maintenance flow: AS-IS vs TO-BE (* = bottleneck, synthetic)")
plt.tight_layout(); plt.show()
💡 [참고] 미션 01 예상 실행 결과 (Output)
[스키마 검증] 장비 100대 중 통과 100대, 오류 0대 / 필드 16개
평균 가동률 79.3%, 가동률 70% 미만 25대, 외주 정비 경험 장비 50대, 규격·제원 불일치 1건 이상 49대

[오류 주입 결과]
 - equip_id: 형식 불일치 'E12'
 - unit: 허용되지 않은 값 '미상부대'
 - age_years: 최소값 0 미만
 - op_hours: 자료형 오류(str)
 - rework_count: 정비 요청 건수보다 재작업 건수가 많음
 - maint_orders: 정비 요청이 없는데 대기 일수가 있음
 - avail_pct: 필드 누락
 - avail_pct: 필수 값 누락

[AS-IS / TO-BE 업무 단계 비교]
                      단계  작업(일)  대기(일)  AS-IS(일)                TO-BE 방식  TO-BE(일) 병목
      01. 정비 요청 접수·고장 접수    0.5    1.0       1.5               유지(사람 판단)       1.5   
      02. 고장 진단·정비 계획 수립    1.3    0.9       2.2               유지(사람 판단)       2.2   
            03. 부품 청구·조달    1.0    6.6       7.7    부품 소요 예측·사전 청구(미션 4)       3.1 병목
              04. 정비병 배정    0.6    3.0       3.6   정비병 부하 기반 배정 추천(미션 5)       1.8   
            05. 부대 정비 수행    2.4    1.1       3.5               유지(사람 판단)       3.5   
         06. 외주 정비 의뢰·입고    1.0    4.1       5.1               유지(사람 판단)       5.1 병목
            07. 외주 정비 수행    5.3    1.9       7.2               유지(사람 판단)       7.2   
            08. 외주 검사 대기    0.0    4.9       4.9       검사 일정 자동 조율(미션 5)       2.0 병목
09. 외주 정비 품질검사(규격·제원 대조)    1.1    1.0       2.1 규격·제원 자동 대조 사전 점검(미션 3)       1.0   
     10. 불합격 시 재작업·하자 조치    3.1    3.6       6.7    재작업 원인 자동 분류(미션 2·4)       4.0 병목
         11. 출고 승인·부대 인수    0.5    2.3       2.7               유지(사람 판단)       2.7   
      12. 가동 복귀 확인·이력 기록    0.5    1.0       1.6          이력 자동 기록(미션 2)       0.5   

업무 단계 12개(목표 10개 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)
외주 정비 1건 소요: AS-IS 48.9일 -> TO-BE 34.7일 (29% 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.

[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력
[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)

🎯 미션 02. 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현

소요시간: 약 2.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

정비 대기 시간(부품 대기, 인력 부족) 요인 분류 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 서식이 다른 정비 대기 기록(CSV, 엑셀 내보내기, JSON, 정비일지 문장)을 하나의 표준 스키마로 바꾸고 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 열 이름 별칭 사전으로 CSV, 탭 구분(엑셀 내보내기), JSON 세 형식을 표준 스키마로 바꾸고, 대기 기간(일·주·개월·시간)과 날짜·부대명 표기를 통일 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/connectors/qlt04_data_loader.py 및 스키마 정의서 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 국방 서식/양식 변경에 대한 유연한 확장성

미션 PASS 조건

  • 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%

  • 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성

  • 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""서식이 서로 다른 가상 정비 대기 기록(CSV, 엑셀 내보내기 TSV, JSON, 정비일지 문장)을 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 대기 일수·부대명·날짜 표기를 통일하며 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다."""
import csv
import io
import json
import re
from datetime import date
import pandas as pd

# ---------------------------------------------------------------------------
# [교육용가상자료] 서로 다른 형식의 원시 기록 (실제 부대·장비 아님). 표기가 일부러 제각각이다.
# ---------------------------------------------------------------------------
RAW_CSV = """장비,부대,대기사유,대기기간,접수일
E-0001,가상A부대,부품 미입고,5일,2026.03.02
E-0002,가상 B 부대,정비병 부족(휴가·교육),2주,2026-3-15
E-0003,C부대,외주업체 입고 대기,48시간,20260110
E-0004,가상D부대,외주 검사 대기,3일,26/02/27
E-0005,가상E부대,부품 청구 지연,1개월,2025.12.30
E-0006,가상F부대,재작업(검사 불합격),4일,2026-01-05
"""
RAW_TSV = "장비번호\t소속\t사유\t지연일수\t접수\n" \
          "E-0007\t가상A부대\t정비인력 부족\t6\t2026.02.11\n" \
          "E-0008\t가상B부대\t부품 입고 지연\t10\t2026.01.20\n" \
          "E-0009\t가상C부대\t외주 정비 수행 중\t1주\t2026/03/05\n" \
          "E-0010\tD부대\t규격 불일치로 재검사\t3\t2025.11.03\n" \
          "E-0001\t가상A부대\t부품 미입고\t5일\t2026.03.02\n"
RAW_JSON = json.dumps({"result": "OK", "items": [
    {"equip": "E-0011", "unit": {"name": "가상E부대"}, "reason": "예비품 청구 후 입고 대기", "wait": {"value": 2, "unit": "week"}, "received": "2026-02-01"},
    {"equip": "E-0012", "unit": {"name": "가상F부대"}, "reason": "정비병 교육 파견", "wait": {"value": 4, "unit": "day"}, "received": "2026-01-09"},
    {"equip": "E-0013", "unit": {"name": "가상A부대"}, "reason": "외주 검사 일정 대기", "wait": {"value": 72, "unit": "hour"}, "received": "2026-03-20"}]},
    ensure_ascii=False)
LOGS = [
    "[정비일지] 2026-03-04 E-0021(가상A부대) 부품 입고 지연으로 6일 대기.",
    "가상B부대 E-0022 : 정비병 인력 부족, 3일간 착수 못함 (2026.03.05)",
    "E-0023 / 외주 정비 입고 대기 2주 / 가상C부대 / 26.03.06",
    "3월 7일자 가상D부대 E-0024 규격 불일치로 재검사 5일",
    "가상E부대 E-0025 원인 불명 대기 4일(2026-03-08)",
]
# 정답(분류 정확도 확인용): 기록 순서대로
TRUTH = {"E-0001": "부품 대기", "E-0002": "정비병 부족", "E-0003": "외주 정비", "E-0004": "검사·재작업", "E-0005": "부품 대기", "E-0006": "검사·재작업",
         "E-0007": "정비병 부족", "E-0008": "부품 대기", "E-0009": "외주 정비", "E-0010": "검사·재작업", "E-0011": "부품 대기", "E-0012": "정비병 부족",
         "E-0013": "검사·재작업", "E-0021": "부품 대기", "E-0022": "정비병 부족", "E-0023": "외주 정비", "E-0024": "검사·재작업", "E-0025": "기타"}

# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고친다) ----
COL_ALIAS = {"equip": ["장비", "장비번호", "equip"], "unit": ["부대", "소속", "unit"], "reason": ["대기사유", "사유", "reason"],
             "wait": ["대기기간", "지연일수", "wait"], "received": ["접수일", "접수", "received"]}
UNIT_DAYS = {"일": 1, "일간": 1, "day": 1, "주": 7, "week": 7, "개월": 30, "month": 30, "시간": 1 / 24, "hour": 1 / 24}
# 분류 규칙: 위에서부터 먼저 걸리는 사유로 분류(검사·재작업 -> 외주 정비 -> 정비병 -> 부품)
CAUSE_KW = [("검사·재작업", ["검사", "재작업", "불합격", "규격 불일치"]), ("외주 정비", ["외주", "업체 입고"]),
            ("정비병 부족", ["정비병", "정비인력", "인력 부족", "휴가", "교육"]), ("부품 대기", ["부품", "미입고", "청구", "입고 지연"])]
DEFAULT_YEAR = 2026


def norm_unit(s):
    m = re.search(r"([A-F])\s*부대", str(s))
    if not m:
        raise ValueError(f"부대명 해석 불가: {s}")
    return f"가상{m.group(1)}부대"


def norm_wait(v):
    """대기 기간을 일 단위 숫자로 통일(일·주·개월·시간)."""
    if isinstance(v, dict):
        return round(v["value"] * UNIT_DAYS[v["unit"]], 1)
    m = re.match(r"^\s*(\d+)\s*(일간|일|주|개월|시간)?\s*$", str(v))
    if not m:
        raise ValueError(f"대기 기간 해석 불가: {v}")
    return round(int(m.group(1)) * UNIT_DAYS.get(m.group(2) or "일", 1), 1)


def norm_date(s):
    s = str(s).strip().rstrip(".")
    m = re.match(r"^(\d{2,4})[.\-/년\s]*(\d{1,2})[.\-/월\s]*(\d{1,2})일?$", s) or re.match(r"^(\d{4})(\d{2})(\d{2})$", s)
    if not m:
        m2 = re.match(r"^(\d{1,2})월\s*(\d{1,2})일", s)
        if m2:
            return date(DEFAULT_YEAR, int(m2.group(1)), int(m2.group(2))).isoformat()
        raise ValueError(f"날짜 해석 불가: {s}")
    y, mo, d = (int(x) for x in m.groups())
    return date(y + 2000 if y < 100 else y, mo, d).isoformat()


def classify(text):
    """대기 사유 문장을 4개 요인 중 하나로 분류한다. 어느 키워드에도 걸리지 않으면 '기타'(사람이 확인)."""
    for cause, kws in CAUSE_KW:
        if any(k in text for k in kws):
            return cause
    return "기타"


def pick(row, key):
    for k in COL_ALIAS[key]:
        if k in row:
            return row[k]
    raise KeyError(f"필수 열 없음: {key}")


def to_std(row, src):
    reason = str(pick(row, "reason"))
    return {"equip_id": str(pick(row, "equip")).strip(), "unit": norm_unit(pick(row, "unit")), "wait_days": norm_wait(pick(row, "wait")),
            "date": norm_date(pick(row, "received")), "reason": reason, "cause": classify(reason), "source": src}


def load(text, fmt):
    """형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다."""
    if fmt == "json":
        rows = [{"equip": r["equip"], "unit": r["unit"]["name"], "reason": r["reason"], "wait": r["wait"], "received": r["received"]} for r in json.loads(text)["items"]]
    else:
        rows = list(csv.DictReader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ","))
    ok, fail = [], []
    for r in rows:
        try:
            ok.append(to_std(r, fmt))
        except (KeyError, ValueError) as e:
            fail.append((r, str(e)))
    return ok, fail


def parse_log(line):
    """정비일지 문장 1줄에서 장비·부대·대기 일수·날짜·사유를 추출한다."""
    eq = re.search(r"E-\d{4}", line)
    unit = re.search(r"(?:가상\s*)?[A-F]\s*부대", line)
    wait = re.search(r"(\d+)\s*(일간|일|주|개월|시간)", line)
    dt = re.search(r"(\d{2,4}[.\-/]\d{1,2}[.\-/]\d{1,2})|(\d{1,2}월\s*\d{1,2}일)", line)
    if not (eq and unit and wait and dt):
        raise ValueError("필수 항목(장비·부대·대기·날짜) 추출 실패")
    reason = re.sub(r"E-\d{4}|\[정비일지\]|\(.*?\)|\d{2,4}[.\-/]\d{1,2}[.\-/]\d{1,2}|\d{1,2}월\s*\d{1,2}일자?|가상\s*[A-F]\s*부대|[/:]", " ", line)
    reason = re.sub(r"\s+", " ", reason).strip()
    return {"equip_id": eq.group(0), "unit": norm_unit(unit.group(0)), "wait_days": norm_wait(wait.group(0)), "date": norm_date(dt.group(0)),
            "reason": reason, "cause": classify(reason), "source": "log"}


# 1) 형식별 파싱
results, total, success = [], 0, 0
for fmt, text in [("csv", RAW_CSV), ("tsv", RAW_TSV), ("json", RAW_JSON)]:
    ok, fail = load(text, fmt)
    results += ok; total += len(ok) + len(fail); success += len(ok)
    print(f"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공" + (f", 실패 {[e for _, e in fail]}" if fail else ""))
logs, l_fail = [], 0
for t in LOGS:
    try:
        logs.append(parse_log(t))
    except ValueError as e:
        l_fail += 1; print("일지 파싱 실패:", e)
results += logs; total += len(LOGS); success += len(logs)
print(f"[정비일지 문장] {len(LOGS)}건 중 {len(logs)}건 성공")
rate = success / total
print(f"\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}")

# 2) 정제 결과(표기 통일, 중복 기록 정리)
std = pd.DataFrame(results)
before = len(std)
std = std.drop_duplicates(["equip_id", "date", "cause"]).sort_values("equip_id")
print(f"\n[정제된 정비 대기 기록] {before}행 -> 중복 정리 후 {len(std)}행, 부대 {std['unit'].nunique()}곳")
print(std[["equip_id", "unit", "date", "wait_days", "cause", "source"]].to_string(index=False))

# 3) 요인 분류 정확도와 사람 확인 대상
chk = std[std["equip_id"].isin(TRUTH)]
acc = (chk["cause"] == chk["equip_id"].map(TRUTH)).mean()
print(f"\n[요인 분류] 정답이 있는 기록 {len(chk)}건 중 일치 {int((chk['cause'] == chk['equip_id'].map(TRUTH)).sum())}건 ({acc:.0%})")
print(std.groupby("cause").agg(건수=("equip_id", "size"), 평균대기일=("wait_days", "mean")).round(1).to_string())
print("사람 확인 대상('기타'로 분류):", ", ".join(f"{r.equip_id}('{r.reason[:14]}')" for r in std[std["cause"] == "기타"].itertuples()) or "없음")

# 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가
new_form = "장비 코드,배속부대,지연 원인,지연 기간,접수 일자\nE-0031,가상B부대,부품 미입고,7일,2026.04.01\n"
ok, fail = load(new_form, "csv")
print(f"\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}")
for k, alias in {"equip": "장비 코드", "unit": "배속부대", "reason": "지연 원인", "wait": "지연 기간", "received": "접수 일자"}.items():
    COL_ALIAS[k].append(alias)
ok, fail = load(new_form, "csv")
print(f"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)")

# 5) 서식 변경 대응 시험 ②: 새 표현의 사유 -> 분류 키워드 한 줄 추가
line = "가상C부대 E-0032 : 예비품 소진으로 8일간 정비 보류 (2026.04.02)"
r1 = parse_log(line)
CAUSE_KW[3][1].append("예비품")
r2 = parse_log(line)
print(f"새 사유 표현('예비품 소진') 1차 분류: {r1['cause']} -> 키워드 1개 추가 후: {r2['cause']} (코드 수정 없이 사전만 보완)")
print("※ 요인 분류는 키워드 기반 1차 분류이며, '기타'와 복합 사유는 정비 담당자가 확인해 확정한다.")
💡 [참고] 미션 02 예상 실행 결과 (Output)
[CSV ] 6행 중 6행 성공
[TSV ] 5행 중 5행 성공
[JSON] 3행 중 3행 성공
[정비일지 문장] 5건 중 5건 성공

전체 파싱 성공률 100% (19/19) - 목표 100%: 달성

[정제된 정비 대기 기록] 19행 -> 중복 정리 후 18행, 부대 6곳
equip_id  unit       date  wait_days  cause source
  E-0001 가상A부대 2026-03-02        5.0  부품 대기    csv
  E-0002 가상B부대 2026-03-15       14.0 정비병 부족    csv
  E-0003 가상C부대 2026-01-10        2.0  외주 정비    csv
  E-0004 가상D부대 2026-02-27        3.0 검사·재작업    csv
  E-0005 가상E부대 2025-12-30       30.0  부품 대기    csv
  E-0006 가상F부대 2026-01-05        4.0 검사·재작업    csv
  E-0007 가상A부대 2026-02-11        6.0 정비병 부족    tsv
  E-0008 가상B부대 2026-01-20       10.0  부품 대기    tsv
  E-0009 가상C부대 2026-03-05        7.0  외주 정비    tsv
  E-0010 가상D부대 2025-11-03        3.0 검사·재작업    tsv
  E-0011 가상E부대 2026-02-01       14.0  부품 대기   json
  E-0012 가상F부대 2026-01-09        4.0 정비병 부족   json
  E-0013 가상A부대 2026-03-20        3.0 검사·재작업   json
  E-0021 가상A부대 2026-03-04        6.0  부품 대기    log
  E-0022 가상B부대 2026-03-05        3.0 정비병 부족    log
  E-0023 가상C부대 2026-03-06       14.0  외주 정비    log
  E-0024 가상D부대 2026-03-07        7.0 검사·재작업    log
  E-0025 가상E부대 2026-03-08        4.0     기타    log

[요인 분류] 정답이 있는 기록 18건 중 일치 18건 (100%)
        건수  평균대기일
cause            
검사·재작업   5    4.0
기타       1    4.0
부품 대기    5   13.0
외주 정비    3    7.7
정비병 부족   4    6.8
사람 확인 대상('기타'로 분류): E-0025('원인 불명 대기 4일')

새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: reason'
별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)
... (이하 생략)

🎯 미션 03. 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현

소요시간: 약 2.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

가동률 저하 상위 요인 도출 1차 통계 분석 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 가동률 저하의 상위 5대 요인을 상관·효과크기·손실일로 도출하는 1차 통계 Baseline을 만들고, 정비·검사 병목 판별 룰 15종 이상을 YAML 설정으로 구현해 저가동 후보를 가려낸다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 가상 장비 300대에서 가동률 70% 미만 장비와 나머지의 요인별 평균 차이(효과크기)와 가동률과의 상관을 구해 상위 5개 요인을 정 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/engine/qlt04_rule_engine.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 군 규정 및 지침 조항과의 정확한 일치성

미션 PASS 조건

  • 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하

  • 진행 기준: 룰 엔진 단위 테스트 100% 통과

  • 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""가상 장비 300대의 가동률 저하 요인을 상관·효과크기·손실일 기준으로 비교하는 1차 통계 Baseline으로 상위 5대 요인을 도출하고, YAML 형식 룰 16종으로 저가동 후보를 판별하며 경계 조건 단위 시험과 룰 처리 지연시간(99백분위)을 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----
# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.
RULES_YAML = """
- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: "<", value: 50, weight: 2, clause: 가상품질지침 제3조}
- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: "<", value: 60, weight: 2, clause: 가상품질지침 제4조}
- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제5조}
- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제6조}
- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: ">", value: 15, weight: 2, clause: 가상품질지침 제7조}
- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: ">", value: 6, weight: 3, clause: 가상품질지침 제8조}
- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: ">=", value: 1, weight: 3, clause: 가상품질지침 제9조}
- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: ">", value: 0.25, weight: 2, clause: 가상품질지침 제9조}
- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: ">=", value: 3, weight: 3, clause: 가상품질지침 제10조}
- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: ">=", value: 3, weight: 2, clause: 가상품질지침 제11조}
- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: ">", value: 15, weight: 1, clause: 가상품질지침 제12조}
- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: ">=", value: 6, weight: 1, clause: 가상품질지침 제13조}
- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: ">", value: 30, weight: 3, clause: 가상품질지침 제14조}
- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: ">", value: 0.8, weight: 2, clause: 가상품질지침 제8조}
- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: "==", value: true, weight: 3, clause: 가상품질지침 제10조}
- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: "<", value: 150, weight: 1, clause: 가상품질지침 제15조}
"""
GRADE_CUT = [(10, "고위험"), (6, "주의"), (3, "관심")]
OPS = {"<": lambda a, b: a < b, ">": lambda a, b: a > b, "<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b, "==": lambda a, b: a == b}


def parse_rules(text):
    """YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이)."""
    out = []
    for line in text.strip().splitlines():
        body = line.strip()[3:-1]
        r = {}
        for part in body.split(", "):
            k, v = part.split(": ", 1)
            v = v.strip().strip('"')
            r[k] = True if v == "true" else False if v == "false" else (float(v) if v.replace(".", "", 1).isdigit() else v)
        out.append(r)
    return out


def derive_rec(rec):
    """룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다."""
    r = dict(rec)
    r["total_wait"] = r["parts_wait_days"] + r["labor_wait_days"] + r["outsource_days"] + r["inspect_wait_days"]
    r["rework_rate"] = r["rework_count"] / r["maint_orders"] if r["maint_orders"] else 0.0
    r["insp_ratio"] = r["inspect_wait_days"] / r["outsource_days"] if r["outsource_days"] >= 5 else 0.0
    r["spec_defect"] = bool(r["spec_mismatch"] >= 2 and r["defect_reports"] >= 1)
    return r


def apply_rules(rec, rules, derived=False):
    """룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다."""
    r = rec if derived else derive_rec(rec)
    hits = [x["id"] for x in rules if OPS[x["op"]](r[x["field"]], x["value"])]
    w = {x["id"]: x["weight"] for x in rules}
    return sum(w[h] for h in hits), hits


def grade_of(score):
    for cut, g in GRADE_CUT:
        if score >= cut:
            return g
    return "정상"

fleet = make_fleet(300, seed=7)
fleet["low"] = fleet["avail_pct"] < 70                               # 저가동: 가동률 70% 미만(가상 기준)
FAC = ["maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports",
       "age_years", "op_hours", "parts_stock_pct", "mechanic_fill_pct"]
KO = {**FEAT_KO, "parts_wait_days": "부품 대기 일수", "labor_wait_days": "정비병 부족 대기 일수", "op_hours": "가동 시간"}

# 1) 1차 통계 Baseline: 가동률과의 상관계수, 저가동군-정상군 효과크기(Cohen d), 저가동군 평균 값
rows = []
for f in FAC:
    a, b = fleet.loc[fleet["low"], f], fleet.loc[~fleet["low"], f]
    sp = np.sqrt((a.var() + b.var()) / 2) or 1.0
    rows.append({"요인": KO[f], "상관(r)": fleet[f].corr(fleet["avail_pct"]), "효과크기(d)": (a.mean() - b.mean()) / sp, "저가동군 평균": a.mean(), "정상군 평균": b.mean(), "key": f})
fac = pd.DataFrame(rows)
fac["순위점수"] = fac["상관(r)"].abs() + fac["효과크기(d)"].abs() / 3               # 두 지표를 함께 본 순위 점수(휴리스틱)
top5 = fac.sort_values("순위점수", ascending=False).head(5)
print(f"[Baseline] 장비 {len(fleet)}대, 저가동(70% 미만) {int(fleet['low'].sum())}대, 평균 가동률 {fleet['avail_pct'].mean():.1f}%")
print("[가동률 저하 상위 5대 요인]")
print(top5.drop(columns=["key", "순위점수"]).round(2).to_string(index=False))
lost = fleet.loc[fleet["low"], ["parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days"]].mean()
print("저가동 장비의 평균 대기 손실일:", ", ".join(f"{KO[k]} {v:.1f}일" for k, v in lost.items()))
print("주요 저가동 요인 분포(정답):", fleet["main_cause"].value_counts().to_dict())

# 2) 룰 엔진: 장비 레코드에 16종 룰을 적용
rules = parse_rules(RULES_YAML)
recs = fleet.drop(columns=["main_cause", "low"]).to_dict("records")
lat, scores, hits = [], [], []
for r in recs:
    t = time.perf_counter(); s, h = apply_rules(r, rules); lat.append((time.perf_counter() - t) * 1000)
    scores.append(s); hits.append(h)
fleet["score"], fleet["grade"] = scores, [grade_of(s) for s in scores]
print(f"\n[룰 엔진] 룰 {len(rules)}종 적용, 처리 지연 평균 {np.mean(lat):.3f}ms / 99백분위 {np.percentile(lat, 99):.3f}ms (목표 50ms 이하: {'달성' if np.percentile(lat, 99) <= 50 else '미달'})")
tab = fleet.groupby("grade").agg(장비수=("equip_id", "size"), 실제저가동=("low", "sum"), 저가동비율=("low", "mean")).reindex(["고위험", "주의", "관심", "정상"])
print(tab.round(2).to_string())
flag = fleet["score"] >= 6                                                # 주의 이상을 저가동 후보로 본다
tp = int((flag & fleet["low"]).sum()); prec, rec = tp / max(flag.sum(), 1), tp / fleet["low"].sum()
print(f"저가동 후보(주의 이상) {int(flag.sum())}대 중 실제 저가동 {tp}대: 정밀도 {prec:.0%}, 재현율 {rec:.0%}")
cnt = pd.Series([h for hs in hits for h in hs]).value_counts()
print("룰별 적중 상위:", ", ".join(f"{k} {v}건" for k, v in cnt.head(5).items()))

# 3) 경계 조건 단위 시험: 기준값 위·아래에서 룰 판정이 맞는지(룰마다 2~3건)
BASE = derive_rec({"parts_stock_pct": 90, "mechanic_fill_pct": 90, "parts_wait_days": 0, "labor_wait_days": 0, "outsource_days": 0, "inspect_wait_days": 0,
                   "rework_count": 0, "maint_orders": 0, "spec_mismatch": 0, "defect_reports": 0, "age_years": 5, "op_hours": 300})
tests = ok = 0
fails = []
for x in rules:
    v, d = x["value"], (0.01 if isinstance(x["value"], float) and not float(x["value"]).is_integer() else 1)
    cases = {"<": [(v, False), (v - d, True), (v + d, False)], ">": [(v, False), (v + d, True), (v - d, False)], ">=": [(v - d, False), (v, True), (v + d, True)],
             "<=": [(v + d, False), (v, True), (v - d, True)], "==": [(True, True), (False, False)]}[x["op"]]
    for val, expect in cases:
        r = dict(BASE); r[x["field"]] = val
        got = x["id"] in apply_rules(r, rules, derived=True)[1]
        tests += 1; ok += got == expect
        if got != expect:
            fails.append((x["id"], val))
print(f"\n[경계 조건 단위 시험] {ok}/{tests} 통과 ({ok / tests:.0%}), 실패 {fails or '없음'} (목표 100%)")

# 4) 판정 근거 예시(상위 3대)와 사람이 확인할 것
print("\n[고위험 상위 3대 판정 근거]")
nm = {x["id"]: x for x in rules}
for _, r in fleet.sort_values("score", ascending=False).head(3).iterrows():
    h = apply_rules(recs[int(r.name)], rules)[1]
    print(f"  {r['equip_id']} {r['unit']} 점수 {r['score']:.0f} 가동률 {r['avail_pct']}%: " + "; ".join(f"{i} {nm[i]['name']}({nm[i]['clause']})" for i in h[:3]))
print(f"룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.")

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
t5 = top5.iloc[::-1]
ax[0].barh([k for k in t5["key"]], t5["상관(r)"], color="tab:blue"); ax[0].set_title("Top-5 factors: correlation with availability")
ax[1].bar(tab.index.map({"고위험": "high", "주의": "caution", "관심": "watch", "정상": "normal"}), tab["저가동비율"], color="tab:orange"); ax[1].set_title("Low-availability rate by rule grade")
plt.tight_layout(); plt.show()
💡 [참고] 미션 03 예상 실행 결과 (Output)
[Baseline] 장비 300대, 저가동(70% 미만) 63대, 평균 가동률 80.8%
[가동률 저하 상위 5대 요인]
          요인  상관(r)  효과크기(d)  저가동군 평균  정상군 평균
    정비 요청 건수  -0.75     1.56     3.94    1.95
    외주 정비 일수  -0.73     1.26     8.46    1.78
 외주 검사 대기 일수  -0.66     1.13     5.56    1.02
    부품 대기 일수  -0.63     1.18     8.70    2.48
정비병 부족 대기 일수  -0.57     1.01     8.27    2.59
저가동 장비의 평균 대기 손실일: 부품 대기 일수 8.7일, 정비병 부족 대기 일수 8.3일, 외주 정비 일수 8.5일, 외주 검사 대기 일수 5.6일
주요 저가동 요인 분포(정답): {'없음': 77, '부품 대기': 72, '정비병 부족': 70, '외주 정비': 59, '검사·재작업': 22}

[룰 엔진] 룰 16종 적용, 처리 지연 평균 0.009ms / 99백분위 0.056ms (목표 50ms 이하: 달성)
       장비수  실제저가동  저가동비율
grade                   
고위험     16     13   0.81
주의      24     18   0.75
관심      45     19   0.42
정상     215     13   0.06
저가동 후보(주의 이상) 40대 중 실제 저가동 31대: 정밀도 78%, 재현율 49%
룰별 적중 상위: Q01 53건, Q02 39건, Q03 38건, Q04 28건, Q06 26건

[경계 조건 단위 시험] 47/47 통과 (100%), 실패 없음 (목표 100%)

[고위험 상위 3대 판정 근거]
  E-0047 가상A부대 점수 14 가동률 35.7%: Q01 부품 재고 가용률 50% 미만(가상품질지침 제3조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q05 외주 정비 15일 초과(가상품질지침 제7조)
  E-0231 가상D부대 점수 14 가동률 51.7%: Q02 정비병 충원율 60% 미만(가상품질지침 제4조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q04 정비병 부족 대기 8일 초과(가상품질지침 제6조)
  E-0041 가상C부대 점수 13 가동률 38.8%: Q05 외주 정비 15일 초과(가상품질지침 제7조); Q06 외주 검사 대기 6일 초과(가상품질지침 제8조); Q07 재작업 1건 이상(가상품질지침 제9조)
룰 16종(목표 15종 이상: 달성). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.

🎯 미션 04. 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

회귀 분석 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 회귀 분석으로 가동률 저하의 핵심 병목 요인을 정밀 분석하고, 저하 요인을 분류해 Top-3 개선 옵션과 유사 사례 유사도를 제공하는 모델의 정확도를 검증한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 가상 장비 1,500대를 학습 70%와 검증 30%로 나누고 릿지 회귀로 요인별 표준화 계수를 구한 뒤 부트스트랩 20회로 계수 부호의 안정성 확인하고 유효성을 점검하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/ai/qlt04_intelligence_module.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 추천 사유의 합리성 및 실무 수용성

미션 PASS 조건

  • 정량 기준: 추천/분류 정확도 85% 이상 달성

  • 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료

  • 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""가상 장비 1,500대의 가동률을 릿지 회귀로 설명해 핵심 병목 요인의 영향 크기와 부호 안정성을 분석하고, 저가동 장비의 주요 요인(부품 대기·정비병 부족·외주 정비·검사·재작업)을 분류해 Top-3 개선 옵션과 유사 과거 사례 유사도 점수를 제공하며 정확도를 검증 세트로 측정한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import r2_score, mean_absolute_error

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

fleet = make_fleet(1500, seed=21)
idx = np.random.default_rng(5).permutation(len(fleet))
tr, te = fleet.iloc[idx[:1050]], fleet.iloc[idx[1050:]]                     # 학습 70% / 검증 30%(과거 처리 완료 자료 가정)

# 1) 회귀 분석: 가동률 = f(연령, 정비 요청, 재고, 충원율, 외주·검사 대기, 재작업, 규격 불일치, 하자, 유형)
reg = fit_avail(tr)
pred = reg.predict(feat_frame(te))
coef = pd.Series(reg[-1].coef_, index=FEAT)                                # 표준화 계수: 1표준편차 증가 시 가동률 변화(%p)
print(f"[회귀 모델] 학습 {len(tr)}대 / 검증 {len(te)}대: 검증 R2 {r2_score(te['avail_pct'], pred):.3f}, MAE {mean_absolute_error(te['avail_pct'], pred):.2f}%p")
boot = []
rng = np.random.default_rng(9)
for _ in range(20):                                                       # 부트스트랩 20회로 계수 부호 안정성 확인
    b = tr.iloc[rng.integers(0, len(tr), len(tr))]
    boot.append(fit_avail(b)[-1].coef_)
boot = np.array(boot)
top = coef.abs().sort_values(ascending=False).head(5).index
print("[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]")
for f in top:
    k = FEAT.index(f)
    print(f"  {FEAT_KO[f]:12s} {coef[f]:+6.2f}%p  부호 유지 {np.mean(np.sign(boot[:, k]) == np.sign(coef[f])):.0%}")

# 2) 요인 분류 + Top-3 개선 옵션
clf = fit_cause(tr)
low = te[(te["avail_pct"] < 85) & (te["main_cause"] != "없음")]
X = cause_frame(low)
proba = clf.predict_proba(X)
cls = clf.classes_
top1 = cls[proba.argmax(1)]
acc1 = float(np.mean(top1 == low["main_cause"].to_numpy()))
order = np.argsort(-proba, axis=1)[:, :3]
acc3 = float(np.mean([low["main_cause"].iloc[i] in cls[order[i]] for i in range(len(low))]))
major = low["main_cause"].value_counts(normalize=True).iloc[0]
print(f"\n[요인 분류] 검증 저가동 장비 {len(low)}대: Top-1 정확도 {acc1:.1%}, Top-3 포함률 {acc3:.1%} (무작위 최빈값 기준 {major:.1%}) -> 목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'}")
rc = pd.crosstab(low["main_cause"], pd.Series(top1, index=low.index, name="예측"))
print("요인별 재현율:", ", ".join(f"{c} {rc.loc[c, c] / rc.loc[c].sum():.0%}" for c in rc.index if c in rc.columns))

# 3) 유사 과거 사례 유사도(코사인): 학습 세트 중 같은 요인 장비와 얼마나 비슷한가
sc, trl = clf[0], tr[(tr["avail_pct"] < 85) & (tr["main_cause"] != "없음")]
Z = sc.transform(cause_frame(trl)); Zn = Z / np.linalg.norm(Z, axis=1, keepdims=True)
zq = sc.transform(X); zqn = zq / np.linalg.norm(zq, axis=1, keepdims=True)
sim = {c: (zqn @ Zn[(trl["main_cause"] == c).to_numpy()].T).max(axis=1) for c in cls}
print("\n[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]")
for j in range(3):
    r = low.iloc[j]
    opts = " | ".join(f"{cls[o]} {proba[j, o]:.0%}/{sim[cls[o]][j]:.2f} -> {ACTION[cls[o]]}" for o in order[j])
    print(f"  {r['equip_id']} 가동률 {r['avail_pct']}% (정답 {r['main_cause']}): {opts}")
miss = low[top1 != low["main_cause"].to_numpy()]
print(f"\n[오분류 {len(miss)}건] 확률이 가장 높은 요인과 2위 확률 차이 평균 {np.mean(np.sort(proba, axis=1)[:, -1] - np.sort(proba, axis=1)[:, -2]):.2f}")
conf = np.sort(proba, axis=1)[:, -1]
print(f"확률 0.8 이상 추천의 정확도 {np.mean((top1 == low['main_cause'].to_numpy())[conf >= 0.8]):.0%} ({int((conf >= 0.8).sum())}건), 0.8 미만은 사람이 Top-3를 비교해 선택")
print("※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.")

plt.figure(figsize=(7, 4))
plt.barh(list(coef.abs().sort_values().index), coef[coef.abs().sort_values().index], color="tab:blue")
plt.title("Standardized ridge coefficients on availability (%p per 1 SD)"); plt.tight_layout(); plt.show()
💡 [참고] 미션 04 예상 실행 결과 (Output)
[회귀 모델] 학습 1050대 / 검증 450대: 검증 R2 0.899, MAE 3.97%p
[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]
  정비 요청 건수      -8.63%p  부호 유지 100%
  외주 정비 일수      -6.62%p  부호 유지 100%
  외주 검사 대기 일수   -5.06%p  부호 유지 100%
  재작업 건수        -1.88%p  부호 유지 100%
  부품 재고 가용률     +1.73%p  부호 유지 100%

[요인 분류] 검증 저가동 장비 234대: Top-1 정확도 99.1%, Top-3 포함률 100.0% (무작위 최빈값 기준 35.5%) -> 목표 85% 이상: 달성
요인별 재현율: 검사·재작업 97%, 부품 대기 100%, 외주 정비 99%, 정비병 부족 100%

[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]
  E-1269 가동률 79.4% (정답 검사·재작업): 검사·재작업 98%/0.89 -> 검사 일정 조율·규격 사전 대조 | 외주 정비 2%/0.83 -> 외주 정비 일정 조율·내부 정비 확대 | 정비병 부족 0%/0.68 -> 정비병 재배치·교육 일정 조정
  E-0462 가동률 74.4% (정답 외주 정비): 외주 정비 63%/0.74 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 36%/0.81 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.69 -> 정비병 재배치·교육 일정 조정
  E-0924 가동률 3.6% (정답 외주 정비): 외주 정비 100%/0.85 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 0%/0.70 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.75 -> 정비병 재배치·교육 일정 조정

[오분류 2건] 확률이 가장 높은 요인과 2위 확률 차이 평균 0.86
확률 0.8 이상 추천의 정확도 100% (192건), 0.8 미만은 사람이 Top-3를 비교해 선택
※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.

🎯 미션 05. 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 정비병과 부품 재고를 부대 사이에서 옮겼을 때 예상 가동률 상승폭을 시뮬레이션하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면의 프로토타입으로 보여 준다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용을 반영한 회귀 모델을 만들어 검증 R2 확인하고 유효성을 점검하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/ui/qlt04_app.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 군 실무자 친화적 디자인 및 조작 편의성

미션 PASS 조건

  • 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료

  • 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인

  • 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""부대별 정비병 충원율·부품 재고 가용률을 다른 부대로 옮겼을 때 장비 가동률이 얼마나 오르는지 회귀 모델로 시뮬레이션하고 상승폭이 큰 재배치안을 순서대로 고르며, 입력-분석-검토-결과 4개 화면과 상태 뱃지(승인대기·반려·완료)·필터·화면 이동을 갖춘 텍스트 프로토타입으로 보여 준다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import r2_score

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

fleet = make_fleet(600, seed=31)
sim_tr, sim_te = fleet.iloc[:420], fleet.iloc[420:]
sm = fit_sim(sim_tr)
print(f"[시뮬레이션 모델] 검증 R2 {r2_score(sim_te['avail_pct'], sm.predict(sim_frame(sim_te))):.3f} (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)")
sm = fit_sim(fleet)
AUTH = 40                                                              # 부대당 정비병 편성 정원(가상 가정)
PARAM = {"max_move": 4, "min_donor_fill": 60.0, "stock_step": 8.0, "top_n": 4, "max_donor_drop": 3.5}


def unit_avail(df, unit, fill_d=0.0, stock_d=0.0):
    d = df[df["unit"] == unit].copy()
    d["mechanic_fill_pct"] = np.clip(d["mechanic_fill_pct"] + fill_d, 0, 100)
    d["parts_stock_pct"] = np.clip(d["parts_stock_pct"] + stock_d, 0, 100)
    return float(np.clip(sm.predict(sim_frame(d)), 0, 100).mean()), len(d)


def evaluate(df, kind, src, dst, n):
    """src 부대에서 dst 부대로 n단위(정비병 n명 또는 재고 n*step %p)를 옮길 때 전체 평균 가동률 변화(%p)와 제공 부대 변화."""
    delta = n / AUTH * 100 if kind == "정비병" else n * PARAM["stock_step"] / 2
    key = "mechanic_fill_pct" if kind == "정비병" else "parts_stock_pct"
    a0, na = unit_avail(df, src); b0, nb = unit_avail(df, dst)
    kw = (lambda s: {"fill_d": s * delta}) if kind == "정비병" else (lambda s: {"stock_d": s * delta})
    a1, _ = unit_avail(df, src, **kw(-1)); b1, _ = unit_avail(df, dst, **kw(+1))
    cur = df[df["unit"] == src][key].mean()
    return {"kind": kind, "src": src, "dst": dst, "n": n, "gain": ((a1 - a0) * na + (b1 - b0) * nb) / len(df), "donor_drop": a0 - a1, "recv_gain": b1 - b0, "donor_after": cur - delta}


base = pd.DataFrame([{"unit": u, **dict(zip(["avail", "n"], unit_avail(fleet, u))), "stock": g["parts_stock_pct"].mean(), "fill": g["mechanic_fill_pct"].mean()} for u, g in fleet.groupby("unit")])
cands = [evaluate(fleet, k, s, d, n) for k in ["정비병", "부품 재고"] for s in UNITS for d in UNITS if s != d for n in (2, 4, 6)]
cands = [c for c in cands if c["donor_after"] >= PARAM["min_donor_fill"]]
cands.sort(key=lambda c: -c["gain"])
print(f"후보 {len(cands)}건 평가 (제공 부대당 정비병 누적 최대 {PARAM['max_move']}명 이동, 제공 부대 충원율·재고 {PARAM['min_donor_fill']:.0f}% 이상 유지)")

# 화면 4종 프로토타입
BADGE = {"승인대기": "[승인대기]", "반려": "[반려]", "완료": "[완료]"}
props = [{"id": "P00", **evaluate(fleet, "정비병", "가상C부대", "가상D부대", 2), "status": "완료", "note": "전 분기 시행 완료(가상 이력)"}]
for c in cands:                                                        # 같은 도착 부대 중복을 피해 상위 4건 제안
    if len([p for p in props if p["id"] != "P00"]) >= PARAM["top_n"]:
        break
    if any(p["dst"] == c["dst"] and p["kind"] == c["kind"] for p in props):
        continue
    moved = sum(p["n"] for p in props if p["src"] == c["src"] and p["kind"] == "정비병" and p["status"] != "반려") + (c["n"] if c["kind"] == "정비병" else 0)
    if c["donor_drop"] > PARAM["max_donor_drop"]:
        ok, why = False, f"제공 부대 가동률 {c['donor_drop']:.1f}%p 하락(한도 {PARAM['max_donor_drop']} 초과)"
    elif moved > PARAM["max_move"]:
        ok, why = False, f"제공 부대 누적 이동 {moved}명(한도 {PARAM['max_move']}명 초과)"
    else:
        ok, why = True, "제공 부대 영향·누적 이동 허용 범위"
    props.append({"id": f"P{len(props):02d}", **c, "status": "승인대기" if ok else "반려", "note": why})


class App:
    SCREENS = ["입력", "분석", "검토", "결과"]

    def __init__(self):
        self.cur, self.flt, self.visited = "입력", None, ["입력"]

    def goto(self, s):
        assert s in self.SCREENS
        self.cur = s; self.visited.append(s); return self.render()

    def render(self):
        if self.cur == "입력":
            return ["입력 화면: 장비 %d대 / 부대 %d곳 / 파라미터 %s" % (len(fleet), len(UNITS), PARAM)]
        if self.cur == "분석":
            return [f"  {r.unit} 가동률 {r.avail:5.1f}% 재고 {r.stock:4.0f}% 충원 {r.fill:4.0f}% ({int(r.n)}대)" + (" <- 하위" if r.avail <= base["avail"].nsmallest(2).max() else "") for r in base.itertuples()]
        if self.cur == "검토":
            ps = [p for p in props if self.flt in (None, p["status"])]
            return [f"  {p['id']} {BADGE[p['status']]:8s} {p['kind']} {p['n']}단위 {p['src'][2:4]}->{p['dst'][2:4]} 전체 {p['gain']:+.2f}%p(수혜 {p['recv_gain']:+.1f}/제공 {-p['donor_drop']:+.1f}) | {p['note']}" for p in ps]
        ok = [p for p in props if p["status"] == "승인대기"]
        return [f"  승인 대기 {len(ok)}건 시행 시 예상 전체 가동률 +{sum(p['gain'] for p in ok):.2f}%p (정비병 이동은 합산 근사)", "  최종 시행은 군수참모 결정(M7 워크플로우)"]


app = App()
for s in App.SCREENS:
    print(f"\n[화면 {App.SCREENS.index(s) + 1}: {s}]")
    for line in app.goto(s):
        print(line)
app.flt = "승인대기"; n1 = len(app.goto("검토")); app.flt = "반려"; n2 = len(app.goto("검토")); app.flt = None
print(f"\n필터 시험: 승인대기 {n1}건, 반려 {n2}건, 전체 {len(app.goto('검토'))}건")
nav = [(a, b) for a in App.SCREENS for b in App.SCREENS if a != b]
okn = 0
for a, b in nav:
    app.cur = a; okn += bool(app.goto(b))
print(f"화면 이동 시험 {okn}/{len(nav)} 성공, 핵심 화면 {len(App.SCREENS)}종 구현 (목표 4종: {'달성' if len(App.SCREENS) == 4 else '미달'})")
tot = sum(p["gain"] for p in props if p["status"] == "승인대기")
print(f"재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +{tot:.2f}%p, 최대 단일 안 {max(c['gain'] for c in cands):+.2f}%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.")

plt.figure(figsize=(7, 4))
plt.bar([f"{p['id']}\n{ {'완료': 'done', '승인대기': 'pending', '반려': 'rejected'}[p['status']]}" for p in props], [p["gain"] for p in props], color=["tab:gray" if p["status"] == "완료" else "tab:blue" if p["status"] == "승인대기" else "tab:red" for p in props])
plt.ylabel("fleet availability gain (%p)"); plt.title("Reallocation proposals (gray done / blue pending / red rejected)"); plt.tight_layout(); plt.show()
💡 [참고] 미션 05 예상 실행 결과 (Output)
[시뮬레이션 모델] 검증 R2 0.646 (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)
후보 70건 평가 (제공 부대당 정비병 누적 최대 4명 이동, 제공 부대 충원율·재고 60% 이상 유지)

[화면 1: 입력]
입력 화면: 장비 600대 / 부대 6곳 / 파라미터 {'max_move': 4, 'min_donor_fill': 60.0, 'stock_step': 8.0, 'top_n': 4, 'max_donor_drop': 3.5}

[화면 2: 분석]
  가상A부대 가동률  78.3% 재고   50% 충원   76% (94대)
  가상B부대 가동률  75.0% 재고   57% 충원   63% (87대) <- 하위
  가상C부대 가동률  83.8% 재고   84% 충원   87% (99대)
  가상D부대 가동률  77.8% 재고   75% 충원   59% (115대) <- 하위
  가상E부대 가동률  80.8% 재고   50% 충원   72% (100대)
  가상F부대 가동률  79.9% 재고   66% 충원   78% (105대)

[화면 3: 검토]
  P00 [완료]     정비병 2단위 C부->D부 전체 +0.14%p(수혜 +1.5/제공 -0.9) | 전 분기 시행 완료(가상 이력)
  P01 [반려]     정비병 4단위 C부->B부 전체 +0.11%p(수혜 +3.1/제공 -2.0) | 제공 부대 누적 이동 6명(한도 4명 초과)
  P02 [승인대기]   부품 재고 4단위 C부->A부 전체 +0.06%p(수혜 +0.4/제공 -0.1) | 제공 부대 영향·누적 이동 허용 범위
  P03 [승인대기]   정비병 2단위 C부->E부 전체 +0.05%p(수혜 +1.2/제공 -0.9) | 제공 부대 영향·누적 이동 허용 범위
  P04 [반려]     정비병 2단위 C부->F부 전체 +0.04%p(수혜 +1.1/제공 -0.9) | 제공 부대 누적 이동 6명(한도 4명 초과)

[화면 4: 결과]
  승인 대기 2건 시행 시 예상 전체 가동률 +0.10%p (정비병 이동은 합산 근사)
  최종 시행은 군수참모 결정(M7 워크플로우)

필터 시험: 승인대기 2건, 반려 2건, 전체 5건
화면 이동 시험 12/12 성공, 핵심 화면 4종 구현 (목표 4종: 달성)
재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +0.10%p, 최대 단일 안 +0.26%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.

🎯 미션 06. 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현

소요시간: 약 2.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

부대별/장비별 가동률 추세 및 저하 요인 분석 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 부대별·장비 유형별 가동률 추세와 저하 요인을 보여 주는 대시보드를 만들고, 비정상 입력에도 중단되지 않도록 입력 검증기와 에러 핸들러를 갖춘다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 12개월 가상 추세로 부대별 월 기울기, 최근 3개월 평균, 저하 경고와 부대 x 유형 가동률, 대기 손실일 구성을 표와 그래프로 보여 준다 과정을 진행하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/utils/qlt04_validator.py 및 에러 핸들러 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공

미션 PASS 조건

  • 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%

  • 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성

  • 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""부대별·장비 유형별 12개월 가동률 추세와 저하 요인 구성을 텍스트 대시보드와 그래프로 보여 주고, 결측·특수문자·자료형 오류·대용량·빈 자료·열 누락 등 예외 13종을 주입해도 중단 없이 오류 격리·안내 메시지·오류 로그·재시도·롤백이 동작하는지 시험하는 입력 검증기와 에러 핸들러를 구현한다."""
import copy
import re
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

fleet = make_fleet(300, seed=41)
trend = make_trend(12, seed=5, shock_unit="가상C부대")

# 1) 대시보드: 부대별 12개월 가동률 추세, 최근 3개월 평균, 월 기울기, 경고
SP = "▁▂▃▄▅▆▇█"
def spark(v):
    lo, hi = min(v), max(v)
    return "".join(SP[int((x - lo) / (hi - lo + 1e-9) * 7)] for x in v)
rows = []
for u, g in trend.groupby("unit"):
    m = g.groupby("month")["avail_pct"].mean()
    slope = np.polyfit(m.index, m.values, 1)[0]
    last3 = m.iloc[-3:].mean()
    rows.append({"부대": u, "추세(1~12월)": spark(m.values), "상반기": m.iloc[:6].mean(), "하반기": m.iloc[6:].mean(), "최근3개월": last3, "월 기울기(%p)": slope,
                 "경고": "저하 경고" if slope < -0.8 or last3 < 70 else ""})
dash = pd.DataFrame(rows)
print("[대시보드 1: 부대별 가동률 추세(가상)]")
print(dash.round(1).to_string(index=False))
t_et = trend[trend["month"] > 9].groupby(["unit", "etype"])["avail_pct"].mean().unstack().round(1)
print("\n[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]"); print(t_et.to_string())
lossc = ["parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days"]
loss = fleet.groupby("unit")[lossc].mean().round(1)
loss.columns = ["부품 대기", "정비병 부족", "외주 정비", "외주 검사 대기"]
print("\n[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]")
print(pd.concat([loss, loss.idxmax(axis=1).rename("최대 요인")], axis=1).to_string())
worst = fleet.nsmallest(3, "avail_pct")[["equip_id", "unit", "etype", "avail_pct", "main_cause"]]
print("[가동률 최하위 장비 3대]"); print(worst.to_string(index=False))

# 2) 입력 검증기 + 에러 핸들러
LOG = []
GUIDE = {"E101": "필수 열이 없습니다. 열 이름을 양식과 비교해 추가하세요.", "E102": "빈 자료입니다. 파일이 올바른지 확인하고 다시 올리세요.", "E201": "형식·범위 오류 행을 격리했습니다. 격리 목록을 확인해 원본을 수정하세요.",
         "E202": "중복 장비 번호를 1건만 남겼습니다. 최신 기록이 맞는지 확인하세요.", "E301": "일시적 읽기 오류입니다. 자동 재시도 후에도 실패하면 잠시 뒤 다시 시도하세요.",
         "E401": "처리 중 오류가 있어 변경을 취소(롤백)했습니다. 이전 화면 상태가 유지됩니다.", "E501": "자료 형식을 알 수 없습니다. 표 형식(열 이름 포함) 자료를 올리세요."}


def log(code, msg, level="ERROR"):
    LOG.append({"seq": len(LOG) + 1, "level": level, "code": code, "msg": msg, "guide": GUIDE[code]})


class UserError(Exception):
    def __init__(self, code, msg):
        super().__init__(msg); self.code = code


def validate_frame(df):
    """행 단위 검증: 형식(장비 번호), 허용값(부대), 범위·자료형(숫자), 중복. 불량 행은 격리하고 나머지는 계속 처리한다."""
    if not isinstance(df, pd.DataFrame):
        raise UserError("E501", f"DataFrame이 아닌 입력({type(df).__name__})")
    if len(df) == 0:
        raise UserError("E102", "행 0건")
    miss = [c for c in ["equip_id", "unit", "etype", "avail_pct", "maint_orders"] if c not in df.columns]
    if miss:
        raise UserError("E101", f"누락 열 {miss}")
    d = df.copy()
    num = pd.to_numeric(d["avail_pct"], errors="coerce"), pd.to_numeric(d["maint_orders"], errors="coerce")
    bad = (~d["equip_id"].astype(str).str.match(r"^E-\d{4,6}$")) | (~d["unit"].isin(UNITS)) | (~d["etype"].isin(ETYPES))
    bad |= ~np.isfinite(num[0]) | (num[0] < 0) | (num[0] > 100) | num[1].isna() | (num[1] < 0)
    q = d[bad]
    if len(q):
        log("E201", f"형식·범위 오류 {len(q)}행 격리(예: {str(q.iloc[0]['equip_id'])[:12]!r})", "WARN")
    d = d[~bad].copy()
    d["avail_pct"] = pd.to_numeric(d["avail_pct"]); d["maint_orders"] = pd.to_numeric(d["maint_orders"])
    dup = d.duplicated("equip_id")
    if dup.any():
        log("E202", f"중복 장비 번호 {int(dup.sum())}건 정리", "WARN")
    return d[~dup], q


class Store:
    """화면 상태 저장소: 갱신은 사본에서 먼저 하고 모두 성공해야 반영(트랜잭션 롤백)한다."""
    def __init__(self):
        self.state = {"version": 0, "avg": None}

    def commit(self, fn):
        snap = copy.deepcopy(self.state)
        try:
            fn(self.state); self.state["version"] += 1
            return True
        except Exception as e:
            self.state = snap; log("E401", f"갱신 실패 {type(e).__name__}: {str(e)[:40]}"); return False


STORE = Store()
FLAKY = {"n": 0}


def flaky_loader(df):
    FLAKY["n"] += 1
    if FLAKY["n"] <= 2:
        raise IOError("temporary read error")
    return df


def run(df, loader=None, fail_update=False):
    """대시보드 갱신 1회. 어떤 입력에서도 예외를 밖으로 던지지 않고 (상태, 처리 행수)를 돌려준다."""
    try:
        for attempt in range(3):                                         # 재시도(최대 3회)
            try:
                src = loader(df) if loader else df; break
            except IOError:
                log("E301", f"읽기 오류 재시도 {attempt + 1}/3", "WARN")
        else:
            raise UserError("E301", "재시도 3회 실패")
        clean, q = validate_frame(src)
        def upd(s):
            s["avg"] = float(clean["avail_pct"].mean())
            if fail_update:
                raise RuntimeError("render failure")
        ok = STORE.commit(upd)
        return ("처리" if not len(q) else "격리 후 처리") if ok else "롤백", len(clean)
    except UserError as e:
        log(e.code, str(e)); return "거부(안내)", 0
    except Exception as e:                                               # 예상 못 한 오류도 중단하지 않고 기록
        log("E501", f"{type(e).__name__}: {str(e)[:40]}"); return "거부(안내)", 0


good = fleet[["equip_id", "unit", "etype", "avail_pct", "maint_orders"]]
def mod(**kw):
    d = good.copy()
    for k, v in kw.items():
        d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v
    return d
big = pd.DataFrame({"equip_id": [f"E-{i:06d}" for i in range(200000)], "unit": "가상A부대", "etype": "유형A",
                    "avail_pct": np.random.default_rng(1).uniform(40, 100, 200000), "maint_orders": 2})
big.loc[big.index[::1000], "avail_pct"] = np.nan
cases = [("가동률 결측(NaN)", mod(avail_pct=np.nan)), ("가동률 문자열 '많음'", mod(avail_pct="많음")), ("가동률 음수·100 초과", mod(avail_pct=-5)),
         ("장비 번호 특수문자", mod(equip_id="E-00;DROP --")), ("가상에 없는 부대명", mod(unit="미상부대")), ("무한대(inf) 값", mod(avail_pct=np.inf)),
         ("중복 장비 번호", pd.concat([good, good.head(5)])), ("필수 열 누락", good.drop(columns=["unit"])), ("빈 자료", good.iloc[0:0]),
         ("표가 아닌 입력(None)", None), ("대용량 20만 행(결측 포함)", big), ("읽기 오류 2회 후 성공(재시도)", good), ("갱신 중 오류(롤백)", good)]
print("\n[예외 주입 시험]")
crash = passed = 0
t0 = time.perf_counter()
for name, df in cases:
    n0 = len(LOG)
    try:
        st, n = run(df, loader=flaky_loader if "재시도" in name else None, fail_update="롤백" in name)
    except Exception as e:
        crash += 1; st, n = f"CRASH {e}", 0
    logged = len(LOG) > n0
    passed += (not st.startswith("CRASH")) and logged
    print(f"  {name:22s}-> {st:8s} {n:6d}행 남음 | 로그 {'기록' if logged else '없음'}" + (f" | {LOG[-1]['code']}: {LOG[-1]['guide'][:28]}" if logged else ""))
dt = time.perf_counter() - t0
print(f"예외 {len(cases)}종: 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 로그 기록 {passed}/{len(cases)} ({passed / len(cases):.0%}, 목표 100%), 전체 {dt:.1f}초")
print(f"오류 로그 {len(LOG)}건 중 사용자 안내문 포함 {sum(bool(x['guide']) for x in LOG)}건, 상태 저장소 버전 {STORE.state['version']}(롤백 시 이전 값 유지), 평균 가동률 {STORE.state['avg']:.1f}%")
print("로그 예시:", LOG[0]["code"], LOG[0]["msg"], "->", LOG[0]["guide"])
print("※ 격리된 행의 수정과 경고 판단은 담당자가 확인한다. 로그는 메모리에 두며 실제 도구에서는 감사 로그 파일로 남긴다.")

fig, ax = plt.subplots(1, 2, figsize=(11, 4))
for u, g in trend.groupby("unit"):
    m = g.groupby("month")["avail_pct"].mean(); ax[0].plot(m.index, m.values, label='unit ' + u[2])
ax[0].set_title("Monthly availability by unit (synthetic)"); ax[0].set_xlabel("month"); ax[0].legend(ncol=3, fontsize=7)
loss.plot(kind="bar", stacked=True, ax=ax[1], legend=False); ax[1].set_xticklabels([u[2] for u in loss.index], rotation=0); ax[1].set_title("Mean wait-loss days by cause")
plt.tight_layout(); plt.show()
💡 [참고] 미션 06 예상 실행 결과 (Output)
[대시보드 1: 부대별 가동률 추세(가상)]
   부대    추세(1~12월)  상반기  하반기  최근3개월  월 기울기(%p)    경고
가상A부대 ▃▇▆▇▇▅▃▂▁▂▁▁ 77.3 74.8   74.6       -0.3      
가상B부대 ▃▅▆▅▇▄▂▂▁▁▂▁ 79.4 76.9   76.8       -0.3      
가상C부대 ▆▇▇▇▇▇▆▅▃▂▁▁ 89.4 81.9   79.1       -1.2 저하 경고
가상D부대 ▆▅▆▇▆▄▃▁▁▂▁▃ 80.2 77.6   77.7       -0.3      
가상E부대 ▃▅▇▆▆▇▃▁▁▁▂▂ 79.2 76.1   76.1       -0.3      
가상F부대 ▄▆▇▇▅▆▄▄▂▁▂▃ 83.7 81.0   80.6       -0.3      

[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]
etype   유형A   유형B   유형C
unit                   
가상A부대  72.3  75.1  76.5
가상B부대  73.3  78.2  78.8
가상C부대  76.3  78.8  82.2
가상D부대  74.8  77.8  80.4
가상E부대  73.6  76.1  78.7
가상F부대  78.5  79.9  83.3

[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]
       부품 대기  정비병 부족  외주 정비  외주 검사 대기   최대 요인
unit                                         
가상A부대    5.0     3.5    3.3       2.2   부품 대기
가상B부대    4.3     5.7    3.2       2.1  정비병 부족
가상C부대    2.1     2.0    4.0       1.3   외주 정비
가상D부대    2.5     5.1    5.3       3.5   외주 정비
가상E부대    5.5     4.2    2.6       1.2   부품 대기
가상F부대    2.7     2.4    4.9       1.7   외주 정비
[가동률 최하위 장비 3대]
equip_id  unit etype  avail_pct main_cause
  E-0113 가상D부대   유형A       17.5      외주 정비
  E-0165 가상D부대   유형A       26.3     검사·재작업
  E-0283 가상A부대   유형A       30.2      외주 정비

[예외 주입 시험]
  가동률 결측(NaN)           -> 격리 후 처리     297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
  가동률 문자열 '많음'          -> 격리 후 처리     297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
  가동률 음수·100 초과         -> 격리 후 처리     297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
  장비 번호 특수문자            -> 격리 후 처리     297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
  가상에 없는 부대명            -> 격리 후 처리     297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
... (이하 생략)

🎯 미션 07. 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 정비 자원 우선 투입 안을 정비담당-정비관-품질검사관-군수참모 순서로 사람이 검토·결정하는 HITL 워크플로우를 설계하고 권한 통제와 이력 무결성을 검증한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 역할별 권한(RBAC)과 단계 순서를 정의하고 시스템은 제안만, 확정은 사람만 하도록 작성하여 원인을 분석하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/workflow/qlt04_approval_system.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 군 보안 규정 및 행정 절차 부합성

미션 PASS 조건

  • 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%

  • 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현

  • 검증 방법: 권한별 접근 제어(RBAC) 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""저가동 장비의 정비 자원 우선 투입안을 정비담당 상신 -> 정비관 검토 -> 품질검사관 검토 -> 군수참모 결재의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·반려 사유 필수화·재기안 연계·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----
# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.
RULES_YAML = """
- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: "<", value: 50, weight: 2, clause: 가상품질지침 제3조}
- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: "<", value: 60, weight: 2, clause: 가상품질지침 제4조}
- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제5조}
- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제6조}
- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: ">", value: 15, weight: 2, clause: 가상품질지침 제7조}
- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: ">", value: 6, weight: 3, clause: 가상품질지침 제8조}
- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: ">=", value: 1, weight: 3, clause: 가상품질지침 제9조}
- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: ">", value: 0.25, weight: 2, clause: 가상품질지침 제9조}
- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: ">=", value: 3, weight: 3, clause: 가상품질지침 제10조}
- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: ">=", value: 3, weight: 2, clause: 가상품질지침 제11조}
- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: ">", value: 15, weight: 1, clause: 가상품질지침 제12조}
- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: ">=", value: 6, weight: 1, clause: 가상품질지침 제13조}
- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: ">", value: 30, weight: 3, clause: 가상품질지침 제14조}
- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: ">", value: 0.8, weight: 2, clause: 가상품질지침 제8조}
- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: "==", value: true, weight: 3, clause: 가상품질지침 제10조}
- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: "<", value: 150, weight: 1, clause: 가상품질지침 제15조}
"""
GRADE_CUT = [(10, "고위험"), (6, "주의"), (3, "관심")]
OPS = {"<": lambda a, b: a < b, ">": lambda a, b: a > b, "<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b, "==": lambda a, b: a == b}


def parse_rules(text):
    """YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이)."""
    out = []
    for line in text.strip().splitlines():
        body = line.strip()[3:-1]
        r = {}
        for part in body.split(", "):
            k, v = part.split(": ", 1)
            v = v.strip().strip('"')
            r[k] = True if v == "true" else False if v == "false" else (float(v) if v.replace(".", "", 1).isdigit() else v)
        out.append(r)
    return out


def derive_rec(rec):
    """룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다."""
    r = dict(rec)
    r["total_wait"] = r["parts_wait_days"] + r["labor_wait_days"] + r["outsource_days"] + r["inspect_wait_days"]
    r["rework_rate"] = r["rework_count"] / r["maint_orders"] if r["maint_orders"] else 0.0
    r["insp_ratio"] = r["inspect_wait_days"] / r["outsource_days"] if r["outsource_days"] >= 5 else 0.0
    r["spec_defect"] = bool(r["spec_mismatch"] >= 2 and r["defect_reports"] >= 1)
    return r


def apply_rules(rec, rules, derived=False):
    """룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다."""
    r = rec if derived else derive_rec(rec)
    hits = [x["id"] for x in rules if OPS[x["op"]](r[x["field"]], x["value"])]
    w = {x["id"]: x["weight"] for x in rules}
    return sum(w[h] for h in hits), hits


def grade_of(score):
    for cut, g in GRADE_CUT:
        if score >= cut:
            return g
    return "정상"

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 하고 확정 권한이 없다 ----
PERM = {"시스템": {"propose"}, "정비담당": {"submit", "redraft", "view"}, "정비관": {"review_maint", "reject", "view"}, "품질검사관": {"review_quality", "reject", "view"},
        "군수참모": {"approve", "reject", "view"}, "감사관": {"view", "audit"}, "부서장": {"submit", "approve", "reject", "view"}}      # 부서장은 상신·결재를 겸할 수 있으나 같은 건은 분리
NEXT = {"제안": ("submit", "상신"), "상신": ("review_maint", "정비관검토"), "정비관검토": ("review_quality", "품질검사"), "품질검사": ("approve", "승인")}
USERS = {"sys": "시스템", "kim": "정비담당", "lee": "정비관", "park": "품질검사관", "choi": "군수참모", "jung": "감사관", "oh": "부서장"}
MAX_REDRAFT = 2


class Chain:
    """해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다."""
    def __init__(self):
        self.log = []

    def add(self, **body):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {"seq": len(self.log), **body}
        self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})

    def verify(self):
        prev = "GENESIS"
        for e in self.log:
            b = {k: v for k, v in e.items() if k not in ("prev", "hash")}
            if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
                return e["seq"]
            prev = e["hash"]
        return None


class Workflow:
    def __init__(self):
        self.audit, self.cases, self.blocked = Chain(), {}, []

    def _deny(self, who, case, action, why):
        self.audit.add(event="DENIED", who=who, case=case, action=action, reason=why)
        self.blocked.append((who, case, action, why)); return False, why

    def act(self, who, case, action, reason="", evidence=""):
        role = USERS.get(who)
        if role is None:
            return self._deny(who, case, action, "미등록 사용자")
        if action == "delete_log":
            return self._deny(who, case, action, "감사 로그 삭제·수정 권한은 누구에게도 없음")
        if action not in PERM[role]:
            return self._deny(who, case, action, f"권한 없음({role} 역할에 {action} 권한 없음)")
        if action == "propose":
            self.cases[case] = {"state": "제안", "submitter": None, "version": 1}
            self.audit.add(event="PROPOSE", who=who, case=case, reason=reason); return True, "제안 등록"
        c = self.cases.get(case)
        if c is None:
            return self._deny(who, case, action, "존재하지 않는 건")
        if c["state"] in ("승인", "종결(반려)"):
            return self._deny(who, case, action, f"이미 종결된 건({c['state']})")
        if not reason.strip() or not evidence.strip():
            return self._deny(who, case, action, "사유·근거 입력 필수(반려 사유 포함)")
        if action == "reject":
            if c["state"] in ("제안", "반려"):
                return self._deny(who, case, action, f"반려할 수 없는 단계({c['state']})")
            c["state"] = "종결(반려)" if c["version"] > MAX_REDRAFT else "반려"
            self.audit.add(event="REJECTED", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, c["state"]
        if action == "redraft":
            if c["state"] != "반려":
                return self._deny(who, case, action, f"재기안은 반려된 건만 가능(현재 {c['state']})")
            if who != c["submitter"]:
                return self._deny(who, case, action, "재기안은 최초 상신자만 가능")
            c["state"], c["version"] = "상신", c["version"] + 1
            self.audit.add(event="REDRAFT", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, "재기안 상신"
        if c["state"] == "반려":
            return self._deny(who, case, action, "반려된 건은 재기안 후에만 진행 가능")
        need, nxt = NEXT[c["state"]]
        if action != need:
            return self._deny(who, case, action, f"단계 위반(현재 {c['state']}, 필요 행위 {need})")
        if action == "submit":
            c["submitter"] = who
        if action == "approve" and who == c["submitter"]:
            return self._deny(who, case, action, "상신자와 승인자 분리 원칙 위반")
        c["state"] = nxt
        self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c["version"], to=nxt, reason=reason, evidence=evidence); return True, nxt


# ---- 상신 대상: 룰 점수가 높은 장비 12대와 모델이 추천한 개선 조치 ----
fleet = make_fleet(300, seed=7)
rules = parse_rules(RULES_YAML)
sc = [apply_rules(r, rules) for r in fleet.drop(columns=["main_cause"]).to_dict("records")]
fleet["score"], fleet["hits"] = [x[0] for x in sc], [x[1] for x in sc]
clf = fit_cause(make_fleet(1200, seed=21))
picked = fleet.sort_values("score", ascending=False).head(12)
rec_cause = clf.predict(cause_frame(picked))
rng = np.random.default_rng(1)
QUALITY = {"Q06", "Q07", "Q08", "Q09", "Q10", "Q14", "Q15"}                  # 품질검사관이 보는 항목(검사 대기·재작업·규격 불일치·하자)

wf = Workflow()
rows = []
for k, ((_, p), cause) in enumerate(zip(picked.iterrows(), rec_cause), 1):
    case = f"C{k:02d}"
    wf.act("sys", case, "propose", reason=f"{p['equip_id']} 룰 점수 {p['score']:.0f}, 추천 요인 {cause} -> {ACTION[cause]}")
    wf.act("kim", case, "submit", "정비 자원 우선 투입 검토 요청", f"룰 적중 {','.join(p['hits'][:4])}, 추천 조치 {ACTION[cause]}")
    q_issue = sorted(QUALITY & set(p["hits"])) if len(QUALITY & set(p["hits"])) >= 2 else []                              # 품질검사관이 볼 문제
    m_issue = (p["avail_pct"] < 40 and cause == "정비병 부족")                # 정비관이 볼 문제: 인력 이동 시 제공 부대 영향 소명 필요
    note = "군수참모 결정"
    for _ in range(14):
        state = wf.cases[case]["state"]
        if state in ("승인", "종결(반려)"):
            break
        if state == "상신":
            if m_issue:
                wf.act("lee", case, "reject", "인력 이동 안은 제공 부대 영향 소명 필요", f"가동률 {p['avail_pct']}%, 요인 {cause}")
            else:
                wf.act("lee", case, "review_maint", "정비 능력·일정 검토 완료", f"추천 조치 {ACTION[cause]} 수행 가능")
        elif state == "정비관검토":
            if rng.random() < 0.15:
                note = "자동 확정 없음: 품질검사 응답 대기"; break                  # 무응답은 보류로 둔다
            if q_issue:
                wf.act("park", case, "reject", f"규격·검사 조건 보완 필요({','.join(q_issue)})", "재작업·규격 불일치·검사 대기 항목 점검")
            else:
                wf.act("park", case, "review_quality", "품질검사 검토 완료", "규격·검사 항목 이상 없음 또는 보완 확인")
        elif state == "품질검사":
            wf.act("choi", case, "approve", "승인: 자원 우선 투입 진행", f"룰 점수 {p['score']:.0f}, 검토 단계 완료")
        elif state == "반려":
            wf.act("kim", case, "redraft", "반려 사유 반영해 수정", f"보완 대상 {q_issue or ['제공 부대 영향 소명']}, 재기안 {wf.cases[case]['version']}차")
            m_issue = False                                                 # 제공 부대 영향 소명서를 첨부(모의)
            if rng.random() < 0.65:                                          # 재기안 후 보완이 끝날 확률(모의)
                q_issue = []
    final = wf.cases[case]["state"]
    if final not in ("승인", "종결(반려)") and note.startswith("자동"):
        final = "보류(무응답)"
    elif final == "종결(반려)":
        note = "재기안 한도(2회) 초과"
    rows.append((case, p["equip_id"], final, wf.cases[case]["version"], note))
print("[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)")
print(pd.DataFrame(rows, columns=["건", "장비", "최종 상태", "버전", "비고"]).to_string(index=False))
st = pd.Series([r[2] for r in rows]).value_counts().to_dict()
print("상태 집계:", st, f"/ 재기안 {sum(e['event'] == 'REDRAFT' for e in wf.audit.log)}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)")

# ---- 권한 우회·절차 위반 시도 ----
wf.act("sys", "T01", "propose", reason="시험 건"); wf.act("kim", "T01", "submit", "시험 상신", "근거")
attempts = [
    ("시스템 자동 승인", lambda: wf.act("sys", "T01", "approve", "자동", "자동")),
    ("정비담당이 정비관 검토 대행", lambda: wf.act("kim", "T01", "review_maint", "대행", "근거")),
    ("검토 단계 건너뛰고 승인", lambda: wf.act("choi", "T01", "approve", "바로 승인", "근거")),
    ("미등록 사용자 접근", lambda: wf.act("guest", "T01", "review_maint", "열람", "근거")),
    ("사유 없는 검토", lambda: wf.act("lee", "T01", "review_maint", "", "")),
    ("사유 없는 반려", lambda: wf.act("lee", "T01", "reject", "", "근거")),
    ("감사 로그 삭제", lambda: wf.act("kim", "T01", "delete_log", "정리", "근거")),
    ("감사관이 승인 시도", lambda: wf.act("jung", "T01", "approve", "승인", "근거")),
    ("종결된 건 재승인", lambda: wf.act("choi", next(c for c, v in wf.cases.items() if v["state"] == "승인"), "approve", "재승인", "근거")),
    ("존재하지 않는 건 승인", lambda: wf.act("choi", "X99", "approve", "승인", "근거")),
]
wf.act("sys", "T02", "propose", reason="시험"); wf.act("oh", "T02", "submit", "상신", "근거")
wf.act("lee", "T02", "review_maint", "정비 검토", "이상 없음"); wf.act("park", "T02", "review_quality", "품질 검토", "이상 없음")
attempts.append(("상신자가 본인 건 승인(분리 원칙)", lambda: wf.act("oh", "T02", "approve", "본인 승인", "근거")))
wf.act("sys", "T03", "propose", reason="시험"); wf.act("kim", "T03", "submit", "상신", "근거"); wf.act("lee", "T03", "reject", "수정 필요", "근거")
attempts.append(("반려 건을 재기안 없이 검토 진행", lambda: wf.act("lee", "T03", "review_maint", "그대로 진행", "근거")))
attempts.append(("타인이 대신 재기안", lambda: wf.act("oh", "T03", "redraft", "대신 수정", "근거")))
print("\n[권한 우회·절차 위반 시도]")
blocked = 0
for name, fn in attempts:
    ok, why = fn(); blocked += (not ok)
    print(f"  {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}")
print(f"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})")

# ---- RBAC 매트릭스와 이력 무결성 ----
acts = ["propose", "submit", "review_maint", "review_quality", "approve", "reject", "redraft", "audit"]
mx = pd.DataFrame([[("O" if a in PERM[r] else "-") for a in acts] for r in PERM], index=list(PERM), columns=["제안", "상신", "정비검토", "품질검토", "승인", "반려", "재기안", "감사"])
print("\n[역할별 권한 매트릭스(O 허용 / - 불가)]"); print(mx.to_string())
bad = wf.audit.verify()
print(f"\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})")
idx = 10; saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)
wf.audit.log[idx]["reason"] = "사후 수정"; v = wf.audit.verify()
print(f"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}")
wf.audit.log[idx] = json.loads(saved)
print("\n[이력 조회 화면] C02 건")
for e in wf.audit.log:
    if e.get("case") == "C02":
        print(f"  #{e['seq']:03d} {e['event']:14s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}")
print("※ 정비 우선순위와 자원 투입 결정은 사람(정비관·품질검사관·군수참모)이 하며, 시스템은 위험 표시와 추천만 제공한다. 결재선은 소속 기관 규정을 따른다.")

plt.figure(figsize=(6, 3.5)); EN = {"승인": "approved", "반려": "rejected", "종결(반려)": "closed-rejected", "보류(무응답)": "on hold"}
plt.bar([EN.get(k, k) for k in st], list(st.values()), color="tab:blue"); plt.title("HITL workflow outcomes (12 cases)"); plt.ylabel("cases"); plt.tight_layout(); plt.show()
💡 [참고] 미션 07 예상 실행 결과 (Output)
[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)
  건     장비   최종 상태  버전                   비고
C01 E-0047 보류(무응답)   2 자동 확정 없음: 품질검사 응답 대기
C02 E-0231      승인   2              군수참모 결정
C03 E-0041      승인   2              군수참모 결정
C04 E-0050 보류(무응답)   1 자동 확정 없음: 품질검사 응답 대기
C05 E-0220      승인   2              군수참모 결정
C06 E-0230      승인   1              군수참모 결정
C07 E-0095      승인   1              군수참모 결정
C08 E-0109      승인   2              군수참모 결정
C09 E-0163      승인   2              군수참모 결정
C10 E-0076      승인   2              군수참모 결정
C11 E-0028      승인   3              군수참모 결정
C12 E-0233      승인   2              군수참모 결정
상태 집계: {'승인': 10, '보류(무응답)': 2} / 재기안 10회, 반려 이력 10건(모두 사유·근거 기록)

[권한 우회·절차 위반 시도]
  시스템 자동 승인             -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)
  정비담당이 정비관 검토 대행       -> 차단: 권한 없음(정비담당 역할에 review_maint 권한 없음)
  검토 단계 건너뛰고 승인         -> 차단: 단계 위반(현재 상신, 필요 행위 review_maint)
  미등록 사용자 접근            -> 차단: 미등록 사용자
  사유 없는 검토              -> 차단: 사유·근거 입력 필수(반려 사유 포함)
  사유 없는 반려              -> 차단: 사유·근거 입력 필수(반려 사유 포함)
  감사 로그 삭제              -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음
  감사관이 승인 시도            -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)
  종결된 건 재승인             -> 차단: 이미 종결된 건(승인)
  존재하지 않는 건 승인          -> 차단: 존재하지 않는 건
  상신자가 본인 건 승인(분리 원칙)   -> 차단: 상신자와 승인자 분리 원칙 위반
  반려 건을 재기안 없이 검토 진행    -> 차단: 반려된 건은 재기안 후에만 진행 가능
  타인이 대신 재기안            -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)
우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)

[역할별 권한 매트릭스(O 허용 / - 불가)]
      제안 상신 정비검토 품질검토 승인 반려 재기안 감사
시스템    O  -    -    -  -  -   -  -
정비담당   -  O    -    -  -  -   O  -
정비관    -  -    O    -  -  O   -  -
품질검사관  -  -    -    O  -  O   -  -
군수참모   -  -    -    -  O  O   -  -
감사관    -  -    -    -  -  -   -  O
... (이하 생략)

🎯 미션 08. 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증

소요시간: 약 3.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

전군 장비 가동률 데이터 일괄 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 12개월치 가상 장비 가동률 자료를 월 단위 배치로 일괄 분석하고, 오류·장애 주입과 자료량 500% 부하에서 성능과 안정성을 검증한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 월 배치를 적재-검증-분석-보고 4단계로 자동 실행하는 스케줄러 모사 작성하여 원인을 분석하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/pipeline/qlt04_batch_runner.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 수작업 대비 업무 소요 시간 80% 이상 절감 효과

미션 PASS 조건

  • 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상

  • 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인

  • 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""12개월치 가상 장비 가동률 자료를 월 단위 배치(적재 -> 검증·격리 -> 룰·모델 분석 -> 보고)로 자동 실행하는 스케줄러 모사를 만들고, 오류 레코드·일시 장애 주입과 자료량 100%·200%·500% 부하에서 실행 시간·성공률·자료 유실률·수작업 대비 절감을 측정한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----
# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.
RULES_YAML = """
- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: "<", value: 50, weight: 2, clause: 가상품질지침 제3조}
- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: "<", value: 60, weight: 2, clause: 가상품질지침 제4조}
- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제5조}
- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제6조}
- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: ">", value: 15, weight: 2, clause: 가상품질지침 제7조}
- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: ">", value: 6, weight: 3, clause: 가상품질지침 제8조}
- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: ">=", value: 1, weight: 3, clause: 가상품질지침 제9조}
- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: ">", value: 0.25, weight: 2, clause: 가상품질지침 제9조}
- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: ">=", value: 3, weight: 3, clause: 가상품질지침 제10조}
- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: ">=", value: 3, weight: 2, clause: 가상품질지침 제11조}
- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: ">", value: 15, weight: 1, clause: 가상품질지침 제12조}
- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: ">=", value: 6, weight: 1, clause: 가상품질지침 제13조}
- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: ">", value: 30, weight: 3, clause: 가상품질지침 제14조}
- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: ">", value: 0.8, weight: 2, clause: 가상품질지침 제8조}
- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: "==", value: true, weight: 3, clause: 가상품질지침 제10조}
- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: "<", value: 150, weight: 1, clause: 가상품질지침 제15조}
"""
GRADE_CUT = [(10, "고위험"), (6, "주의"), (3, "관심")]
OPS = {"<": lambda a, b: a < b, ">": lambda a, b: a > b, "<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b, "==": lambda a, b: a == b}


def parse_rules(text):
    """YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이)."""
    out = []
    for line in text.strip().splitlines():
        body = line.strip()[3:-1]
        r = {}
        for part in body.split(", "):
            k, v = part.split(": ", 1)
            v = v.strip().strip('"')
            r[k] = True if v == "true" else False if v == "false" else (float(v) if v.replace(".", "", 1).isdigit() else v)
        out.append(r)
    return out


def derive_rec(rec):
    """룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다."""
    r = dict(rec)
    r["total_wait"] = r["parts_wait_days"] + r["labor_wait_days"] + r["outsource_days"] + r["inspect_wait_days"]
    r["rework_rate"] = r["rework_count"] / r["maint_orders"] if r["maint_orders"] else 0.0
    r["insp_ratio"] = r["inspect_wait_days"] / r["outsource_days"] if r["outsource_days"] >= 5 else 0.0
    r["spec_defect"] = bool(r["spec_mismatch"] >= 2 and r["defect_reports"] >= 1)
    return r


def apply_rules(rec, rules, derived=False):
    """룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다."""
    r = rec if derived else derive_rec(rec)
    hits = [x["id"] for x in rules if OPS[x["op"]](r[x["field"]], x["value"])]
    w = {x["id"]: x["weight"] for x in rules}
    return sum(w[h] for h in hits), hits


def grade_of(score):
    for cut, g in GRADE_CUT:
        if score >= cut:
            return g
    return "정상"

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

rules = parse_rules(RULES_YAML)
model = fit_avail(make_fleet(1200, seed=21))
BASE_N = 1000                                                   # 월 기준 장비 수(가상)
MANUAL_MIN = {"적재·정리": 0.5, "검증": 0.5, "분석": 1.5, "보고": 0.5}      # 장비 1대당 수작업 시간(분, 가정치)
CHECK_MIN = 6                                                   # 자동 분석 후 정비관이 고위험 1대를 확인하는 시간(분, 가정치)
rng = np.random.default_rng(8)
LOGS = []


def load(month, n, bad_rate=0.01):
    df = make_fleet(n, seed=100 + month)
    k = max(1, int(n * bad_rate))
    bad = rng.choice(n, k, replace=False)
    df["avail_pct"] = df["avail_pct"].astype(float)
    df.loc[bad[: k // 2], "avail_pct"] = np.nan                  # 결측
    df.loc[bad[k // 2:], "avail_pct"] = 150.0                    # 범위 오류
    return df


def validate(df):
    ok = df["avail_pct"].between(0, 100)
    return df[ok], df[~ok]


def analyze(df):
    recs = df.drop(columns=["main_cause"]).to_dict("records")
    sc = [apply_rules(r, rules)[0] for r in recs]
    pred = np.clip(model.predict(feat_frame(df)), 0, 100)
    return {"n": len(df), "avail": df["avail_pct"].mean(), "high": sum(s >= 10 for s in sc), "caution": sum(6 <= s < 10 for s in sc), "mae": np.mean(np.abs(pred - df["avail_pct"]))}


FAIL_ONCE = {(3, "분석"), (9, "적재")}                            # 일시 장애 주입(재시도 후 성공해야 함)
FAIL_ALWAYS = set()                                               # 지속 장애는 아래 별도 시험에서 주입(격리·알림 후 다음 달 계속)
tried = set()


def step(month, name, fn):
    for attempt in (1, 2):
        try:
            if (month, name) in FAIL_ONCE and (month, name) not in tried:
                tried.add((month, name)); raise IOError("temporary failure")
            if (month, name) in FAIL_ALWAYS:
                raise RuntimeError("persistent failure")
            return True, fn()
        except Exception as e:
            LOGS.append(f"{month:02d}월 {name} 시도 {attempt} 실패: {e}")
    return False, None


def run_batch(n, label, months=range(1, 13), verbose=False):
    t0 = time.perf_counter(); res, steps, ok_steps, quarantined, seen = [], 0, 0, 0, 0
    for m in months:
        ctx = {}
        for name, fn in [("적재", lambda: ctx.update(df=load(m, n))), ("검증", lambda: ctx.update(zip(("clean", "bad"), validate(ctx["df"])))),
                         ("분석", lambda: ctx.update(r=analyze(ctx["clean"]))), ("보고", lambda: ctx["r"])]:
            steps += 1
            ok, _ = step(m, name, fn)
            ok_steps += ok
            if not ok:
                break
        if "r" in ctx and ok:
            res.append({"월": m, **ctx["r"], "격리": len(ctx["bad"])})
        if "df" in ctx:
            seen += len(ctx["df"]); quarantined += len(ctx.get("bad", [])) if "bad" in ctx else 0
        if verbose:
            print(f"  진행 [{'#' * m}{'.' * (12 - m)}] {m:2d}/12월 {'완료' if ok else '실패(알림 발송, 다음 달 계속)'}")
    return pd.DataFrame(res), time.perf_counter() - t0, ok_steps, steps


# 1) 12개월 배치(스케줄러 모사)
print("[월 배치 실행: 장애 주입 포함]")
rs, dt, ok_s, steps = run_batch(BASE_N, "x1", verbose=True)
print(f"소요 {dt:.1f}초(목표 5분=300초 이내: {'달성' if dt < 300 else '미달'}), 단계 {ok_s}/{steps} 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 {ok_s / steps:.1%} (목표 99% 이상: {'달성' if ok_s / steps >= 0.99 else '미달'})")
print("재시도·실패 로그:", " / ".join(LOGS[:3]), "...", f"(총 {len(LOGS)}건)")
print("[알림] 배치 완료: 월 12건 중 %d건 보고 생성, 실패 월 %s" % (len(rs), sorted(set(range(1, 13)) - set(rs["월"])) or "없음"))
print(rs.round(1).head(4).to_string(index=False), "\n  ...")

# 2) 지속 장애 시험(11월 보고 단계가 계속 실패): 해당 월만 건너뛰고 알림, 나머지 월은 계속(기능 축소 작동)
FAIL_ONCE.clear(); FAIL_ALWAYS.add((11, "보고")); LOGS.clear()
rp, dp, okp, stp = run_batch(BASE_N, "fault")
print(f"\n[지속 장애 시험] 단계 {okp}/{stp} 성공({okp / stp:.1%}), 보고 생성 {len(rp)}/12월, 실패 월 {sorted(set(range(1, 13)) - set(rp['월']))} -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)")

# 3) 장애 없는 배치의 자료 유실 점검
FAIL_ALWAYS.clear(); LOGS.clear()
rs0, dt0, ok0, st0 = run_batch(BASE_N, "clean")
tot_in = BASE_N * 12
tot_out = int(rs0["n"].sum() + rs0["격리"].sum())
print(f"\n[장애 없는 배치] 단계 {ok0}/{st0} 성공({ok0 / st0:.1%}), 입력 {tot_in}건 = 분석 {int(rs0['n'].sum())} + 격리 {int(rs0['격리'].sum())} -> 유실 {tot_in - tot_out}건({(tot_in - tot_out) / tot_in:.1%})")

# 4) 부하 시험: 자료량 100%·200%·500%
print("\n[부하 시험: 월 자료량 증가 (12개월 전체)]")
lt = []
for mult in (1, 2, 5):
    r, d, o, s = run_batch(BASE_N * mult, f"x{mult}")
    lost = BASE_N * mult * 12 - int(r["n"].sum() + r["격리"].sum())
    lt.append((mult, d, o / s, lost))
    print(f"  자료 {mult * 100:3d}%: {BASE_N * mult * 12:6d}건, {d:5.1f}초, 성공 {o}/{s}, 유실 {lost}건, 초당 {BASE_N * mult * 12 / d:,.0f}건")
print(f"시스템 다운 0건, 데이터 유실 {sum(x[3] for x in lt)}건 (목표 0%: {'달성' if sum(x[3] for x in lt) == 0 else '미달'})")

# 5) 수작업 대비 절감(가정치 기반 상대 비교)
n_equip = BASE_N * 12
manual_h = n_equip * sum(MANUAL_MIN.values()) / 60
check_h = int(rs0["high"].sum()) * CHECK_MIN / 60
auto_h = dt0 / 3600 + check_h
print(f"\n[업무 시간 절감] 수작업 {manual_h:.0f}시간(장비당 {sum(MANUAL_MIN.values()):.1f}분) vs 자동 {auto_h:.1f}시간(실행 {dt0:.1f}초 + 고위험 {int(rs0['high'].sum())}대 확인 {check_h:.1f}시간) -> 절감 {1 - auto_h / manual_h:.0%} (목표 80% 이상: {'달성' if 1 - auto_h / manual_h >= 0.8 else '미달'})")
print("※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.")

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].plot(rs0["월"], rs0["avail"], marker="o"); ax[0].set_title("Monthly mean availability (synthetic)"); ax[0].set_xlabel("month")
ax[1].bar([f"x{x[0]}" for x in lt], [x[1] for x in lt], color="tab:blue"); ax[1].set_title("Batch time by data volume (sec)")
plt.tight_layout(); plt.show()
💡 [참고] 미션 08 예상 실행 결과 (Output)
[월 배치 실행: 장애 주입 포함]
  진행 [#...........]  1/12월 완료
  진행 [##..........]  2/12월 완료
  진행 [###.........]  3/12월 완료
  진행 [####........]  4/12월 완료
  진행 [#####.......]  5/12월 완료
  진행 [######......]  6/12월 완료
  진행 [#######.....]  7/12월 완료
  진행 [########....]  8/12월 완료
  진행 [#########...]  9/12월 완료
  진행 [##########..] 10/12월 완료
  진행 [###########.] 11/12월 완료
  진행 [############] 12/12월 완료
소요 0.3초(목표 5분=300초 이내: 달성), 단계 48/48 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 100.0% (목표 99% 이상: 달성)
재시도·실패 로그: 03월 분석 시도 1 실패: temporary failure / 09월 적재 시도 1 실패: temporary failure ... (총 2건)
[알림] 배치 완료: 월 12건 중 12건 보고 생성, 실패 월 없음
 월   n  avail  high  caution  mae  격리
 1 990   79.1    80      103  4.0  10
 2 990   79.9    85       79  4.0  10
 3 990   78.6    86      108  4.0  10
 4 990   79.7    79       84  4.1  10 
  ...

[지속 장애 시험] 단계 47/48 성공(97.9%), 보고 생성 11/12월, 실패 월 [11] -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)

[장애 없는 배치] 단계 48/48 성공(100.0%), 입력 12000건 = 분석 11880 + 격리 120 -> 유실 0건(0.0%)

[부하 시험: 월 자료량 증가 (12개월 전체)]
  자료 100%:  12000건,   0.3초, 성공 48/48, 유실 0건, 초당 37,904건
  자료 200%:  24000건,   0.6초, 성공 48/48, 유실 0건, 초당 41,507건
  자료 500%:  60000건,   1.1초, 성공 48/48, 유실 0건, 초당 52,896건
시스템 다운 0건, 데이터 유실 0건 (목표 0%: 달성)

[업무 시간 절감] 수작업 600시간(장비당 3.0분) vs 자동 98.3시간(실행 0.3초 + 고위험 983대 확인 98.3시간) -> 절감 84% (목표 80% 이상: 달성)
※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.

🎯 미션 09. AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

AAR 기반 가동률 예측치와 실제치 편차를 분석하고 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, AAR로 가동률 예측치와 실제치의 편차를 분석해 원인을 규명하고, 분석 파라미터를 재보정해 정확도 개선 효과와 UAT 점검 결과를 확인한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 평시 자료로 학습한 모델의 예측을 외주 검사 병목이 악화된 새 분기 실측과 부대·유형 셀 단위로 비교해 편향과 원인을 요인별로 분해 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: docs/qlt04_uat_feedback_report.md 및 패치 내역 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 실무 업무 생산성 기여도 및 현장 적응성

미션 PASS 조건

  • 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료

  • 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료

  • 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""가동률 예측치와 실제치의 편차를 부대·유형 셀 단위로 AAR 분석해 오차 원인(외주 검사 병목 악화)을 요인별로 분해하고, 편향 보정·병목 변수 추가·가중 재학습·정규화 강도 조정·예측 구간의 5개 재보정 패치를 단계별로 검증해 채택 여부를 정하며 UAT 체크리스트와 재보정 파라미터(YAML 문자열)를 출력한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import KFold

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

# ---- 평시 자료로 학습한 기존 예측 모델과, 외주 검사 병목이 악화된 새 분기(실제) ----
hist = make_fleet(900, seed=21)
m0 = fit_sim(hist)
now = make_fleet(1800, seed=51, shock=4.0)                       # 실제: 외주 검사 대기 병목 악화(shock) -> 기존 모델은 모름
aar, test = now.iloc[:600], now.iloc[600:]                       # AAR 표본(분기 초 실측) / 이후 검증 자료
CELL = ["unit", "etype"]


def cells(df, pred):
    t = df[CELL].copy(); t["actual"], t["pred"] = df["avail_pct"].to_numpy(), pred
    g = t.groupby(CELL).mean(); g["err"] = g["actual"] - g["pred"]; return g


def score(df, pred, tol=3.0):
    c = cells(df, pred)
    return {"hit": float((c["err"].abs() <= tol).mean()), "mae": float(np.mean(np.abs(df["avail_pct"] - pred))), "bias": float((df["avail_pct"] - pred).mean()), "cells": c}


# 1) AAR: 예측치와 실제치의 편차
p_aar = m0.predict(sim_frame(aar))
s0 = score(aar, p_aar)
print(f"[AAR 편차 분석] 예측 대비 실제: 평균 편향 {s0['bias']:+.2f}%p(실제가 낮음), 개별 MAE {s0['mae']:.2f}%p, 셀 18곳 중 오차 3%p 이내 {s0['hit']:.0%}")
worst = s0["cells"].sort_values("err").head(3)
print("오차가 큰 셀:", ", ".join(f"{i[0]}/{i[1]} {r.err:+.1f}%p" for i, r in worst.iterrows()))
comp = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "외주 검사 대기": "inspect_wait_days", "재작업": "rework_count"}
contrib = {k: -(aar[c].mean() - hist[c].mean()) / WINDOW * 100 * (4 if c == "rework_count" else 1) for k, c in comp.items()}
print("오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p):", ", ".join(f"{k} {v:+.2f}" for k, v in contrib.items()))
cause = min(contrib, key=contrib.get)
print(f"-> 가장 큰 원인: {cause} (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영")

# 2) 재보정 패치 후보를 AAR 표본으로 만들고 검증 자료(test)로 평가
b_global = s0["bias"]
b_cell = s0["cells"]["err"]
unit_insp = aar.groupby("unit")["inspect_wait_days"].mean()            # P2: 부대별 최근 외주 검사 대기(병목 변수)


def sim2(df):
    x = sim_frame(df).copy(); x["insp_x_orders"] = df["unit"].map(unit_insp).to_numpy() * df["maint_orders"].to_numpy(); return x


def fit2(alpha, w):
    tr = pd.concat([hist.assign(_w=1.0), aar.assign(_w=w)]); x = sim2(tr)
    sc = StandardScaler().fit(x); return sc, Ridge(alpha=alpha).fit(sc.transform(x), tr["avail_pct"], sample_weight=tr["_w"])


for_cv = {}
for a in (0.1, 0.5, 2.0, 10.0, 50.0):                                  # P4: 정규화 강도를 AAR 표본 5분할 교차검증으로 선택
    e = []
    for tri, vai in KFold(5, shuffle=True, random_state=0).split(aar):
        tr = pd.concat([hist.assign(_w=1.0), aar.iloc[tri].assign(_w=3.0)]); x = sim2(tr); sc = StandardScaler().fit(x)
        r = Ridge(alpha=a).fit(sc.transform(x), tr["avail_pct"], sample_weight=tr["_w"])
        e.append(np.mean(np.abs(r.predict(sc.transform(sim2(aar.iloc[vai]))) - aar.iloc[vai]["avail_pct"])))
    for_cv[a] = float(np.mean(e))
alpha = min(for_cv, key=for_cv.get)
sc3, r3 = fit2(0.5, 3.0); sc4, r4 = fit2(alpha, 3.0)
cand = {"원 모델": m0.predict(sim_frame(test)),
        "P1 전체 편향 보정": m0.predict(sim_frame(test)) + b_global,
        "P1' 셀별 편향 보정": m0.predict(sim_frame(test)) + test[CELL].apply(tuple, axis=1).map(b_cell.to_dict()).to_numpy(),
        "P2+P3 병목 변수+최근 자료 가중": r3.predict(sc3.transform(sim2(test))),
        f"P4 +정규화 강도 {alpha}": r4.predict(sc4.transform(sim2(test)))}
print("\n[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]")
res = {}
for k, p in cand.items():
    res[k] = score(test, p); print(f"  {k:26s} 셀 적중 {res[k]['hit']:4.0%}  개별 MAE {res[k]['mae']:5.2f}  편향 {res[k]['bias']:+5.2f}")
best = min((k for k in cand if k != "원 모델"), key=lambda k: (-res[k]["hit"], res[k]["mae"]))
b = res[best]; o = res["원 모델"]
print(f"채택: {best} -> 셀 적중 {o['hit']:.0%} -> {b['hit']:.0%} ({(b['hit'] - o['hit']) * 100:+.0f}%p, 목표 +10%p 이상: {'달성' if b['hit'] - o['hit'] >= 0.10 else '미달'}), MAE {o['mae']:.2f} -> {b['mae']:.2f}")

# 3) P5 예측 구간(잔차 90% 분위수)과 재발 오류
pf = cand[best]; resid_aar = aar["avail_pct"].to_numpy() - r4.predict(sc4.transform(sim2(aar)))
q = float(np.quantile(np.abs(resid_aar), 0.9))
cover = float(np.mean(np.abs(test["avail_pct"].to_numpy() - pf) <= q))
bad0 = set(i for i, r in s0["cells"].iterrows() if abs(r.err) > 3)
recur = [i for i in bad0 if abs(b["cells"].loc[i, "err"]) > 3]
print("장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.")
print(f"P5 예측 구간 ±{q:.1f}%p: 검증 포괄률 {cover:.0%}(목표 약 90%). 재보정 전 오차 3%p 초과 셀 {len(bad0)}곳 중 재발 {len(recur)}곳 {recur[:3] if recur else ''}")

# 4) UAT 체크리스트(측정값 기반 가상 점검) 10항목 x 10점
uat = {"셀 적중률 80% 이상": b["hit"] >= 0.8, "개별 MAE 12%p 이하(장비 단위는 참고용)": b["mae"] <= 12, "평균 편향 절대값 1.0%p 이하": abs(b["bias"]) <= 1.0, "예측 구간 포괄률 85~95%": 0.85 <= cover <= 0.95,
       "재발 오류 0건": len(recur) == 0, "원인 분해표 제공": len(contrib) == 5, "재보정 전후 비교표 제공": len(cand) == 5, "파라미터 YAML 출력": True,
       "이전 파라미터로 되돌리기 가능(원 모델 보존)": m0 is not None, "사람 확인 문구 포함(자동 적용 금지)": True}
pts = sum(10 for v in uat.values() if v)
print(f"\n[UAT 체크리스트] {pts}점/100점 (목표 90점 이상: {'달성' if pts >= 90 else '미달'}), 미충족: {[k for k, v in uat.items() if not v] or '없음'}")
print("개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영")

YAML = f"""[tuned_params 제안: 담당자 확인 후 적용]
model: ridge
alpha: {alpha}
recent_weight: 3.0
extra_feature: insp_x_orders   # 부대별 최근 외주 검사 대기 x 정비 요청 건수
bias_cells: {len(b_cell)}
interval_pct90: {q:.1f}
retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과"""
print("\n" + YAML)
print("지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.")

plt.figure(figsize=(8, 4))
ks = list(res); plt.bar([f"S{i}" for i in range(len(ks))], [res[k]["hit"] * 100 for k in ks], color=["tab:gray"] + ["tab:blue"] * (len(ks) - 1))
plt.ylabel("cells within 3%p (%)"); plt.title("Forecast hit rate: base (S0) vs recalibration patches (S1-S4)"); plt.tight_layout(); plt.show()
💡 [참고] 미션 09 예상 실행 결과 (Output)
[AAR 편차 분석] 예측 대비 실제: 평균 편향 -4.60%p(실제가 낮음), 개별 MAE 11.44%p, 셀 18곳 중 오차 3%p 이내 33%
오차가 큰 셀: 가상D부대/유형A -11.0%p, 가상D부대/유형C -10.3%p, 가상A부대/유형B -9.1%p
오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p): 부품 대기 -0.21, 정비병 부족 -0.45, 외주 정비 -0.01, 외주 검사 대기 -4.92, 재작업 +0.18
-> 가장 큰 원인: 외주 검사 대기 (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영

[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]
  원 모델                       셀 적중  39%  개별 MAE 10.53  편향 -3.82
  P1 전체 편향 보정                셀 적중  78%  개별 MAE 11.48  편향 +0.79
  P1' 셀별 편향 보정               셀 적중  50%  개별 MAE 11.81  편향 +0.95
  P2+P3 병목 변수+최근 자료 가중       셀 적중  83%  개별 MAE 10.62  편향 -0.78
  P4 +정규화 강도 10.0            셀 적중  83%  개별 MAE 10.62  편향 -0.78
채택: P2+P3 병목 변수+최근 자료 가중 -> 셀 적중 39% -> 83% (+44%p, 목표 +10%p 이상: 달성), MAE 10.53 -> 10.62
장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.
P5 예측 구간 ±25.2%p: 검증 포괄률 90%(목표 약 90%). 재보정 전 오차 3%p 초과 셀 12곳 중 재발 1곳 [('가상A부대', '유형B')]

[UAT 체크리스트] 90점/100점 (목표 90점 이상: 달성), 미충족: ['재발 오류 0건']
개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영

[tuned_params 제안: 담당자 확인 후 적용]
model: ridge
alpha: 10.0
recent_weight: 3.0
extra_feature: insp_x_orders   # 부대별 최근 외주 검사 대기 x 정비 요청 건수
bias_cells: 18
interval_pct90: 25.2
retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과
지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.

🎯 미션 10. 전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구 최종 완성

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 입력 검증부터 룰 판별, 요인 추천, 사람 검토 큐, 감사 로그, 브리핑까지 하나로 묶은 가동률 저하 요인 분석·극대화 도구를 완성하고 배포 점검을 마친다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 서로 다른 시드 4종의 End-to-End 실행, 자체 점검 9항목과 함수 커버리지 수행하고 분석하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: dist/qlt04_package/, README.md, docs/qlt04_final_manual.md 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 상용 소프트웨어 수준의 완성도 및 조직 확산 가치

미션 PASS 조건

  • 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%

  • 진행 기준: 최종 배포본 생성 및 검수 완료

  • 검증 방법: Clean 환경에서의 설치 및 초기화 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""입력 검증·격리, 16종 룰 판별, 가동률 회귀와 저하 요인 Top-3 추천, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 외주정비 품질검사·가동률 분석 도구를 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다."""
import hashlib
import json
import sys
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)
# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.
# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.
# ---------------------------------------------------------------------------
UNITS = ["가상A부대", "가상B부대", "가상C부대", "가상D부대", "가상E부대", "가상F부대"]
ETYPES = ["유형A", "유형B", "유형C"]
WINDOW = 90                                       # 관측 기간(일)
CAUSES = ["부품 대기", "정비병 부족", "외주 정비", "검사·재작업"]
CAUSE_COL = {"부품 대기": "parts_wait_days", "정비병 부족": "labor_wait_days", "외주 정비": "outsource_days", "검사·재작업": "inspect_wait_days"}
NUM_FIELDS = ["age_years", "op_hours", "maint_orders", "parts_wait_days", "labor_wait_days", "outsource_days", "inspect_wait_days", "rework_count",
              "spec_mismatch", "defect_reports", "parts_stock_pct", "mechanic_fill_pct", "avail_pct"]
FEAT = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch", "defect_reports", "etype_B", "etype_C"]
FEAT_KO = {"age_years": "장비 연령", "maint_orders": "정비 요청 건수", "parts_stock_pct": "부품 재고 가용률", "mechanic_fill_pct": "정비병 충원율", "outsource_days": "외주 정비 일수",
           "inspect_wait_days": "외주 검사 대기 일수", "rework_count": "재작업 건수", "spec_mismatch": "규격·제원 불일치", "defect_reports": "하자·불만 접수",
           "etype_B": "유형B 여부", "etype_C": "유형C 여부"}


def unit_profile(seed=3):
    """부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도."""
    r = np.random.default_rng(seed)
    return pd.DataFrame({"unit": UNITS, "stock": r.uniform(45, 95, len(UNITS)), "fill": r.uniform(55, 98, len(UNITS)),
                         "dist": r.uniform(0.8, 1.6, len(UNITS)), "queue": r.uniform(0.7, 2.2, len(UNITS))})


def make_fleet(n=300, seed=7, shock=0.0):
    """장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화)."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    ui = rng.integers(0, len(UNITS), n)
    et = rng.choice(ETYPES, n, p=[.35, .3, .35])
    age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)
    eff = np.array([{"유형A": 1.15, "유형B": 1.0, "유형C": 0.9}[t] for t in et])
    orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)
    stock = np.clip(pf["stock"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)
    fill = np.clip(pf["fill"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)
    parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)
    labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)
    n_out = rng.binomial(orders, 0.3)
    out_d = (n_out * rng.gamma(3, 4.5 * pf["dist"].to_numpy()[ui] / 3)).round(0)
    insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf["queue"].to_numpy()[ui] / 2)).round(0)
    spec = rng.poisson(0.2 + 0.04 * age)
    rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))
    defect = rng.poisson(0.3 + 0.7 * rework)
    base_rep = orders * 1.8
    down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)
    comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])
    df = pd.DataFrame({"equip_id": [f"E-{i + 1:04d}" for i in range(n)], "unit": np.array(UNITS)[ui], "etype": et, "age_years": age,
                       "op_hours": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), "maint_orders": orders,
                       "parts_wait_days": parts_w, "labor_wait_days": labor_w, "outsource_days": out_d, "inspect_wait_days": insp_w,
                       "rework_count": rework, "spec_mismatch": spec, "defect_reports": defect, "parts_stock_pct": stock, "mechanic_fill_pct": fill,
                       "avail_pct": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})
    df["main_cause"] = np.where(comp.max(axis=1) < 3, "없음", np.array(CAUSES)[comp.argmax(axis=1)])
    return df


def feat_frame(df):
    """회귀·분석용 설명 변수 표(유형은 더미 변수)."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    return x[FEAT]


def make_trend(months=12, seed=5, shock_unit=None):
    """[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다."""
    rng = np.random.default_rng(seed)
    pf = unit_profile()
    rows = []
    for _, u in pf.iterrows():
        base = 62 + 0.22 * u["stock"] + 0.12 * u["fill"] - 3.0 * u["queue"]
        for t in ETYPES:
            off = {"유형A": -3.0, "유형B": 0.0, "유형C": 2.0}[t]
            for m in range(months):
                v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)
                if shock_unit == u["unit"] and m >= months // 2:
                    v -= 1.5 * (m - months // 2 + 1)
                rows.append((u["unit"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))
    return pd.DataFrame(rows, columns=["unit", "etype", "month", "avail_pct"])

# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----
# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.
RULES_YAML = """
- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: "<", value: 50, weight: 2, clause: 가상품질지침 제3조}
- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: "<", value: 60, weight: 2, clause: 가상품질지침 제4조}
- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제5조}
- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: ">", value: 8, weight: 2, clause: 가상품질지침 제6조}
- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: ">", value: 15, weight: 2, clause: 가상품질지침 제7조}
- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: ">", value: 6, weight: 3, clause: 가상품질지침 제8조}
- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: ">=", value: 1, weight: 3, clause: 가상품질지침 제9조}
- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: ">", value: 0.25, weight: 2, clause: 가상품질지침 제9조}
- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: ">=", value: 3, weight: 3, clause: 가상품질지침 제10조}
- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: ">=", value: 3, weight: 2, clause: 가상품질지침 제11조}
- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: ">", value: 15, weight: 1, clause: 가상품질지침 제12조}
- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: ">=", value: 6, weight: 1, clause: 가상품질지침 제13조}
- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: ">", value: 30, weight: 3, clause: 가상품질지침 제14조}
- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: ">", value: 0.8, weight: 2, clause: 가상품질지침 제8조}
- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: "==", value: true, weight: 3, clause: 가상품질지침 제10조}
- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: "<", value: 150, weight: 1, clause: 가상품질지침 제15조}
"""
GRADE_CUT = [(10, "고위험"), (6, "주의"), (3, "관심")]
OPS = {"<": lambda a, b: a < b, ">": lambda a, b: a > b, "<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b, "==": lambda a, b: a == b}


def parse_rules(text):
    """YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이)."""
    out = []
    for line in text.strip().splitlines():
        body = line.strip()[3:-1]
        r = {}
        for part in body.split(", "):
            k, v = part.split(": ", 1)
            v = v.strip().strip('"')
            r[k] = True if v == "true" else False if v == "false" else (float(v) if v.replace(".", "", 1).isdigit() else v)
        out.append(r)
    return out


def derive_rec(rec):
    """룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다."""
    r = dict(rec)
    r["total_wait"] = r["parts_wait_days"] + r["labor_wait_days"] + r["outsource_days"] + r["inspect_wait_days"]
    r["rework_rate"] = r["rework_count"] / r["maint_orders"] if r["maint_orders"] else 0.0
    r["insp_ratio"] = r["inspect_wait_days"] / r["outsource_days"] if r["outsource_days"] >= 5 else 0.0
    r["spec_defect"] = bool(r["spec_mismatch"] >= 2 and r["defect_reports"] >= 1)
    return r


def apply_rules(rec, rules, derived=False):
    """룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다."""
    r = rec if derived else derive_rec(rec)
    hits = [x["id"] for x in rules if OPS[x["op"]](r[x["field"]], x["value"])]
    w = {x["id"]: x["weight"] for x in rules}
    return sum(w[h] for h in hits), hits


def grade_of(score):
    for cut, g in GRADE_CUT:
        if score >= cut:
            return g
    return "정상"

# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----
from sklearn.linear_model import Ridge, LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

CAUSE_COLS = ["age_years", "maint_orders", "parts_stock_pct", "mechanic_fill_pct", "outsource_days", "inspect_wait_days", "rework_count", "spec_mismatch",
              "defect_reports", "parts_wait_days", "labor_wait_days"]
ACTION = {"부품 대기": "부품 사전 청구·재고 보강", "정비병 부족": "정비병 재배치·교육 일정 조정", "외주 정비": "외주 정비 일정 조율·내부 정비 확대", "검사·재작업": "검사 일정 조율·규격 사전 대조"}


def cause_frame(df):
    x = df[CAUSE_COLS].copy()
    x["out_insp"] = x["outsource_days"] + x["inspect_wait_days"] + 4 * x["rework_count"]
    x["wait_pm"] = (x["parts_wait_days"] + x["labor_wait_days"]) / (x["maint_orders"] + 1)
    return x


def fit_avail(df, alpha=1.0):
    """가동률(%) 회귀: 표준화 + 릿지 회귀."""
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df["avail_pct"])


def fit_cause(df, cut=85, C=5):
    """가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다."""
    low = df[(df["avail_pct"] < cut) & (df["main_cause"] != "없음")]
    return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low["main_cause"])


SIM_FEAT = ["age_years", "maint_orders", "spec_mismatch", "stock_gap_x_orders", "fill_gap_x_orders", "parts_stock_pct", "mechanic_fill_pct", "etype_B", "etype_C"]


def sim_frame(df):
    """자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다."""
    x = df.copy()
    x["etype_B"], x["etype_C"] = (x["etype"] == "유형B").astype(int), (x["etype"] == "유형C").astype(int)
    x["stock_gap_x_orders"] = x["maint_orders"] * ((100 - x["parts_stock_pct"]) / 28) ** 2
    x["fill_gap_x_orders"] = x["maint_orders"] * ((100 - x["mechanic_fill_pct"]) / 24) ** 2
    return x[SIM_FEAT]


def fit_sim(df, alpha=0.5):
    return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df["avail_pct"])

RULES = parse_rules(RULES_YAML)
REQ = {"equip_id": r"^E-\d{4}$", "unit": UNITS, "etype": ETYPES, "avail_pct": (0, 100), "maint_orders": (0, 30), "age_years": (0, 40), "parts_stock_pct": (0, 100), "mechanic_fill_pct": (0, 100)}
CONF = {"queue_min_score": 6}
MANUAL = {
    "1. 목적과 범위": "외주정비 품질검사·정비 대기로 인한 가동률 저하 요인을 찾는 분석 보조 도구다. 우선순위·자원 투입 결정은 정비관·품질검사관·군수참모가 한다.",
    "2. 설치와 실행": "install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.",
    "3. 입력 데이터": "장비 속성 16개 필드. 형식·범위 오류 행은 격리하고 사유를 기록한다.",
    "4. 판정 규칙": "YAML 룰 16종 점수, 가동률 회귀, 저하 요인 분류와 Top-3 개선 옵션을 함께 보여 준다.",
    "5. 사람 검토와 감사 로그": "모든 검토 대상은 PENDING으로 시작해 정비관·품질검사관·군수참모가 처리한다. 모든 행위는 해시 체인에 남는다.",
    "6. 오류 대응": "깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 요인 모델 실패 시 룰 판별만 제공한다.",
    "7. 한계와 확장": "가상 자료 기반이며 실제 장비·검사 자료는 담당 부서가 제공해야 한다. 결재·정비 체계 연동은 차기 과제다.",
}
INSTALL_SH = """#!/bin/sh
# [교육용가상자료] qlt04 설치 스크립트 예시
set -e
python3 -c "import sys; assert sys.version_info >= (3, 9)"          # 1) 파이썬 버전 점검
python3 -m venv .venv && . .venv/bin/activate                        # 2) 가상환경
pip install -r requirements.txt                                      # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)
python3 -m pytest tests -q                                           # 4) 자체 시험
python3 main_qlt04.py --demo                                         # 5) 데모 실행(가상 자료)
"""


def validate(df):
    """필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다."""
    bad = pd.Series(False, index=df.index)
    for k, rule in REQ.items():
        if k not in df.columns:
            raise ValueError(f"필수 열 없음: {k}")
        if isinstance(rule, str):
            bad |= ~df[k].astype(str).str.match(rule)
        elif isinstance(rule, tuple):
            v = pd.to_numeric(df[k], errors="coerce"); bad |= v.isna() | (v < rule[0]) | (v > rule[1])
        else:
            bad |= ~df[k].isin(rule)
    return df[~bad].copy(), df[bad].copy()


class Chain:
    """해시 체인 감사 로그."""
    def __init__(self):
        self.log = []

    def add(self, **body):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {"seq": len(self.log), **body}
        self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})

    def verify(self):
        prev = "GENESIS"
        for e in self.log:
            b = {k: v for k, v in e.items() if k not in ("prev", "hash")}
            if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
                return e["seq"]
            prev = e["hash"]
        return None


def recommend(clf, row_df):
    """저하 요인 Top-3 개선 옵션(확률). 모델이 없으면 룰 기반 대체(None)."""
    if clf is None:
        return [None]
    p = clf.predict_proba(cause_frame(row_df))[0]
    o = np.argsort(-p)[:3]
    return [(clf.classes_[i], float(p[i])) for i in o]


def run_pipeline(df, reg, clf):
    """검증 -> 룰 -> 회귀 예측 -> Top-3 추천 -> 검토 큐(PENDING) + 감사 로그."""
    clean, quar = validate(df)
    audit = Chain()
    audit.add(event="LOAD", n=len(df), quarantined=len(quar))
    sc = [apply_rules(r, RULES) for r in clean.drop(columns=["main_cause"]).to_dict("records")]
    clean["score"], clean["hits"] = [s[0] for s in sc], [s[1] for s in sc]
    clean["grade"] = clean["score"].map(grade_of)
    clean["pred"] = np.clip(reg.predict(feat_frame(clean)), 0, 100)
    q = clean[clean["score"] >= CONF["queue_min_score"]].sort_values("score", ascending=False).copy()
    recs = []
    for _, r in q.iterrows():
        top = recommend(clf, clean.loc[[r.name]])
        recs.append(top[0][0] if top[0] else "룰 기반")
        audit.add(event="QUEUE", equip=r["equip_id"], score=int(r["score"]), status="PENDING")
    q["rec"], q["status"] = recs, "PENDING(사람 검토 대기)"
    return {"data": clean, "queue": q, "quarantine": quar, "audit": audit}


def self_tests(reg, clf, base):
    res = {}
    r = run_pipeline(base, reg, clf)
    res["정상 자료 통합 실행"] = len(r["data"]) == len(base) and len(r["queue"]) > 0
    broken = base.copy(); broken.loc[broken.index[:3], "avail_pct"] = -5; broken.loc[broken.index[3], "equip_id"] = "X1"
    r2 = run_pipeline(broken, reg, clf)
    res["손상 레코드 4건 격리"] = len(r2["quarantine"]) == 4 and len(r2["data"]) == len(base) - 4
    res["모든 검토 건이 PENDING(자동 확정 없음)"] = bool(r["queue"]["status"].str.startswith("PENDING").all())
    r["audit"].log[1]["equip"] = "E-9999"
    res["감사 로그 변조 탐지"] = r["audit"].verify() == 1
    res["룰 16종 이상 로딩"] = len(RULES) >= 16
    safe = apply_rules({"parts_stock_pct": 90, "mechanic_fill_pct": 90, "parts_wait_days": 0, "labor_wait_days": 0, "outsource_days": 0, "inspect_wait_days": 0, "rework_count": 0,
                        "maint_orders": 1, "spec_mismatch": 0, "defect_reports": 0, "age_years": 5, "op_hours": 300}, RULES)
    res["안전 속성 장비는 점수 0"] = safe[0] == 0 and grade_of(safe[0]) == "정상"
    r3 = run_pipeline(base, reg, None)
    res["요인 모델 없이도 룰 기반으로 동작"] = len(r3["queue"]) > 0 and (r3["queue"]["rec"] == "룰 기반").all()
    res["매뉴얼 7개 장 작성"] = all(len(v) > 20 for v in MANUAL.values())
    res["설치 스크립트 5단계"] = INSTALL_SH.count("\n") >= 7 and all(k in INSTALL_SH for k in ("requirements", "pytest", "--demo", "venv", "version_info"))
    return res


# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----
called = set()
own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, "__module__", "") == "__main__" and hasattr(f, "__code__")}
sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == "call" else None)
t_all = time.perf_counter()
train = make_fleet(1200, seed=21)
reg, clf = fit_avail(train), fit_cause(train)
_ = parse_rules(RULES_YAML); _ = unit_profile(); _ = make_trend(2); _ = sim_frame(train.head(3)); _ = fit_sim(train.head(100))
base = make_fleet(300, seed=7)
tests = self_tests(reg, clf, base)
e2e = []
for s in (7, 17, 27, 37):
    try:
        d = make_fleet(300, seed=s); d.loc[d.index[:2], "avail_pct"] = np.nan
        r = run_pipeline(d, reg, clf)
        q = r["queue"]
        e2e.append((s, True, len(q), float((q["avail_pct"] < 70).mean()) if len(q) else np.nan, int((q["pred"] - q["avail_pct"]).abs().gt(15).sum()), len(r["quarantine"]), r["audit"].verify() is None))
    except Exception as e:
        e2e.append((s, False, 0, np.nan, 0, 0, False))
sys.setprofile(None)
cov = len(own & called) / len(own)

print(f"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)")
print("[설치 스크립트(원클릭) 5단계 점검]")
step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in ("numpy", "pandas", "sklearn")), all(tests.values()), all(x[1] for x in e2e)]
for name, ok in zip(["파이썬 버전", "가상환경", "의존 패키지", "자체 시험", "데모 실행"], step_ok):
    print(f"  [{'PASS' if ok else 'FAIL'}] {name}")
print(f"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})")
print("\n[End-to-End 시나리오 4종]")
for s, ok, nq, acc, big, nb, chain in e2e:
    print(f"  seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(실제 저가동 비율 {acc:.0%}, 예측 오차 15%p 초과 {big}건), 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}")
print(f"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}")
print("[자체 점검]")
for k, v in tests.items():
    print(f"  [{'PASS' if v else 'FAIL'}] {k}")
print(f"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})")
done = sum(len(v) > 20 for v in MANUAL.values())
print(f"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): " + ", ".join(MANUAL))

# ---- 브리핑 요약(seed 7 기준) ----
r = run_pipeline(base, reg, clf)
d, q = r["data"], r["queue"]
print("\n[정비·품질검사 담당 브리핑 요약]")
print(f"1. 장비 {len(d)}대 점검: 평균 가동률 {d['avail_pct'].mean():.1f}%, 룰 고위험 {int((d['grade'] == '고위험').sum())}대, 주의 {int((d['grade'] == '주의').sum())}대, 가동률 70% 미만 {int((d['avail_pct'] < 70).sum())}대")
print(f"2. 사람 검토 큐 {len(q)}건: 모두 PENDING. 추천 요인 분포 {q['rec'].value_counts().to_dict()}")
for _, x in q.head(3).iterrows():
    print(f"   - {x['equip_id']} {x['unit']} 가동률 {x['avail_pct']}%, 룰 점수 {x['score']}, 적중 {','.join(x['hits'][:3])}, 추천 {x['rec']}")
print("3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.")
print("4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.")

plt.figure(figsize=(6, 3.5)); g = d.groupby("grade")["avail_pct"].mean().reindex(["고위험", "주의", "관심", "정상"]).dropna()
plt.bar(g.index.map({"고위험": "high", "주의": "caution", "관심": "watch", "정상": "normal"}), g.values, color="tab:blue"); plt.ylabel("mean availability (%)"); plt.title("Mean availability by rule grade"); plt.tight_layout(); plt.show()
💡 [참고] 미션 10 예상 실행 결과 (Output)
[교육용가상자료] 통합 실행 3.0초 (모델 학습 포함)
[설치 스크립트(원클릭) 5단계 점검]
  [PASS] 파이썬 버전
  [PASS] 가상환경
  [PASS] 의존 패키지
  [PASS] 자체 시험
  [PASS] 데모 실행
설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)

[End-to-End 시나리오 4종]
  seed 7: 성공, 검토 큐 40건(실제 저가동 비율 78%, 예측 오차 15%p 초과 5건), 격리 2건, 감사 로그 정상
  seed 17: 성공, 검토 큐 60건(실제 저가동 비율 82%, 예측 오차 15%p 초과 14건), 격리 2건, 감사 로그 정상
  seed 27: 성공, 검토 큐 51건(실제 저가동 비율 86%, 예측 오차 15%p 초과 6건), 격리 2건, 감사 로그 정상
  seed 37: 성공, 검토 큐 59건(실제 저가동 비율 86%, 예측 오차 15%p 초과 8건), 격리 2건, 감사 로그 정상
E2E 성공률 100%
[자체 점검]
  [PASS] 정상 자료 통합 실행
  [PASS] 손상 레코드 4건 격리
  [PASS] 모든 검토 건이 PENDING(자동 확정 없음)
  [PASS] 감사 로그 변조 탐지
  [PASS] 룰 16종 이상 로딩
  [PASS] 안전 속성 장비는 점수 0
  [PASS] 요인 모델 없이도 룰 기반으로 동작
  [PASS] 매뉴얼 7개 장 작성
  [PASS] 설치 스크립트 5단계
자체 점검 9/9 통과, 함수 커버리지 100%(17/17, 목표 85% 이상: 달성, 미호출 없음)
매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장

[정비·품질검사 담당 브리핑 요약]
1. 장비 300대 점검: 평균 가동률 80.8%, 룰 고위험 16대, 주의 24대, 가동률 70% 미만 63대
2. 사람 검토 큐 40건: 모두 PENDING. 추천 요인 분포 {'검사·재작업': 15, '부품 대기': 12, '정비병 부족': 7, '외주 정비': 6}
   - E-0047 가상A부대 가동률 35.7%, 룰 점수 14.0, 적중 Q01,Q03,Q05, 추천 외주 정비
   - E-0231 가상D부대 가동률 51.7%, 룰 점수 14.0, 적중 Q02,Q03,Q04, 추천 부품 대기
   - E-0050 가상A부대 가동률 22.8%, 룰 점수 13.0, 적중 Q03,Q04,Q06, 추천 검사·재작업
3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.
4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.