Files
2026-10-08 16:43:48 +09:00

2946 lines
194 KiB
Markdown

# QLT04 정답 코드 - Data 기반 외주정비 품질검사 통합 솔루션
> [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
## 실행 환경
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
## 미션 1. 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의
- 난이도: 초급 / 예상 소요시간: 1.5시간
- 코드 설명: 가상 장비 가동률·정비 대기 자료(필드 16개)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 정비 입고부터 외주 정비 검사·가동 복귀까지의 현행(AS-IS) 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.
```python
"""가상 장비 가동률·정비 대기 자료(필드 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()
```
실행 결과 예시
```text
[스키마 검증] 장비 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에서 구현)
```
## 미션 2. 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
- 코드 설명: 서식이 서로 다른 가상 정비 대기 기록(CSV, 엑셀 내보내기 TSV, JSON, 정비일지 문장)을 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 대기 일수·부대명·날짜 표기를 통일하며 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다.
```python
"""서식이 서로 다른 가상 정비 대기 기록(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차 분류이며, '기타'와 복합 사유는 정비 담당자가 확인해 확정한다.")
```
실행 결과 예시
```text
[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 (코드 수정 없이 설정만 변경)
... (이하 생략)
```
## 미션 3. 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현
- 난이도: 중급 / 예상 소요시간: 2.5시간
- 코드 설명: 가상 장비 300대의 가동률 저하 요인을 상관·효과크기·손실일 기준으로 비교하는 1차 통계 Baseline으로 상위 5대 요인을 도출하고, YAML 형식 룰 16종으로 저가동 후보를 판별하며 경계 조건 단위 시험과 룰 처리 지연시간(99백분위)을 확인한다.
```python
"""가상 장비 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()
```
실행 결과 예시
```text
[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만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.
```
## 미션 4. 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
- 코드 설명: 가상 장비 1,500대의 가동률을 릿지 회귀로 설명해 핵심 병목 요인의 영향 크기와 부호 안정성을 분석하고, 저가동 장비의 주요 요인(부품 대기·정비병 부족·외주 정비·검사·재작업)을 분류해 Top-3 개선 옵션과 유사 과거 사례 유사도 점수를 제공하며 정확도를 검증 세트로 측정한다.
```python
"""가상 장비 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()
```
실행 결과 예시
```text
[회귀 모델] 학습 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를 비교해 선택
※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.
```
## 미션 5. 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발
- 난이도: 중급 / 예상 소요시간: 3.0시간
- 코드 설명: 부대별 정비병 충원율·부품 재고 가용률을 다른 부대로 옮겼을 때 장비 가동률이 얼마나 오르는지 회귀 모델로 시뮬레이션하고 상승폭이 큰 재배치안을 순서대로 고르며, 입력-분석-검토-결과 4개 화면과 상태 뱃지(승인대기·반려·완료)·필터·화면 이동을 갖춘 텍스트 프로토타입으로 보여 준다.
```python
"""부대별 정비병 충원율·부품 재고 가용률을 다른 부대로 옮겼을 때 장비 가동률이 얼마나 오르는지 회귀 모델로 시뮬레이션하고 상승폭이 큰 재배치안을 순서대로 고르며, 입력-분석-검토-결과 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()
```
실행 결과 예시
```text
[시뮬레이션 모델] 검증 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. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.
```
## 미션 6. 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현
- 난이도: 중급~고급 / 예상 소요시간: 2.5시간
- 코드 설명: 부대별·장비 유형별 12개월 가동률 추세와 저하 요인 구성을 텍스트 대시보드와 그래프로 보여 주고, 결측·특수문자·자료형 오류·대용량·빈 자료·열 누락 등 예외 13종을 주입해도 중단 없이 오류 격리·안내 메시지·오류 로그·재시도·롤백이 동작하는지 시험하는 입력 검증기와 에러 핸들러를 구현한다.
```python
"""부대별·장비 유형별 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()
```
실행 결과 예시
```text
[대시보드 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: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확
... (이하 생략)
```
## 미션 7. 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 저가동 장비의 정비 자원 우선 투입안을 정비담당 상신 -> 정비관 검토 -> 품질검사관 검토 -> 군수참모 결재의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·반려 사유 필수화·재기안 연계·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.
```python
"""저가동 장비의 정비 자원 우선 투입안을 정비담당 상신 -> 정비관 검토 -> 품질검사관 검토 -> 군수참모 결재의 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()
```
실행 결과 예시
```text
[결재 결과] 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
... (이하 생략)
```
## 미션 8. 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증
- 난이도: 고급 / 예상 소요시간: 3.5시간
- 코드 설명: 12개월치 가상 장비 가동률 자료를 월 단위 배치(적재 -> 검증·격리 -> 룰·모델 분석 -> 보고)로 자동 실행하는 스케줄러 모사를 만들고, 오류 레코드·일시 장애 주입과 자료량 100%·200%·500% 부하에서 실행 시간·성공률·자료 유실률·수작업 대비 절감을 측정한다.
```python
"""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()
```
실행 결과 예시
```text
[월 배치 실행: 장애 주입 포함]
진행 [#...........] 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분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.
```
## 미션 9. AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 가동률 예측치와 실제치의 편차를 부대·유형 셀 단위로 AAR 분석해 오차 원인(외주 검사 병목 악화)을 요인별로 분해하고, 편향 보정·병목 변수 추가·가중 재학습·정규화 강도 조정·예측 구간의 5개 재보정 패치를 단계별로 검증해 채택 여부를 정하며 UAT 체크리스트와 재보정 파라미터(YAML 문자열)를 출력한다.
```python
"""가동률 예측치와 실제치의 편차를 부대·유형 셀 단위로 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()
```
실행 결과 예시
```text
[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시간
- 코드 설명: 입력 검증·격리, 16종 룰 판별, 가동률 회귀와 저하 요인 Top-3 추천, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 외주정비 품질검사·가동률 분석 도구를 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다.
```python
"""입력 검증·격리, 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()
```
실행 결과 예시
```text
[교육용가상자료] 통합 실행 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. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.
```