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

2146 lines
154 KiB
Markdown

# MNT06 정답 코드 - AI 기반 부품 폐기율 산정·예측 모델
> [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
## 실행 환경
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
## 미션 1. 3D 프린팅 제작 대상 군수 부품 3D CAD 모델 및 금속/폴리머 소재 물성 데이터 정의
- 난이도: 초급 / 예상 소요시간: 1.5시간
- 코드 설명: 폐기율이 높은 수리부속의 야전 3D 프린팅 대체 제작 판단에 필요한 3D CAD 모델·금속/폴리머 소재 물성·사용 조건 데이터의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플을 검증한다.
```python
"""폐기율이 높은 수리부속의 야전 3D 프린팅 대체 제작 판단에 필요한 3D CAD 모델·금속/폴리머 소재 물성·사용 조건 데이터의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플을 검증한다."""
import json
import re
import numpy as np
import pandas as pd
# 1) 업무 프로세스 맵: 폐기 부품 식별에서 대체 제작 승인·효과 확인까지
PROCESS = [("폐기 부품 식별", "부품 코드, 월 폐기수량, 폐기율, 조달 소요일"), ("CAD 확보", "CAD 파일 형식, 삼각형 수, 단위, 치수"),
("소재 선정", "소재 코드, 공정 구분, 인장강도 기준값, 인증서 여부"), ("제작 가능성 판별", "벽두께, 돌출 비율, 최대 치수, 나사부"),
("신뢰성 평가", "사용 하중, 안전율, 피로 수명, 군 규격 부합 점수"), ("정비기술관 승인", "승인·반려·보류, 사유"),
("출력·후처리", "출력 시간, 소재량, 후처리 항목"), ("실 장착·시험", "장착 부대, 시험 결과, 파괴시험 강도"), ("사후검토(AAR)", "예측 오차, 폐기율 변화, 보정 이력")]
# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명
S = {
"part": {
"part_id": (str, {"pattern": r"^P\d{3}$"}, "부품 ID(가상)"),
"item_code": (str, {"pattern": r"^VX-\d{4}-\d{4}$"}, "부품 식별코드(국방 표준 군수 코드 형식을 모사한 가상 코드)"),
"fsc_group": (str, {"pattern": r"^\d{4}$"}, "보급 분류군(가상 4자리)"),
"category": (str, {"enum": ["bracket", "housing", "clip", "gear", "duct", "handle"]}, "부품군"),
"crit": (int, {"enum": [1, 2, 3]}, "작전 중요도(1 일반, 2 중요, 3 핵심)"),
"scrap_rate": (float, {"min": 0, "max": 1}, "월 폐기율(폐기수량/소요수량) - 예측대상 1"),
"lead_days": (float, {"min": 0, "max": 400}, "기존 조달 소요일"),
},
"cad": {
"cad_format": (str, {"enum": ["stl", "step", "obj"]}, "CAD 파일 형식"),
"unit": (str, {"enum": ["mm"]}, "치수 단위(mm로 통일)"),
"volume_cm3": (float, {"min": 0.5, "max": 1000}, "부피"),
"max_dim_mm": (float, {"min": 5, "max": 400}, "최대 치수"),
"min_wall_mm": (float, {"min": 0.2, "max": 20}, "최소 벽두께"),
"overhang": (float, {"min": 0, "max": 1}, "45도 초과 돌출면 비율(지지대 필요 정도)"),
"n_triangles": (int, {"min": 100, "max": 5000000}, "메쉬 삼각형 수"),
"thread": (bool, {}, "나사부 포함 여부"),
},
"material": {
"material": (str, {"enum": ["PA12", "PETG", "CF-PA", "AlSi10Mg", "SS316L", "Ti64"]}, "소재 코드"),
"process": (str, {"enum": ["polymer", "metal"]}, "공정 구분(폴리머 압출·분말 / 금속 분말 용융)"),
"base_strength": (float, {"min": 10, "max": 1500}, "소재 기준 인장강도(MPa, 가상)"),
"cert": (bool, {}, "소재 인증서 보유 여부"),
},
"use": {
"load_N": (float, {"min": 1, "max": 30000}, "사용 하중(N)"),
"temp_C": (float, {"min": -40, "max": 150}, "사용 온도"),
"orient_deg": (float, {"min": 0, "max": 90}, "출력 방향 각도(적층 방향 대비)"),
},
"result": {
"printable": (bool, {}, "시험 출력 성공 여부 - 예측대상 2"),
"strength_mpa": (float, {"min": 0, "max": 1500}, "실측 인장강도 - 예측대상 3"),
"log_cycles": (float, {"min": 0, "max": 9}, "피로 수명 log10(회)"),
"conform": (float, {"min": 0, "max": 100}, "군 규격 부합 신뢰도 점수 - 예측대상 4"),
},
}
def check(name, v, typ, rule):
if not isinstance(v, typ) or (typ in (int, float) and isinstance(v, bool)):
return [f"{name}: 자료형 오류({type(v).__name__})"]
e = []
if "enum" in rule and v not in rule["enum"]:
e.append(f"{name}: 허용되지 않은 값 '{v}'")
if "min" in rule and v < rule["min"]:
e.append(f"{name}: 최소값 {rule['min']} 미만")
if "max" in rule and v > rule["max"]:
e.append(f"{name}: 최대값 {rule['max']} 초과")
if "pattern" in rule and not re.match(rule["pattern"], v):
e.append(f"{name}: 형식 불일치 '{v}'")
return e
def validate(rec):
errs = []
for g, fields in S.items():
for k, (typ, rule, _) in fields.items():
if k not in rec.get(g, {}):
errs.append(f"{g}.{k}: 필수 필드 누락")
else:
errs += check(f"{g}.{k}", rec[g][k], typ, rule)
return errs
# 3) [교육용가상자료] 샘플 20건
rng = np.random.default_rng(1)
MATS = {"PA12": ("polymer", 45), "PETG": ("polymer", 50), "CF-PA": ("polymer", 70), "AlSi10Mg": ("metal", 350), "SS316L": ("metal", 520), "Ti64": ("metal", 900)}
recs = []
for i in range(20):
m = str(rng.choice(list(MATS)))
recs.append({
"part": {"part_id": f"P{i:03d}", "item_code": f"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}", "fsc_group": f"{rng.integers(1000, 9999)}",
"category": str(rng.choice(["bracket", "housing", "clip", "gear", "duct", "handle"])), "crit": int(rng.integers(1, 4)),
"scrap_rate": float(round(rng.uniform(0.02, 0.3), 3)), "lead_days": float(round(rng.uniform(10, 120), 0))},
"cad": {"cad_format": str(rng.choice(["stl", "step", "obj"])), "unit": "mm", "volume_cm3": float(round(rng.uniform(5, 200), 1)),
"max_dim_mm": float(round(rng.uniform(20, 180), 1)), "min_wall_mm": float(round(rng.uniform(0.8, 6), 1)),
"overhang": float(round(rng.uniform(0, 0.5), 2)), "n_triangles": int(rng.integers(2000, 400000)), "thread": bool(rng.random() < 0.25)},
"material": {"material": m, "process": MATS[m][0], "base_strength": float(MATS[m][1]), "cert": bool(rng.random() < 0.5)},
"use": {"load_N": float(round(rng.uniform(50, 3000), 0)), "temp_C": float(round(rng.uniform(-10, 80), 0)), "orient_deg": float(round(rng.uniform(0, 90), 0))},
"result": {"printable": bool(rng.random() < 0.8), "strength_mpa": float(round(MATS[m][1] * rng.uniform(0.6, 1.0), 1)),
"log_cycles": float(round(rng.uniform(3, 7.5), 2)), "conform": float(round(rng.uniform(30, 98), 1))}})
ok = sum(not validate(r) for r in recs)
print(f"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건")
# 4) 오류 주입 시험
bad = json.loads(json.dumps(recs[0]))
bad["cad"]["min_wall_mm"] = 0.0 # 허용 범위 미만(0 이하 벽두께)
bad["cad"]["unit"] = "inch" # 단위 혼재
bad["part"]["item_code"] = "1234567890123" # 코드 형식 오류
del bad["use"]["load_N"] # 사용 하중 누락
bad["result"]["conform"] = "높음" # 자료형 오류(문자열)
print("\n[오류 주입 결과]")
for e in validate(bad):
print(" -", e)
# 5) 프로세스 맵과 데이터 딕셔너리
print("\n[업무 프로세스 맵]")
print(" -> ".join(f"{i}.{p}" for i, (p, _) in enumerate(PROCESS, 1)))
for p, d in PROCESS:
print(f" {p:10s}: {d}")
rows = [(g, k, t.__name__, str(r) if r else "-", d) for g, f in S.items() for k, (t, r, d) in f.items()]
dd = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙", "설명"])
print(f"\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})")
print(dd[["그룹", "필드", "자료형", "설명"]].to_string(index=False))
print("\n예측 대상: 월 폐기율, 시험 출력 성공 여부, 인장강도, 군 규격 부합 점수. 최종 활용: 폐기율이 높고 대체 제작이 가능한 부품의 우선 선정")
```
실행 결과 예시
```text
정상 샘플 20건 중 검증 통과 20건, 오류 0건
[오류 주입 결과]
- part.item_code: 형식 불일치 '1234567890123'
- cad.unit: 허용되지 않은 값 'inch'
- cad.min_wall_mm: 최소값 0.2 미만
- use.load_N: 필수 필드 누락
- result.conform: 자료형 오류(str)
[업무 프로세스 맵]
1.폐기 부품 식별 -> 2.CAD 확보 -> 3.소재 선정 -> 4.제작 가능성 판별 -> 5.신뢰성 평가 -> 6.정비기술관 승인 -> 7.출력·후처리 -> 8.실 장착·시험 -> 9.사후검토(AAR)
폐기 부품 식별 : 부품 코드, 월 폐기수량, 폐기율, 조달 소요일
CAD 확보 : CAD 파일 형식, 삼각형 수, 단위, 치수
소재 선정 : 소재 코드, 공정 구분, 인장강도 기준값, 인증서 여부
제작 가능성 판별 : 벽두께, 돌출 비율, 최대 치수, 나사부
신뢰성 평가 : 사용 하중, 안전율, 피로 수명, 군 규격 부합 점수
정비기술관 승인 : 승인·반려·보류, 사유
출력·후처리 : 출력 시간, 소재량, 후처리 항목
실 장착·시험 : 장착 부대, 시험 결과, 파괴시험 강도
사후검토(AAR) : 예측 오차, 폐기율 변화, 보정 이력
[데이터 딕셔너리] 피처 26개 (목표 20개 이상: 달성)
그룹 필드 자료형 설명
part part_id str 부품 ID(가상)
part item_code str 부품 식별코드(국방 표준 군수 코드 형식을 모사한 가상 코드)
part fsc_group str 보급 분류군(가상 4자리)
part category str 부품군
part crit int 작전 중요도(1 일반, 2 중요, 3 핵심)
part scrap_rate float 월 폐기율(폐기수량/소요수량) - 예측대상 1
part lead_days float 기존 조달 소요일
cad cad_format str CAD 파일 형식
cad unit str 치수 단위(mm로 통일)
cad volume_cm3 float 부피
cad max_dim_mm float 최대 치수
cad min_wall_mm float 최소 벽두께
cad overhang float 45도 초과 돌출면 비율(지지대 필요 정도)
cad n_triangles int 메쉬 삼각형 수
cad thread bool 나사부 포함 여부
material material str 소재 코드
material process str 공정 구분(폴리머 압출·분말 / 금속 분말 용융)
... (이하 생략)
```
## 미션 2. CAD 파일 메쉬 정제, 슬라이싱 데이터 파싱 및 제작 규격 전처리 모듈 구현
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
- 코드 설명: 부품 이력 레코드(표기·단위·중복·결측 오류), CAD 삼각형 메쉬(NaN 정점·퇴화·중복·뒤집힌 법선), 슬라이싱(G-code류) 출력 파일의 불량 라인이 섞인 가상 원천 자료에 정제 규칙 12종을 적용하고, 긴급 제작 예외 레코드는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.
```python
"""부품 이력 레코드(표기·단위·중복·결측 오류), CAD 삼각형 메쉬(NaN 정점·퇴화·중복·뒤집힌 법선), 슬라이싱(G-code류) 출력 파일의 불량 라인이 섞인 가상 원천 자료에 정제 규칙 12종을 적용하고, 긴급 제작 예외 레코드는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다."""
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.impute import KNNImputer
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
rng = np.random.default_rng(2)
applied = []
def rule(name, n):
applied.append((name, int(n)))
# ---------------- 1) 부품 이력 레코드 ----------------
P = make_parts(200, seed=5)
raw = P[["part_id", "material", "max_dim_mm", "min_wall_mm", "volume_cm3", "scrap_rate", "load_N"]].copy()
raw["unit"] = "mm"
raw["test_date"] = [f"2026-{rng.integers(1, 10):02d}-{rng.integers(1, 28):02d}" for _ in range(len(raw))]
raw["emergency"] = False
idx = rng.choice(len(raw), 80, replace=False)
a, b, c, d, e, f, g = np.split(idx[:77], 7)
raw.loc[a, "material"] = rng.choice(["pa12", "PA-12", "ss316l", "SS 316L", "ti64"], len(a)) # 소재 표기 불일치
raw.loc[b, ["max_dim_mm", "min_wall_mm"]] = raw.loc[b, ["max_dim_mm", "min_wall_mm"]].values / 25.4 # inch 값
raw.loc[b, "unit"] = "inch"
raw.loc[c, "scrap_rate"] = (raw.loc[c, "scrap_rate"] * 100).round(1) # % 단위 혼재
raw.loc[d, "test_date"] = [x.replace("-", "/") for x in raw.loc[d, "test_date"]] # 날짜 형식 혼재
raw.loc[e, "min_wall_mm"] = rng.choice([0.0, -1.2], len(e)) # 불가능 값
raw.loc[f, ["load_N", "volume_cm3"]] = np.nan # 결측
raw.loc[g, "emergency"] = True; raw.loc[g, "load_N"] = raw.loc[g, "load_N"] * 12 # 긴급 제작 요청(예외: 과대 하중 기록, 보존)
raw = pd.concat([raw, raw.sample(10, random_state=1)], ignore_index=True) # 중복 레코드
print(f"[부품 이력] 원천 {len(raw)}건 (중복 10건, 오류 주입 {len(idx[:77])}건 포함)")
df = raw.copy()
rule("R1 중복 레코드 제거", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)
mat = df["material"].str.upper().str.replace(r"[\s-]", "", regex=True).map({"PA12": "PA12", "PETG": "PETG", "CFPA": "CF-PA", "ALSI10MG": "AlSi10Mg", "SS316L": "SS316L", "TI64": "Ti64"})
rule("R2 소재 코드 표기 통일", (mat != df["material"]).sum()); df["material"] = mat
inch = df["unit"] == "inch"
df.loc[inch, ["max_dim_mm", "min_wall_mm"]] *= 25.4; df["unit"] = "mm"; rule("R3 CAD 치수 단위 통일(inch -> mm)", inch.sum())
pct = df["scrap_rate"] > 1; df.loc[pct, "scrap_rate"] /= 100; rule("R4 폐기율 단위 통일(% -> 비율)", pct.sum())
t1 = pd.to_datetime(df["test_date"], format="%Y-%m-%d", errors="coerce")
t2 = pd.to_datetime(df["test_date"], format="%Y/%m/%d", errors="coerce"); rule("R5 시험일 형식 통일", t1.isna().sum()); df["test_date"] = t1.fillna(t2)
bad = df["min_wall_mm"] <= 0; df.loc[bad, "min_wall_mm"] = np.nan; rule("R6 불가능 벽두께 결측 처리", bad.sum())
miss = df[["load_N", "volume_cm3", "min_wall_mm"]].isna().sum().sum()
df[["load_N", "volume_cm3", "min_wall_mm"]] = KNNImputer(n_neighbors=5).fit_transform(df[["load_N", "volume_cm3", "min_wall_mm", "max_dim_mm"]])[:, :3]
rule("R7 결측 KNN 보정", miss)
q1, q3 = df["load_N"].quantile([0.25, 0.75]); out = df["load_N"] > q3 + 3 * (q3 - q1)
df["outlier_flag"] = out & ~df["emergency"]
rule("R12 긴급 제작 예외 보존·일반 이상값 표시", (out & df["emergency"]).sum())
# ---------------- 2) CAD 메쉬 ----------------
def box_tris(a_, b_, c_, k=4):
dims, tris = (a_, b_, c_), []
for ax in range(3):
u, v = [i for i in range(3) if i != ax]
for sgn in (0, 1):
for i in range(k):
for j in range(k):
def Pt(ii, jj):
p = np.zeros(3); p[ax] = dims[ax] * sgn; p[u] = dims[u] * ii / k; p[v] = dims[v] * jj / k; return p
p00, p10, p11, p01 = Pt(i, j), Pt(i + 1, j), Pt(i + 1, j + 1), Pt(i, j + 1)
flip = (-1 if ax == 1 else 1) != (1 if sgn else -1)
for t in ([p00, p10, p11], [p00, p11, p01]):
tris.append(t[::-1] if flip else t)
return np.array(tris)
def volume(T):
return float(np.einsum("ij,ij->i", T[:, 0], np.cross(T[:, 1], T[:, 2])).sum() / 6)
T0 = box_tris(40.0, 30.0, 20.0) # 정상 메쉬(가상 브래킷 블랭크, 부피 24 cm3)
nan_extra = T0[rng.choice(len(T0), 3, replace=False)].copy(); nan_extra[:, 0] = np.nan # NaN 정점을 가진 불량 삼각형
deg_extra = T0[rng.choice(len(T0), 8, replace=False)].copy(); deg_extra[:, 1] = deg_extra[:, 0] # 퇴화(면적 0) 삼각형
dup_extra = T0[rng.choice(len(T0), 6, replace=False)].copy() # 중복 삼각형
m = np.concatenate([T0, nan_extra, deg_extra, dup_extra])
fl = rng.choice(len(T0), 10, replace=False); m[fl] = m[fl][:, ::-1] # 법선 뒤집힘(원본 중 10개)
vol_raw = volume(np.nan_to_num(m))
n_raw = len(m)
nan_t = np.isnan(m).any(axis=(1, 2)); rule("R8 NaN 정점 삼각형 제거", nan_t.sum()); m = m[~nan_t]
area = 0.5 * np.linalg.norm(np.cross(m[:, 1] - m[:, 0], m[:, 2] - m[:, 0]), axis=1)
degen = area < 1e-9
keys = [tuple(sorted(map(tuple, np.round(t, 4)))) for t in m]
dup = np.array([k in set(keys[:i]) for i, k in enumerate(keys)])
rule("R9 퇴화·중복 삼각형 제거", (degen | dup).sum()); m = m[~(degen | dup)]
cen = m.mean(axis=(0, 1)); nrm = np.cross(m[:, 1] - m[:, 0], m[:, 2] - m[:, 0]); inward = np.einsum("ij,ij->i", nrm, m.mean(axis=1) - cen) < 0
m[inward] = m[inward][:, ::-1]; rule("R10 법선 방향 통일(안쪽 향함 뒤집기)", inward.sum())
vol_clean = volume(m)
# ---------------- 3) 슬라이싱 출력 파싱 ----------------
lines = ["; layer_height=0.2"]
for L in range(30):
lines.append(f";LAYER:{L}")
for s in range(12):
lines.append(f"G1 X{rng.uniform(0, 40):.2f} Y{rng.uniform(0, 30):.2f} E{rng.uniform(0.01, 0.2):.3f} F{rng.choice([1200, 1800, 2400])}")
for i in rng.choice(len(lines), 8, replace=False):
if lines[i].startswith("G1"):
lines[i] = lines[i].replace(" Y", " Yx") if i % 2 else lines[i].replace("F", "F-") # 좌표 깨짐·음수 이송속도
pat = re.compile(r"^G1 X(-?\d+\.\d+) Y(-?\d+\.\d+) E(\d+\.\d+) F(\d+)$")
good, rej, layer = [], [], -1
for ln in lines:
if ln.startswith(";LAYER:"):
layer = int(ln.split(":")[1])
elif ln.startswith("G1"):
mt = pat.match(ln)
(good if mt else rej).append((layer, *map(float, mt.groups())) if mt else ln)
G = pd.DataFrame(good, columns=["layer", "x", "y", "e", "f"])
rule("R11 슬라이싱 불량 라인 격리(수동 확인 목록)", len(rej))
# ---------------- 4) 정합성 검증 ----------------
chk = {"중복 레코드 0건": not df.duplicated().any(), "소재 코드 허용값": df["material"].isin(list(MAT)).all(),
"치수 단위 mm": (df["unit"] == "mm").all(), "폐기율 0~1": df["scrap_rate"].between(0, 1).all(),
"시험일 변환": df["test_date"].notna().all(), "벽두께 0 초과": (df["min_wall_mm"] > 0).all(),
"결측 0건": df[["load_N", "volume_cm3", "min_wall_mm"]].notna().all().all(),
f"긴급 제작 {int(df['emergency'].sum())}건 보존": int(df["emergency"].sum()) == len(g),
"메쉬 NaN 0건": not np.isnan(m).any(), "메쉬 부피 오차 1% 이내": abs(vol_clean - 24000) / 24000 < 0.01,
"슬라이싱 이송속도 양수": bool((G["f"] > 0).all())}
rate = np.mean(list(chk.values()))
print("\n[적용 규칙과 처리 건수]")
applied.sort(key=lambda r_: int(re.match(r"R(\d+)", r_[0]).group(1)))
for nme, n in applied:
print(f" {nme:42s} {n:4d}건")
print(f"\n정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 규칙 {len(applied)}종 (목표 10종 이상: {'달성' if len(applied) >= 10 else '미달'})")
for k, v in chk.items():
if not v:
print(" 실패:", k)
print(f"정제 후 부품 레코드 {len(df)}건, 일반 이상값 표시 {int(df['outlier_flag'].sum())}건, 긴급 제작 예외 {int(df['emergency'].sum())}건 유지")
print(f"메쉬 삼각형 {n_raw} -> {len(m)}개, 부피 {vol_raw / 1000:.2f} -> {vol_clean / 1000:.2f} cm3(기준 24.00)")
print(f"슬라이싱: {int(G['layer'].nunique())}층, 유효 {len(G)}줄, 격리 {len(rej)}줄, 총 압출량 {G['e'].sum():.1f}, 예상 이송 시간 {(np.hypot(G['x'].diff(), G['y'].diff()).fillna(0) / G['f'] * 60).sum():.2f}분")
num = lambda x: pd.to_numeric(x, errors="coerce")
cmp = pd.DataFrame({"정제 전 평균": [num(raw[c_]).mean() for c_ in ["max_dim_mm", "min_wall_mm", "scrap_rate", "load_N"]],
"정제 후 평균": [df[c_].mean() for c_ in ["max_dim_mm", "min_wall_mm", "scrap_rate", "load_N"]],
"정제 전 최소": [num(raw[c_]).min() for c_ in ["max_dim_mm", "min_wall_mm", "scrap_rate", "load_N"]],
"정제 후 최소": [df[c_].min() for c_ in ["max_dim_mm", "min_wall_mm", "scrap_rate", "load_N"]]},
index=["최대치수mm", "벽두께mm", "폐기율", "하중N"]).round(3)
print("\n[정제 전후 기술통계]"); print(cmp.to_string())
fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
ax[0].hist(num(raw["scrap_rate"]).dropna(), bins=30, alpha=0.6, label="raw"); ax[0].hist(df["scrap_rate"], bins=30, alpha=0.6, label="cleaned")
ax[0].set_title("Scrap rate before/after unit fix"); ax[0].legend()
ax[1].bar([n_.split()[0] for n_, _ in applied], [n_ for _, n_ in applied]); ax[1].set_title("Records changed by rule")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[부품 이력] 원천 210건 (중복 10건, 오류 주입 77건 포함)
[적용 규칙과 처리 건수]
R1 중복 레코드 제거 10건
R2 소재 코드 표기 통일 11건
R3 CAD 치수 단위 통일(inch -> mm) 11건
R4 폐기율 단위 통일(% -> 비율) 11건
R5 시험일 형식 통일 11건
R6 불가능 벽두께 결측 처리 11건
R7 결측 KNN 보정 33건
R8 NaN 정점 삼각형 제거 3건
R9 퇴화·중복 삼각형 제거 14건
R10 법선 방향 통일(안쪽 향함 뒤집기) 10건
R11 슬라이싱 불량 라인 격리(수동 확인 목록) 7건
R12 긴급 제작 예외 보존·일반 이상값 표시 5건
정합성 검증 11/11 통과, 통과율 100% (목표 100%: 달성), 규칙 12종 (목표 10종 이상: 달성)
정제 후 부품 레코드 200건, 일반 이상값 표시 3건, 긴급 제작 예외 11건 유지
메쉬 삼각형 209 -> 192개, 부피 21.00 -> 24.00 cm3(기준 24.00)
슬라이싱: 30층, 유효 353줄, 격리 7줄, 총 압출량 39.0, 예상 이송 시간 238.36분
[정제 전후 기술통계]
정제 전 평균 정제 후 평균 정제 전 최소 정제 후 최소
최대치수mm 43.270 46.097 0.817 18.448
벽두께mm 2.114 2.407 -1.200 0.405
폐기율 0.663 0.117 0.029 0.029
하중N 2785.396 2864.990 50.301 50.301
```
## 미션 3. 기하학적 형상 기반 1차 3D 프린팅 제작 가능 여부 판별 Baseline 알고리즘 구현
- 난이도: 중급 / 예상 소요시간: 2.5시간
- 코드 설명: 부품의 형상(최소 벽두께·돌출 비율·최대 치수)만으로 3D 프린팅 제작 가능 여부를 1차 판별하는 규칙 기반 Baseline 알고리즘을 구현하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·파레토(ABC) 분석으로 Baseline의 한계와 폐기 손실이 큰 핵심 관리 대상을 찾는다.
```python
"""부품의 형상(최소 벽두께·돌출 비율·최대 치수)만으로 3D 프린팅 제작 가능 여부를 1차 판별하는 규칙 기반 Baseline 알고리즘을 구현하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·파레토(ABC) 분석으로 Baseline의 한계와 폐기 손실이 큰 핵심 관리 대상을 찾는다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
df = make_parts(600, seed=3)
def baseline(d):
"""형상 기반 1차 판별: 벽두께 >= 공통 기준 1.0mm, 최대 치수 <= 출력 가능 치수, 돌출 비율 < 0.5 (소재별 차이는 보지 않는다)."""
return (d["min_wall_mm"] >= 1.0) & (d["max_dim_mm"] <= d["process"].map(BUILD_MM)) & (d["overhang"] < 0.5)
df["pred"] = baseline(df)
tp, fp = int((df["pred"] & df["printable"]).sum()), int((df["pred"] & ~df["printable"]).sum())
fn, tn = int((~df["pred"] & df["printable"]).sum()), int((~df["pred"] & ~df["printable"]).sum())
acc, prec, rec = (tp + tn) / len(df), tp / (tp + fp), tp / (tp + fn)
print(f"[Baseline 결과] 부품 {len(df)}종, 시험 출력 성공 {df['printable'].mean():.1%}")
print(f"정확도 {acc:.1%}, 정밀도 {prec:.1%}, 재현율 {rec:.1%} (혼동행렬 TP {tp} FP {fp} FN {fn} TN {tn})")
miss = df[df["pred"] & ~df["printable"]]
why = pd.Series(np.select([miss["thread"] & (miss["process"] == "polymer") & (miss["min_wall_mm"] < 1.5), (miss["process"] == "metal") & (miss["max_dim_mm"] > 140) & (miss["overhang"] > 0.3)],
["얇은 나사부 파손", "금속 대형 오버행 변형"], "소재별 최소 벽두께 미달"))
print("Baseline이 '가능'이라 했으나 실패한 원인:", ", ".join(f"{k} {v}건" for k, v in why.value_counts().items()))
print(f"Baseline이 '불가'라 했으나 실제 성공한 부품 {fn}건(금속 등 얇은 벽 출력 가능 소재를 놓침)")
print("-> 형상 규칙만으로는 나사부·열변형·소재 편차를 못 본다. 위험한 오판(FP)은 정비기술관 검토 전 걸러야 한다(미션 4~5의 동기).")
# 1) 상관분석: 시험 출력 성공 여부와의 관계(점이연 상관, 유의수준 0.05)
df["wall_ratio"] = df["min_wall_mm"] / df["material"].map(lambda m: MAT[m][5])
df["is_metal"] = (df["process"] == "metal").astype(int)
feats = ["wall_ratio", "min_wall_mm", "overhang", "max_dim_mm", "volume_cm3", "is_metal", "n_holes", "thread", "layer_mm", "load_N", "crit"]
rows = []
for f in feats:
r, p = stats.pearsonr(df[f].astype(float), df["printable"].astype(float))
rows.append({"피처": f, "상관계수": round(r, 3), "p-value": f"{p:.1e}", "유의": p < 0.05})
corr = pd.DataFrame(rows).sort_values("상관계수", key=abs, ascending=False)
print("\n[시험 출력 성공 여부와의 상관분석]")
print(corr.to_string(index=False))
n_sig = int(corr["유의"].sum())
print(f"유의미한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})")
t, p = stats.ttest_ind(df.loc[df["printable"], "scrap_rate"], df.loc[~df["printable"], "scrap_rate"])
print(f"폐기율 평균: 제작 가능 {df.loc[df['printable'], 'scrap_rate'].mean():.3f} vs 불가 {df.loc[~df['printable'], 'scrap_rate'].mean():.3f} (t검정 p={p:.2f}) -> 폐기율과 제작 가능성은 별개 축이므로 둘을 함께 봐야 우선순위가 정해진다")
# 2) 파레토(ABC): 월 폐기 손실액이 큰 (부품군, 소재) 조합 중 제작 가능한 것
g = df.groupby(["cat", "material"])["scrap_cost"].sum().sort_values(ascending=False)
cum = g.cumsum() / g.sum()
abc = pd.cut(cum, [0, 0.8, 0.95, 1.0], labels=["A", "B", "C"])
top = [f"{c}/{m}" for (c, m), v in abc.items() if v == "A"]
print(f"\n[파레토 A등급(누적 폐기 손실 80%)] {len(top)}개 조합: " + ", ".join(top[:6]) + " ...")
print(f"A등급은 전체 조합 {len(g)}개 중 {(abc == 'A').mean():.0%}, 이 중 Baseline 제작 가능 부품 비율 {df[df.set_index(['cat', 'material']).index.isin(list(abc[abc == 'A'].index))]['pred'].mean():.0%}")
# 3) EDA 차트 8종
fig, ax = plt.subplots(2, 4, figsize=(18, 8))
a = ax.ravel()
a[0].hist(df["scrap_rate"], bins=30); a[0].set_title("1. Scrap rate distribution")
df.groupby("cat")["scrap_rate"].mean().plot.bar(ax=a[1]); a[1].set_title("2. Mean scrap rate by category")
a[2].scatter(df["min_wall_mm"], df["max_dim_mm"], c=df["printable"], cmap="coolwarm_r", s=8); a[2].set_title("3. Wall vs size (blue=printable)")
df.boxplot(column="overhang", by="printable", ax=a[3]); a[3].set_title("4. Overhang by print result")
df["material"].value_counts().plot.bar(ax=a[4]); a[4].set_title("5. Parts by material")
a[5].scatter(df["load_N"], df["safety"], s=6); a[5].set_xscale("log"); a[5].set_yscale("log"); a[5].set_title("6. Load vs safety factor")
a[6].barh(corr["피처"], corr["상관계수"]); a[6].set_title("7. Correlation with print success")
a[7].plot(range(1, len(cum) + 1), cum.values, "o-"); a[7].axhline(0.8, ls="--", c="gray"); a[7].set_title("8. Pareto of scrap loss")
plt.suptitle("MNT06 EDA - baseline printability rule (synthetic data)"); plt.tight_layout(); plt.show()
print("\nEDA 차트 8종 생성 (목표 8종 이상: 달성)")
```
실행 결과 예시
```text
[Baseline 결과] 부품 600종, 시험 출력 성공 80.8%
정확도 91.0%, 정밀도 96.5%, 재현율 92.2% (혼동행렬 TP 447 FP 16 FN 38 TN 99)
Baseline이 '가능'이라 했으나 실패한 원인: 얇은 나사부 파손 14건, 소재별 최소 벽두께 미달 2건
Baseline이 '불가'라 했으나 실제 성공한 부품 38건(금속 등 얇은 벽 출력 가능 소재를 놓침)
-> 형상 규칙만으로는 나사부·열변형·소재 편차를 못 본다. 위험한 오판(FP)은 정비기술관 검토 전 걸러야 한다(미션 4~5의 동기).
[시험 출력 성공 여부와의 상관분석]
피처 상관계수 p-value 유의
overhang -0.385 1.3e-22 True
wall_ratio 0.294 2.1e-13 True
min_wall_mm 0.279 3.5e-12 True
is_metal 0.188 3.7e-06 True
layer_mm -0.159 8.9e-05 True
load_N 0.103 1.1e-02 True
crit -0.078 5.5e-02 False
thread -0.069 9.2e-02 False
max_dim_mm 0.026 5.3e-01 False
n_holes 0.018 6.6e-01 False
volume_cm3 -0.005 9.1e-01 False
유의미한 피처 6개 (목표 5개 이상: 달성)
폐기율 평균: 제작 가능 0.124 vs 불가 0.105 (t검정 p=0.01) -> 폐기율과 제작 가능성은 별개 축이므로 둘을 함께 봐야 우선순위가 정해진다
[파레토 A등급(누적 폐기 손실 80%)] 8개 조합: handle/SS316L, handle/AlSi10Mg, clip/Ti64, clip/AlSi10Mg, handle/Ti64, clip/SS316L ...
A등급은 전체 조합 36개 중 22%, 이 중 Baseline 제작 가능 부품 비율 82%
EDA 차트 8종 생성 (목표 8종 이상: 달성)
```
## 미션 4. 유한요소해석(FEA) 연계 야전 부품 인장강도·피로내구도 신뢰성 평가 머신러닝 고도화
- 난이도: 중급 / 예상 소요시간: 3.0시간
- 코드 설명: 유한요소해석(FEA)을 간이화한 응력 계산값과 소재·공정 지식으로 파생 피처 16개를 만들어 야전 부품의 인장강도와 피로 수명(신뢰성)을 예측하는 머신러닝 기준 모델을 시계열 교차검증(MAPE)으로 평가하고, 피처 중요도로 차원 축소 후보를 고른다.
```python
"""유한요소해석(FEA)을 간이화한 응력 계산값과 소재·공정 지식으로 파생 피처 16개를 만들어 야전 부품의 인장강도와 피로 수명(신뢰성)을 예측하는 머신러닝 기준 모델을 시계열 교차검증(MAPE)으로 평가하고, 피처 중요도로 차원 축소 후보를 고른다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import TimeSeriesSplit
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
parts = make_parts(900, seed=7) # 인덱스 순서 = 시험 수행 순서(앞이 과거)
X = make_features(parts)
print(f"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})")
mape = lambda a, p: float(np.mean(np.abs(a - p) / np.abs(a)))
gb = lambda: GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.05, random_state=0)
# 1) 시계열 교차검증: 과거 시험으로 학습 -> 이후 시험 예측 (5분할)
rows = []
for i, (tr, te) in enumerate(TimeSeriesSplit(5).split(X), 1):
ys, yf, bs = parts["strength_mpa"], parts["log_cycles"], X["base_strength"]
base = parts.iloc[tr].groupby("material")["strength_mpa"].mean() # 기준모델: 소재별 평균 강도
p0 = parts.iloc[te]["material"].map(base)
p1 = gb().fit(X.iloc[tr], (ys / bs).iloc[tr]).predict(X.iloc[te]) * bs.iloc[te] # 소재 기준강도 대비 저감 계수를 학습
q1 = gb().fit(X.iloc[tr], yf.iloc[tr]).predict(X.iloc[te])
rows.append({"분할": i, "학습": len(tr), "검증": len(te), "강도 기준 MAPE": round(mape(ys.iloc[te], p0), 3), "강도 GBM MAPE": round(mape(ys.iloc[te], p1), 3),
"피로수명 MAE(log10)": round(float(np.mean(np.abs(yf.iloc[te] - q1))), 2)})
cv = pd.DataFrame(rows)
print(cv.to_string(index=False))
m_b, m_g = cv["강도 기준 MAPE"].mean(), cv["강도 GBM MAPE"].mean()
print(f"평균 인장강도 MAPE: 기준모델(소재별 평균) {m_b:.1%}, GBM {m_g:.1%} (목표 25% 이하: {'달성' if m_g <= 0.25 else '미달'})")
print(f"피로 수명 예측 평균 오차 {cv['피로수명 MAE(log10)'].mean():.2f} 자릿수(log10 회)")
# 2) 마지막 20%를 신뢰성 Hold-out으로: 예측 안전율로 '대체 제작 신뢰성 충족(안전율 1.5 이상)' 판별
cut = int(len(parts) * 0.8)
ratio = parts["strength_mpa"] / X["base_strength"]
ms = gb().fit(X.iloc[:cut], ratio.iloc[:cut])
te = parts.iloc[cut:]
p_all = ms.predict(X.iloc[cut:]) * X.iloc[cut:]["base_strength"]
pred_sf = p_all / (te["load_N"] / X.iloc[cut:]["cross_mm2"])
ok_true, ok_pred = te["safety"] >= 1.5, pred_sf >= 1.5
print(f"\n[안전율 1.5 이상 판별] 정확도 {(ok_true == ok_pred).mean():.1%}, 위험 오판(불충분한데 충족이라 판정) {int((~ok_true & ok_pred).sum())}건 / {len(te)}건")
print("간이 해석 응력만으로 만든 판별의 정확도:", f"{((X.iloc[cut:]['stress_ratio'] < 1 / 1.5) == ok_true).mean():.1%}")
# 3) 피처 중요도와 차원 축소 후보
model = gb().fit(X, ratio)
imp = pd.Series(model.feature_importances_, X.columns).sort_values(ascending=False)
print("\n피처 중요도 상위 5(강도 저감 계수 모델):", ", ".join(f"{k}({v:.2f})" for k, v in imp.head(5).items()))
print("중요도 0.01 미만 피처(차원 축소 후보):", list(imp[imp < 0.01].index))
print("단, 단면적·응력집중·응력비는 강도 모델에서는 약하지만 안전율(하중 쪽) 계산에 필요하므로 삭제하지 않고 모델별로 나눠 쓴다.")
print("피처 설계 근거: 충전율·적층 방향·소재·온도 저하는 강도를 깎는 요인이고 단면적·구멍·나사부는 응력을 키우는 요인이라 정비 현장에서도 설명이 된다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
imp.head(10)[::-1].plot.barh(ax=ax[0]); ax[0].set_title("Feature importance (strength knock-down)")
ax[1].scatter(te["strength_mpa"], p_all, s=10); ax[1].plot([0, 1000], [0, 1000], "r--")
ax[1].set_xlabel("measured strength (MPa)"); ax[1].set_ylabel("predicted"); ax[1].set_title("Hold-out: strength prediction")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
파생 피처 16개 생성 (목표 10개 이상: 달성)
분할 학습 검증 강도 기준 MAPE 강도 GBM MAPE 피로수명 MAE(log10)
1 150 150 0.100 0.066 0.39
2 300 150 0.102 0.057 0.27
3 450 150 0.121 0.056 0.28
4 600 150 0.095 0.052 0.32
5 750 150 0.118 0.062 0.26
평균 인장강도 MAPE: 기준모델(소재별 평균) 10.7%, GBM 5.9% (목표 25% 이하: 달성)
피로 수명 예측 평균 오차 0.30 자릿수(log10 회)
[안전율 1.5 이상 판별] 정확도 97.8%, 위험 오판(불충분한데 충족이라 판정) 2건 / 180건
간이 해석 응력만으로 만든 판별의 정확도: 93.9%
피처 중요도 상위 5(강도 저감 계수 모델): infill_eff(0.33), orient_sin2(0.22), is_metal(0.19), base_strength(0.13), temp_derate(0.11)
중요도 0.01 미만 피처(차원 축소 후보): ['overhang_layer', 'fea_stress', 'load_per_vol', 'surf_vol', 'stress_ratio', 'slender', 'kt', 'wall_ratio', 'crit', 'cross_mm2', 'cert']
단, 단면적·응력집중·응력비는 강도 모델에서는 약하지만 안전율(하중 쪽) 계산에 필요하므로 삭제하지 않고 모델별로 나눠 쓴다.
피처 설계 근거: 충전율·적층 방향·소재·온도 저하는 강도를 깎는 요인이고 단면적·구멍·나사부는 응력을 키우는 요인이라 정비 현장에서도 설명이 된다.
```
## 미션 5. 긴급 야전 제작 부품의 군사 규격(MIL-STD) 부합성 신뢰도 점수 산출 엔진 개발
- 난이도: 중급~고급 / 예상 소요시간: 3.5시간
- 코드 설명: 긴급 야전 제작 부품의 군 규격 부합 신뢰도 점수(0~100, 가상 기준)를 앙상블 회귀(GBM+RandomForest)로 산출하는 엔진을 만들고, Hold-out 부품에서 원 입력 5개 선형 기준모델 대비 MAE/RMSE 개선율과 R2, 점수 구간별 실제 부합률(보정 상태)과 예측 불확실성을 검증한다.
```python
"""긴급 야전 제작 부품의 군 규격 부합 신뢰도 점수(0~100, 가상 기준)를 앙상블 회귀(GBM+RandomForest)로 산출하는 엔진을 만들고, Hold-out 부품에서 원 입력 5개 선형 기준모델 대비 MAE/RMSE 개선율과 R2, 점수 구간별 실제 부합률(보정 상태)과 예측 불확실성을 검증한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score, roc_auc_score
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
train = pd.concat([make_parts(1000, seed=s) for s in (11, 12)], ignore_index=True)
test = make_parts(600, seed=21) # Hold-out: 학습에 쓰지 않은 부품
Xtr, Xte, ytr, yte = make_features(train), make_features(test), train["conform"], test["conform"]
# 1) 기준모델(원 입력 5개 선형) vs 앙상블
raw_cols = ["load_N", "min_wall_mm", "infill", "temp_C", "cert"]
base = LinearRegression().fit(train[raw_cols], ytr)
gbm = GradientBoostingRegressor(n_estimators=300, max_depth=3, learning_rate=0.05, random_state=0).fit(Xtr, ytr)
rf = RandomForestRegressor(n_estimators=150, min_samples_leaf=3, random_state=0, n_jobs=1).fit(Xtr, ytr)
preds = {"기준모델(선형 5입력)": base.predict(test[raw_cols]), "GBM": gbm.predict(Xte), "RandomForest": rf.predict(Xte)}
preds["앙상블(GBM+RF)"] = (preds["GBM"] + preds["RandomForest"]) / 2
tab = pd.DataFrame([{"모델": k, "MAE": mean_absolute_error(yte, p), "RMSE": mean_squared_error(yte, p) ** 0.5, "R2": r2_score(yte, p)} for k, p in preds.items()]).round(3)
print(f"학습 {len(train)}종 / Hold-out {len(test)}종")
print(tab.to_string(index=False))
b, e = tab.iloc[0], tab.iloc[-1]
imp_mae, imp_rmse = 1 - e["MAE"] / b["MAE"], 1 - e["RMSE"] / b["RMSE"]
print(f"기준모델 대비 개선: MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), "
f"R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= 0.85 else '미달'})")
# 2) 신뢰도 점수 구간별 실제 부합률: 실제 점수 70 이상을 '규격 부합'으로 본다(가상 기준)
score = np.clip(preds["앙상블(GBM+RF)"], 0, 100)
actual = (yte >= 70).values
bins = pd.cut(score, [-1, 50, 70, 85, 101], labels=["<50 부적합 의심", "50~70 보완 필요", "70~85 조건부", ">=85 부합 유력"])
cal = pd.DataFrame({"구간": bins, "점수": score, "실제부합": actual}).groupby("구간", observed=True).agg(건수=("점수", "size"), 평균점수=("점수", "mean"), 실제부합률=("실제부합", "mean")).round(2)
print("\n[점수 구간별 실제 부합률]")
print(cal.to_string())
mono = bool((cal["실제부합률"].diff().dropna() >= 0).all())
print(f"점수 구간이 높을수록 실제 부합률이 {'단조 증가' if mono else '단조 증가하지 않음'}, AUC {roc_auc_score(actual, score):.3f}")
# 3) 예측 안정성: 두 모델의 의견 차이가 큰 부품은 '판정 보류' 후보
gap = np.abs(preds["GBM"] - preds["RandomForest"])
hold = gap > np.quantile(gap, 0.9)
print(f"\n[예측 안정성] 모델 간 차이 상위 10% {int(hold.sum())}종: 평균 오차 {np.abs(yte[hold] - score[hold]).mean():.1f}점 vs 나머지 {np.abs(yte[~hold] - score[~hold]).mean():.1f}점")
irr = test["cert"].eq(0) & test["crit"].eq(3)
print(f"인증서 없는 핵심 부품 {int(irr.sum())}종: 평균 점수 {score[irr.values].mean():.1f}점 -> 점수와 별개로 소재 인증 확보가 선행되어야 한다")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].scatter(yte, score, s=8); ax[0].plot([0, 100], [0, 100], "r--"); ax[0].set_xlabel("actual score"); ax[0].set_ylabel("predicted")
ax[0].set_title(f"Conformity score hold-out (R2={e['R2']:.2f})")
ax[1].plot(cal["평균점수"], cal["실제부합률"], "o-"); ax[1].set_xlabel("mean confidence score"); ax[1].set_ylabel("actual conform rate"); ax[1].set_title("Score band calibration")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
학습 2000종 / Hold-out 600종
모델 MAE RMSE R2
기준모델(선형 5입력) 15.800 18.627 0.438
GBM 3.893 5.120 0.958
RandomForest 4.246 5.740 0.947
앙상블(GBM+RF) 3.916 5.208 0.956
기준모델 대비 개선: MAE 75.2%, RMSE 72.0% (목표 20% 이상: 달성), R2 0.956 (목표 0.85 이상: 달성)
[점수 구간별 실제 부합률]
건수 평균점수 실제부합률
구간
<50 부적합 의심 217 32.12 0.00
50~70 보완 필요 131 60.96 0.16
70~85 조건부 139 77.20 0.91
>=85 부합 유력 113 90.07 1.00
점수 구간이 높을수록 실제 부합률이 단조 증가, AUC 0.987
[예측 안정성] 모델 간 차이 상위 10% 60종: 평균 오차 6.6점 vs 나머지 3.6점
인증서 없는 핵심 부품 57종: 평균 점수 48.5점 -> 점수와 별개로 소재 인증 확보가 선행되어야 한다
```
## 미션 6. 부품별 제작 소요 시간, 비용 및 신뢰성 평가 결과 가시화 대시보드 구현
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
- 코드 설명: 부품별 출력 소요 시간·제작 비용·신뢰성 점수와 폐기 손실을 한 화면에 보여 주는 대시보드를 그리고, 군 규격 부합 점수 예측마다 기여도 상위 3개 인자를 가림(occlusion) 방식(SHAP 근사)으로 자동 산출해 워터폴 그림과 자연어 설명으로 제공하는 설명가능성(XAI) 모듈을 구현한다.
```python
"""부품별 출력 소요 시간·제작 비용·신뢰성 점수와 폐기 손실을 한 화면에 보여 주는 대시보드를 그리고, 군 규격 부합 점수 예측마다 기여도 상위 3개 인자를 가림(occlusion) 방식(SHAP 근사)으로 자동 산출해 워터폴 그림과 자연어 설명으로 제공하는 설명가능성(XAI) 모듈을 구현한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
KO = {"base_strength": "소재 기준강도", "is_metal": "금속 공정", "cross_mm2": "하중 지지 단면적", "kt": "구멍·나사부 응력집중", "fea_stress": "간이 해석 응력",
"stress_ratio": "응력/기준강도 비", "infill_eff": "충전율", "orient_sin2": "적층 방향 영향", "temp_derate": "고온 강도 저하", "overhang_layer": "돌출x층두께",
"slender": "세장비", "surf_vol": "표면적/부피", "wall_ratio": "벽두께 여유", "load_per_vol": "부피당 하중", "cert": "소재 인증서", "crit": "작전 중요도"}
def explain(model, X, bg, top=3):
"""가림(occlusion) 방식 국소 기여도: 피처 j를 배경 표본 값으로 바꿨을 때 예측이 평균적으로 얼마나 달라지는지(SHAP 근사)."""
base = model.predict(X)
contrib = np.zeros(X.shape)
for j, c in enumerate(X.columns):
for b in bg:
Xb = X.copy(); Xb[c] = b[j]
contrib[:, j] += base - model.predict(Xb)
contrib /= len(bg)
C = pd.DataFrame(contrib, columns=X.columns, index=X.index)
return base, C, [list(C.loc[i].abs().sort_values(ascending=False).index[:top]) for i in C.index]
hist, parts = make_parts(1000, seed=11), make_parts(300, seed=21)
Xh, X = make_features(hist), make_features(parts)
model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(Xh, hist["conform"])
AVG = float(hist["conform"].mean())
# 1) 설명가능성: 전체 부품에 대해 상위 3개 인자 자동 산출
t0 = time.perf_counter()
pred, C, tops = explain(model, X, Xh.sample(25, random_state=0).values)
sec = time.perf_counter() - t0
rate = np.mean([len(t) == 3 for t in tops])
print(f"[교육용가상자료] 부품 {len(X)}종 설명 산출 {sec:.1f}초, 상위 3개 인자 자동 산출률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})")
gap = np.abs(C.sum(axis=1) - (pred - model.predict(Xh).mean())).mean()
print(f"기여도 합과 (예측 - 평균 예측)의 평균 차이 {gap:.1f}점 -> 근사 설명이므로 참고 지표로 쓴다")
freq = pd.Series([f for t in tops for f in t]).map(lambda f: KO.get(f, f)).value_counts().head(5)
print("상위 3개 인자에 자주 등장한 요인:", ", ".join(f"{k} {v}건" for k, v in freq.items()))
# 2) 대시보드용 표: 출력 시간·비용·신뢰성·폐기 손실
parts["score"] = np.clip(pred, 0, 100)
parts["save"] = parts["buy_cost"] - parts["print_cost"] # 부품 1개당 구매 대비 절감(만원)
cand = parts[(parts["score"] >= 70) & parts["printable"]].copy()
cand["priority"] = cand["scrap_cost"] * (cand["score"] / 100)
print(f"\n[대시보드 요약] 부품 {len(parts)}종 중 점수 70 이상이며 출력 가능 {len(cand)}종, 월 폐기 손실 합계 {cand['scrap_cost'].sum():,.0f}만원(가상)")
top = cand.sort_values("priority", ascending=False).head(5)
print(top[["part_id", "cat", "material", "build_h", "print_cost", "buy_cost", "score", "scrap_cost"]].round(1).to_string(index=False))
fig, ax = plt.subplots(2, 3, figsize=(15, 8))
col = {1: "tab:gray", 2: "tab:orange", 3: "tab:red"}
ax[0, 0].scatter(parts["build_h"], parts["print_cost"], c=parts["crit"].map(col), s=10); ax[0, 0].set_xscale("log"); ax[0, 0].set_yscale("log")
ax[0, 0].set_title("Build time vs cost (red = critical)"); ax[0, 0].set_xlabel("build hours")
ax[0, 1].scatter(parts["print_cost"], parts["buy_cost"], s=8); ax[0, 1].plot([1, 5000], [1, 5000], "r--"); ax[0, 1].set_xscale("log"); ax[0, 1].set_yscale("log")
ax[0, 1].set_title("Print cost vs purchase cost"); ax[0, 1].set_xlabel("print cost")
ax[0, 2].hist(parts["score"], bins=25); ax[0, 2].axvline(70, ls="--", c="k"); ax[0, 2].set_title("Conformity score distribution")
ax[1, 0].scatter(parts["score"], parts["scrap_cost"], c=parts["printable"].map({True: "tab:blue", False: "tab:red"}), s=8); ax[1, 0].set_yscale("log")
ax[1, 0].set_title("Score vs scrap loss (blue = printable)"); ax[1, 0].set_xlabel("score")
ax[1, 1].barh(top["part_id"], top["scrap_cost"]); ax[1, 1].set_title("Top 5 candidates by scrap loss")
i = int(parts["score"].idxmin())
ci = C.loc[i].reindex(C.loc[i].abs().sort_values(ascending=False).index)[:6][::-1]
start = model.predict(Xh).mean()
ax[1, 2].barh(range(len(ci)), ci.values, left=start + np.r_[0, np.cumsum(ci.values)[:-1]], color=["tab:green" if v > 0 else "tab:red" for v in ci.values])
ax[1, 2].set_yticks(range(len(ci)), ci.index); ax[1, 2].set_title(f"Waterfall: {parts.loc[i, 'part_id']} (lowest score)"); ax[1, 2].set_xlabel("score")
plt.tight_layout(); plt.show()
print("대시보드 구성요소 6종: 시간-비용, 출력-구매 비용, 점수 분포, 점수-폐기손실, 우선 후보 5종, 점수 워터폴")
# 3) 자연어 설명 예시(정비기술관 화면 하단)
def sentence(k):
parts_ = [f"{KO.get(f, f)}({'+' if C.loc[k, f] > 0 else ''}{C.loc[k, f]:.1f}점)" for f in tops[k]]
return f"{parts.loc[k, 'part_id']} {parts.loc[k, 'cat']}/{parts.loc[k, 'material']} 부합 점수 {pred[k]:.1f}점(평균 대비 {pred[k] - AVG:+.1f}점), 영향 요인: " + ", ".join(parts_)
print("\n[예측 설명 예시]")
for k in list(top.index[:3]) + [i]:
print(" - " + sentence(k))
print("\n점수가 낮은 부품은 인자별 조치(인증서 확보, 충전율 상향, 벽두께 보강 등)를 정비기술관이 판단한다. 설명은 모델 근사이며 승인 결정을 대신하지 않는다.")
```
실행 결과 예시
```text
[교육용가상자료] 부품 300종 설명 산출 2.4초, 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)
기여도 합과 (예측 - 평균 예측)의 평균 차이 6.7점 -> 근사 설명이므로 참고 지표로 쓴다
상위 3개 인자에 자주 등장한 요인: 소재 인증서 286건, 응력/기준강도 비 282건, 구멍·나사부 응력집중 137건, 돌출x층두께 116건, 적층 방향 영향 31건
[대시보드 요약] 부품 300종 중 점수 70 이상이며 출력 가능 106종, 월 폐기 손실 합계 222,932만원(가상)
part_id cat material build_h print_cost buy_cost score scrap_cost
P121 handle Ti64 93.7 2925.4 7443.9 71.2 45271.3
P117 handle AlSi10Mg 23.6 600.5 1218.9 83.5 17632.8
P159 handle Ti64 15.7 491.7 878.2 81.6 17124.9
P293 handle SS316L 57.4 1483.2 1960.5 92.0 15131.3
P090 handle Ti64 40.6 1268.2 3157.5 90.5 12245.6
대시보드 구성요소 6종: 시간-비용, 출력-구매 비용, 점수 분포, 점수-폐기손실, 우선 후보 5종, 점수 워터폴
[예측 설명 예시]
- P121 handle/Ti64 부합 점수 71.2점(평균 대비 +13.2점), 영향 요인: 응력/기준강도 비(+10.0점), 소재 인증서(-9.3점), 구멍·나사부 응력집중(+4.2점)
- P117 handle/AlSi10Mg 부합 점수 83.5점(평균 대비 +25.5점), 영향 요인: 응력/기준강도 비(+10.1점), 소재 인증서(+3.8점), 구멍·나사부 응력집중(+3.1점)
- P159 handle/Ti64 부합 점수 81.6점(평균 대비 +23.6점), 영향 요인: 응력/기준강도 비(+10.0점), 돌출x층두께(+5.0점), 소재 인증서(+4.2점)
- P282 bracket/PA12 부합 점수 7.2점(평균 대비 -50.7점), 영향 요인: 응력/기준강도 비(-39.5점), 소재 인증서(-12.9점), 적층 방향 영향(-1.9점)
점수가 낮은 부품은 인자별 조치(인증서 확보, 충전율 상향, 벽두께 보강 등)를 정비기술관이 판단한다. 설명은 모델 근사이며 승인 결정을 대신하지 않는다.
```
## 미션 7. 야전 3D 프린팅 부품 실 장착 승인을 위한 정비기술관 HITL 검토 절차 설계
- 난이도: 고급 / 예상 소요시간: 3.5시간
- 코드 설명: 야전 3D 프린팅 부품의 실 장착 승인을 위해, 모델이 대체 제작을 제안하면 정비기술관 승인 대기열에 올리고 승인·반려·무응답(구매 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다. 구매 유지 대비 비용 절감률과 승인 지연·요청 급증에 대한 민감도, 자동 승인 시의 위험을 비교한다.
```python
"""야전 3D 프린팅 부품의 실 장착 승인을 위해, 모델이 대체 제작을 제안하면 정비기술관 승인 대기열에 올리고 승인·반려·무응답(구매 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다. 구매 유지 대비 비용 절감률과 승인 지연·요청 급증에 대한 민감도, 자동 승인 시의 위험을 비교한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----
DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별
FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)
POST = 3.0 # 후처리·검사 비용(만원)
def make_requests_month(catalog, n=250, seed=0):
"""월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다."""
return catalog.sample(n, replace=True, weights=catalog["demand_month"], random_state=seed).reset_index(drop=True)
def buy_cost(r):
return r["buy_cost"] + r["lead_days"] * DOWN[r["crit"]]
def print_outcome(r, delay_d=1.0):
"""실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다."""
wait = (r["build_h"] / 24 + delay_d + 2.0) * DOWN[r["crit"]]
ok = bool(r["printable"] and r["conform"] >= 70)
c = r["print_cost"] + POST + wait
return (c, True) if ok else (c + buy_cost(r) + FAIL[r["crit"]], False)
class Audit:
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 officer(r, rng):
"""[모의 정비기술관] 핵심 부품의 인증서 미보유·낮은 점수·장시간 점유는 반려, 8%는 기한 내 무응답(구매 유지)."""
if rng.random() < 0.08:
return "무응답", "기한 내 응답 없음 -> 기존 조달 유지"
if r["crit"] == 3 and r["cert"] == 0:
return "반려", "핵심 부품 소재 인증서 없음"
if r["crit"] == 3 and r["score"] < 80:
return "반려", f"핵심 부품 점수 {r['score']:.0f} < 80"
if r["build_h"] > 80:
return "반려", f"출력 {r['build_h']:.0f}시간으로 장비 점유 과다"
return "승인", f"점수 {r['score']:.0f}, 구매 대비 이득 확인"
def run(req, mode, delay=1.0, seed=0, audit=None):
"""mode: buy(기존 조달) / hitl(제안 -> 승인 시 장착) / auto(제안 전부 적용: 비교용 상한, 실제 운용 금지)"""
rng = np.random.default_rng(seed)
rows = []
for k, r in req.iterrows():
c_buy, how, fail = buy_cost(r), "구매", False
proposed = mode != "buy" and r["score"] >= 70 and r["rule_ok"]
if proposed:
if mode == "auto":
dec = "승인"
else:
dec, why = officer(r, rng)
audit.add(event="PROPOSE", part=r["part_id"], score=round(float(r["score"]), 1), status="PENDING")
audit.add(event="DECIDE", part=r["part_id"], decision=dec, reason=why, by="정비기술관(모의)")
if dec == "승인":
c, ok = print_outcome(r, delay)
how, fail = "3D 제작", not ok
c_buy = c
rows.append({"part_id": r["part_id"], "crit": r["crit"], "proposed": proposed, "how": how, "fail": fail, "cost": c_buy,
"dec": dec if proposed else "-", "scrap_cost": r["scrap_cost"]})
return pd.DataFrame(rows)
hist = make_parts(1000, seed=11)
model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(make_features(hist), hist["conform"])
cat = make_parts(300, seed=21)
cat["score"] = np.clip(model.predict(make_features(cat)), 0, 100)
cat["rule_ok"] = (cat["min_wall_mm"] >= 1.0) & (cat["max_dim_mm"] <= cat["process"].map(BUILD_MM)) & (cat["overhang"] < 0.5) # 형상 Baseline(미션 3)
req = make_requests_month(cat, 250, seed=3)
audit = Audit()
R = {m: run(req, m, seed=5, audit=audit) for m in ("buy", "hitl", "auto")}
T = pd.DataFrame([{"방식": n, "총비용(만원)": round(R[m]["cost"].sum()), "3D 제작 건수": int((R[m]["how"] == "3D 제작").sum()), "장착 후 불합격": int(R[m]["fail"].sum()),
"불합격 비용(만원)": int(sum(FAIL[c] for c in R[m].loc[R[m]["fail"], "crit"]))}
for m, n in [("buy", "기존 조달(구매)"), ("hitl", "제안 + 정비기술관 승인"), ("auto", "제안 전부 적용(비교용)")] for n in [n]])
T.insert(0, "방식", T.pop("방식"))
print("[교육용가상자료] 월간 대체 제작 요청 250건 모의")
print(T.to_string(index=False))
cut = 1 - T.loc[1, "총비용(만원)"] / T.loc[0, "총비용(만원)"]
print(f"HITL 적용 총비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})")
print(f"제안 전부 적용은 절감률 {1 - T.loc[2, '총비용(만원)'] / T.loc[0, '총비용(만원)']:.1%}로 더 높지만 정비기술관 판단 없이 장착하는 방식이라 실제 운용에서는 금지한다(비교용 상한)")
rj = R["hitl"][R["hitl"]["dec"] == "반려"]
would_fail = sum(not print_outcome(req.loc[i])[1] for i in rj.index)
print(f"반려 {len(rj)}건 중 실제로 장착했다면 불합격이었을 부품 {would_fail}건 -> "
+ ("승인 단계가 불합격 위험을 미리 걸러 냈다" if would_fail else "이번 모의에서는 반려가 모두 보수적 판단이었다(기회손실). 반려 기준은 미션 9 AAR에서 재검토한다"))
dec = R["hitl"][R["hitl"]["proposed"]]["dec"].value_counts()
print(f"\n변경 제안 {int(dec.sum())}건: " + ", ".join(f"{k} {v}건" for k, v in dec.items()))
rej = [e["reason"] for e in audit.log if e.get("decision") == "반려"]
print("반려 사유 예:", "; ".join([x for x in (next((x for x in rej if k in x), None) for k in ("인증서", "점수", "점유")) if x]))
n_dec = sum(e["event"] == "DECIDE" for e in audit.log)
print(f"제안 {int(dec.sum())}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / max(int(dec.sum()), 1):.0%}), 자동 승인 0건, 무응답 시 기존 조달 유지")
print(f"감사 로그 무결성: {'정상' if audit.verify() is None else '이상'}", end=", ")
audit.log[5]["reason"] = "사후 수정"
print(f"사후 변조 시 seq {audit.verify()}에서 탐지")
print("\n[민감도] 승인 지연(일) -> 비용 절감률")
for d in (0.0, 1.0, 3.0, 7.0, 14.0):
r_ = run(req, "hitl", delay=d, seed=5, audit=Audit())
print(f" {d:>4}: {1 - r_['cost'].sum() / T.loc[0, '총비용(만원)']:.1%}")
surge = make_requests_month(cat, 400, seed=3)
b, h = run(surge, "buy"), run(surge, "hitl", seed=5, audit=Audit())
print(f"요청 1.6배 급증(400건) 시나리오: 비용 절감률 {1 - h['cost'].sum() / b['cost'].sum():.1%}, 승인 대기 건수 {int(h['proposed'].sum())}건 -> 정비기술관 검토 인력이 병목")
plt.figure(figsize=(7, 4))
plt.bar(["buy", "HITL", "auto(compare)"], T["총비용(만원)"], color=["tab:gray", "tab:blue", "tab:orange"]); plt.ylabel("total cost (virtual)")
plt.title("HITL simulator: monthly cost"); plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[교육용가상자료] 월간 대체 제작 요청 250건 모의
방식 총비용(만원) 3D 제작 건수 장착 후 불합격 불합격 비용(만원)
기존 조달(구매) 102267 0 0 0
제안 + 정비기술관 승인 86487 72 1 10
제안 전부 적용(비교용) 80225 96 1 10
HITL 적용 총비용 절감률 15.4% (목표 15% 이상: 달성)
제안 전부 적용은 절감률 21.6%로 더 높지만 정비기술관 판단 없이 장착하는 방식이라 실제 운용에서는 금지한다(비교용 상한)
반려 15건 중 실제로 장착했다면 불합격이었을 부품 0건 -> 이번 모의에서는 반려가 모두 보수적 판단이었다(기회손실). 반려 기준은 미션 9 AAR에서 재검토한다
변경 제안 96건: 승인 72건, 반려 15건, 무응답 9건
반려 사유 예: 핵심 부품 소재 인증서 없음; 핵심 부품 점수 79 < 80
제안 96건 중 결정 기록 96건(로깅률 100%), 자동 승인 0건, 무응답 시 기존 조달 유지
감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지
[민감도] 승인 지연(일) -> 비용 절감률
0.0: 15.6%
1.0: 15.4%
3.0: 15.1%
7.0: 14.5%
14.0: 13.5%
요청 1.6배 급증(400건) 시나리오: 비용 절감률 13.1%, 승인 대기 건수 141건 -> 정비기술관 검토 인력이 병목
```
## 미션 8. 복잡 형상 부품 50종 일괄 신뢰성 평가 시뮬레이션 부하 테스트
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 복잡 형상 부품 50종(최대 500종) 일괄 신뢰성 평가를 요청하면서 손상 레코드 주입과 평가 모델 장애를 일으켜, 일괄 처리+화면 생성 시간, '신뢰성 미달 경보' 정확도, 요청 유실, 손상 데이터의 수동 확인 분리, 필터 조회 응답, 모델 장애 시 간이 규칙으로의 기능 축소 운용을 스트레스 테스트한다.
```python
"""복잡 형상 부품 50종(최대 500종) 일괄 신뢰성 평가를 요청하면서 손상 레코드 주입과 평가 모델 장애를 일으켜, 일괄 처리+화면 생성 시간, '신뢰성 미달 경보' 정확도, 요청 유실, 손상 데이터의 수동 확인 분리, 필터 조회 응답, 모델 장애 시 간이 규칙으로의 기능 축소 운용을 스트레스 테스트한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
ALERT, BAND = 70.0, 5.0 # 점수 65 미만 '경보', 75 이상 '정상', 그 사이는 모델 불확실 구간이라 '보류(수동 확인)'
def make_batch(n, seed):
"""[교육용가상자료] 복잡 형상(돌출 많고 구멍 3개 이상) 부품을 n종 뽑아 한꺼번에 평가를 요청한다."""
pool = make_parts(max(4 * n, 600), seed=seed)
cx = pool[(pool["overhang"] > 0.3) & (pool["n_holes"] >= 3)]
return cx.sample(n, replace=len(cx) < n, random_state=seed).reset_index(drop=True)
def validate(r):
errs = []
if not r["min_wall_mm"] > 0 or not r["max_dim_mm"] > 0:
errs.append("치수 오류")
if r["material"] not in MAT:
errs.append("소재 코드 오류")
if not np.isfinite(r["load_N"]):
errs.append("하중 누락")
return errs
def fallback_score(X):
"""모델 장애 시 간이 규칙: 응력/기준강도 비로만 점수화(보수적)."""
return np.clip(100 * (1 - 1.8 * X["stress_ratio"]), 0, 100).values
def evaluate(batch, model, fail_model=False):
ok_mask = np.array([not validate(r) for r in batch.to_dict("records")])
good, manual = batch[ok_mask].copy(), batch[~ok_mask]
X = make_features(good)
try:
if fail_model:
raise RuntimeError("model down")
good["score"], good["src"] = np.clip(model.predict(X), 0, 100), "모델"
except Exception:
good["score"], good["src"] = fallback_score(X), "간이 규칙"
good["state"] = np.where(good["score"] < ALERT - BAND, "경보", np.where(good["score"] >= ALERT + BAND, "정상", "보류"))
good["alert"] = good["state"] == "경보"
return good, manual
def dashboard(good):
fig, ax = plt.subplots(1, 3, figsize=(14, 4))
ax[0].hist(good["score"], bins=20); ax[0].axvline(ALERT, ls="--", c="r"); ax[0].set_title("Score distribution")
ax[1].scatter(good["score"], good["build_h"], c=good["state"].map({"경보": "red", "보류": "orange", "정상": "gray"}), s=12); ax[1].set_yscale("log"); ax[1].set_title("Score vs build hours (red = alert, orange = hold)")
good.groupby("cat")["alert"].sum().plot.bar(ax=ax[2]); ax[2].set_title("Alerts by category")
fig.tight_layout(); fig.canvas.draw()
return fig
hist = make_parts(1000, seed=11)
model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(make_features(hist), hist["conform"])
rows, keep = [], {}
for n, bad, down in [(50, 0, False), (50, 4, False), (100, 6, False), (200, 10, False), (500, 20, False), (50, 4, True)]:
batch = make_batch(n, seed=n + bad)
for k in range(bad): # 손상 레코드 주입
i = k * 3
if k % 3 == 0:
batch.loc[i, "min_wall_mm"] = -1.0
elif k % 3 == 1:
batch.loc[i, "material"] = "unknown"
else:
batch.loc[i, "load_N"] = np.nan
t0 = time.perf_counter()
good, manual = evaluate(batch, model, fail_model=down)
fig = dashboard(good)
sec = time.perf_counter() - t0
truth = good["conform"] < ALERT
decided = good["state"] != "보류"
acc = float((good["alert"] == truth)[decided].mean())
rows.append({"상황": f"{n}종" + (f", 손상 {bad}건" if bad else "") + (", 모델 장애" if down else ""), "평가": len(good), "수동 확인": len(manual),
"유실": n - len(good) - len(manual), "처리+화면(초)": round(sec, 2), "경보": int(good["alert"].sum()), "보류": int((~decided).sum()), "경보 정확도": round(acc, 3),
"미달 놓침": int((truth & (good["state"] == "정상")).sum())})
keep[n, bad, down] = (fig, good)
if (n, bad, down) != (50, 4, False):
plt.close(fig)
T = pd.DataFrame(rows)
print("[교육용가상자료] 복잡 형상 부품 일괄 신뢰성 평가 스트레스 테스트")
print(T.to_string(index=False))
m = T.iloc[1]
normal = T[~T["상황"].str.contains("장애")]
print(f"\n50종(손상 4건) 기준: 처리+화면 {m['처리+화면(초)']}초 (목표 2초 이내: {'달성' if normal['처리+화면(초)'].max() <= 2 else '미달'}), "
f"확정 판정 경보 정확도 {normal['경보 정확도'].min():.1%}~{normal['경보 정확도'].max():.1%} (목표 95% 이상: {'달성' if normal['경보 정확도'].min() >= 0.95 else '미달'}), 유실 {int(T['유실'].sum())}건")
print(f"보류(65~75점) 비율 {(T['보류'] / T['평가']).min():.0%}~{(T['보류'] / T['평가']).max():.0%}: 모델이 판단하기 어려운 구간은 자동 판정하지 않고 정비기술관 수동 확인으로 넘긴다")
print(f"한계치: 500종에서도 {T.iloc[4]['처리+화면(초)']}초 -> 이 규모에서는 처리 시간이 병목이 아니다. 병목은 손상 레코드 수동 확인과 정비기술관 검토 인력이다.")
print("손상 레코드는 삭제하지 않고 '수동 확인' 목록으로 넘겨 설계자가 CAD·시험 기록을 재확인한다.")
good = keep[50, 4, False][1]
qt = []
for c in good["cat"].unique():
for mt in MAT:
t0 = time.perf_counter(); good[(good["cat"] == c) & (good["material"] == mt)]; qt.append((time.perf_counter() - t0) * 1000)
print(f"실시간 필터(부품군 x 소재) 평균 {np.mean(qt):.2f} ms")
fb = T.iloc[5]
print(f"\n모델 장애 시 간이 규칙으로 전환: 확정 판정 정확도 {fb['경보 정확도']:.1%}, 경보 {fb['경보']}건(정상 모델 {T.iloc[1]['경보']}건), 미달 놓침 {fb['미달 놓침']}건(정상 모델 {T.iloc[1]['미달 놓침']}건)")
print("기능 축소 운용: 모델 장애 또는 처리 2초 초과 시 응력/기준강도 비 규칙 점수로 즉시 평가하고, 정밀 점수는 복구 후 재계산한다. 규칙 점수는 보수적이라 경보가 늘어난다.")
plt.show()
```
실행 결과 예시
```text
[교육용가상자료] 복잡 형상 부품 일괄 신뢰성 평가 스트레스 테스트
상황 평가 수동 확인 유실 처리+화면(초) 경보 보류 경보 정확도 미달 놓침
50종 50 0 0 0.88 29 9 0.976 1
50종, 손상 4건 46 4 0 0.67 26 11 0.971 1
100종, 손상 6건 94 6 0 0.78 67 10 1.000 0
200종, 손상 10건 190 10 0 0.57 104 35 0.987 0
500종, 손상 20건 480 20 0 0.50 262 110 0.978 0
50종, 손상 4건, 모델 장애 46 4 0 0.61 40 4 0.810 0
50종(손상 4건) 기준: 처리+화면 0.67초 (목표 2초 이내: 달성), 확정 판정 경보 정확도 97.1%~100.0% (목표 95% 이상: 달성), 유실 0건
보류(65~75점) 비율 9%~24%: 모델이 판단하기 어려운 구간은 자동 판정하지 않고 정비기술관 수동 확인으로 넘긴다
한계치: 500종에서도 0.5초 -> 이 규모에서는 처리 시간이 병목이 아니다. 병목은 손상 레코드 수동 확인과 정비기술관 검토 인력이다.
손상 레코드는 삭제하지 않고 '수동 확인' 목록으로 넘겨 설계자가 CAD·시험 기록을 재확인한다.
실시간 필터(부품군 x 소재) 평균 0.72 ms
모델 장애 시 간이 규칙으로 전환: 확정 판정 정확도 81.0%, 경보 40건(정상 모델 26건), 미달 놓침 0건(정상 모델 1건)
기능 축소 운용: 모델 장애 또는 처리 2초 초과 시 응력/기준강도 비 규칙 점수로 즉시 평가하고, 정밀 점수는 복구 후 재계산한다. 규칙 점수는 보수적이라 경보가 늘어난다.
```
## 미션 9. AAR 기반 실물 파괴 시험 결과와 3D 모델 예측치 간 오차 분석 및 보정
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: AAR로 실물 파괴 시험 결과와 3D 모델(강도 예측)의 오차를 분석해 과대 예측 원인을 분류하고, 과거 시험으로 보정 계수와 승인 기준을 재보정해 이후 시험에서 검증한다. 비용 절감액·공급 시간 단축률·폐기 감소량의 3대 정량 효과 지표와 단계별 도입 로드맵을 출력한다.
```python
"""AAR로 실물 파괴 시험 결과와 3D 모델(강도 예측)의 오차를 분석해 과대 예측 원인을 분류하고, 과거 시험으로 보정 계수와 승인 기준을 재보정해 이후 시험에서 검증한다. 비용 절감액·공급 시간 단축률·폐기 감소량의 3대 정량 효과 지표와 단계별 도입 로드맵을 출력한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----
DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별
FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)
POST = 3.0 # 후처리·검사 비용(만원)
def make_requests_month(catalog, n=250, seed=0):
"""월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다."""
return catalog.sample(n, replace=True, weights=catalog["demand_month"], random_state=seed).reset_index(drop=True)
def buy_cost(r):
return r["buy_cost"] + r["lead_days"] * DOWN[r["crit"]]
def print_outcome(r, delay_d=1.0):
"""실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다."""
wait = (r["build_h"] / 24 + delay_d + 2.0) * DOWN[r["crit"]]
ok = bool(r["printable"] and r["conform"] >= 70)
c = r["print_cost"] + POST + wait
return (c, True) if ok else (c + buy_cost(r) + FAIL[r["crit"]], False)
SF_MIN = 1.5 # 최소 안전율
SCRAP_CUT = 0.30 # [교육용가상자료] 대체 공급 성공 부품의 폐기 수량이 줄어든다고 가정하는 비율
def destructive_test(d, seed):
"""[교육용가상자료] 실물 파괴 시험 강도: 모델이 모르는 영향(얇은 벽 미충전·금속 잔류응력·인증서 없는 소재 편차)이 섞인다."""
rng = np.random.default_rng(seed)
f = np.ones(len(d))
f *= np.where((d["process"] == "polymer") & (d["min_wall_mm"] < 1.5), 0.86, 1.0)
f *= np.where((d["process"] == "metal") & (d["overhang"] > 0.3), 0.88, 1.0)
f *= np.where(d["cert"] == 0, 0.94, 1.0)
return d["strength_mpa"] * f * rng.lognormal(0, 0.03, len(d))
hist = make_parts(1000, seed=11)
Xh = make_features(hist)
model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(Xh, hist["strength_mpa"] / Xh["base_strength"])
def predict_strength(d):
X = make_features(d)
return model.predict(X) * X["base_strength"], X
def group(d):
return np.select([(d["process"] == "polymer") & (d["min_wall_mm"] < 1.5), (d["process"] == "metal") & (d["overhang"] > 0.3)], ["폴리머 얇은 벽", "금속 돌출 많음"], "일반")
cal, test = make_parts(250, seed=31), make_parts(250, seed=32) # 과거 시험(보정용) / 이후 시험(검증용)
for d, s in ((cal, 41), (test, 42)):
d["meas"] = destructive_test(d, s); d["pred"], _ = predict_strength(d); d["grp"] = group(d)
d["err"] = d["pred"] / d["meas"] - 1
# 1) AAR: 과대 예측(+10% 초과, 위험한 방향) 사례의 원인 분류
over = cal[cal["err"] > 0.10].copy()
over["원인"] = np.select([over["grp"] == "폴리머 얇은 벽", over["grp"] == "금속 돌출 많음", over["cert"] == 0], ["얇은 벽 미충전(모델 미반영)", "금속 잔류응력(모델 미반영)", "인증서 없는 소재 편차"], "기타(시험 산포)")
print(f"[교육용가상자료] AAR: 과거 시험 {len(cal)}종 중 예측이 실측보다 10% 이상 높았던 위험 사례 {len(over)}건 (평균 오차 {cal['err'].mean():+.1%}, MAPE {cal['err'].abs().mean():.1%})")
print(over["원인"].value_counts().to_string())
print("-> 원인 대부분이 모델에 없는 형상·공정 효과라 승인 기준 보정으로 줄일 수 있다. '기타'는 시험 산포라 보정이 아닌 안전율 여유로 대응한다.")
# 2) 재보정: 그룹별 보정 계수(중앙값 실측/예측) + 승인 안전율 임계값
corr = pd.Series({g_: float(np.median((cal["meas"] / cal["pred"])[cal["grp"] == g_])) for g_ in np.unique(cal["grp"])})
print("\n보정 계수(실측/예측 중앙값):", {k: round(v, 3) for k, v in corr.items()})
stress = lambda d: d["load_N"] / (d["min_wall_mm"] * np.sqrt(d["max_dim_mm"]))
def sf_pred(d, use_corr):
p = d["pred"] * (d["grp"].map(corr) if use_corr else 1.0)
return p / stress(d)
def unsafe_rate(d, use_corr, thr):
ok = sf_pred(d, use_corr) >= thr
real_bad = (d["meas"] / stress(d)) < SF_MIN
return float((ok & real_bad).sum() / max(ok.sum(), 1)), int(ok.sum())
thr_new = next((t for t in np.arange(1.5, 4.01, 0.05) if unsafe_rate(cal, True, t)[0] <= 0.02), 4.0)
rows = [{"방식": "현행(예측 안전율 1.5 이상)", **dict(zip(["위험 승인 비율", "승인 가능 부품"], unsafe_rate(test, False, SF_MIN)))},
{"방식": "보정만(계수 적용)", **dict(zip(["위험 승인 비율", "승인 가능 부품"], unsafe_rate(test, True, SF_MIN)))},
{"방식": f"재보정(계수 + 임계 {thr_new:.2f})", **dict(zip(["위험 승인 비율", "승인 가능 부품"], unsafe_rate(test, True, thr_new)))}]
Tq = pd.DataFrame(rows)
Tq["위험 승인 비율"] = (Tq["위험 승인 비율"] * 100).round(1).astype(str) + "%"
print(f"과거 시험에서 위험 승인 비율 2% 이하를 만족하는 최소 임계 안전율: {thr_new:.2f} (계수 보정 후에는 기본 1.5로 충분)")
print("\n[이후 시험 250종 검증]")
print(Tq.to_string(index=False))
test["pred2"] = test["pred"] * test["grp"].map(corr)
print(f"강도 예측 MAPE: 보정 전 {(test['pred'] / test['meas'] - 1).abs().mean():.1%} -> 보정 후 {(test['pred2'] / test['meas'] - 1).abs().mean():.1%}")
# 3) 3대 정량 효과 지표: 월간 요청 250건, 승인 기준 현행 vs 재보정(구매 기준 대비)
cat = make_parts(300, seed=51)
cat["meas"] = destructive_test(cat, 61); cat["pred"], _ = predict_strength(cat); cat["grp"] = group(cat)
cat["rule_ok"] = (cat["min_wall_mm"] >= 1.0) & (cat["max_dim_mm"] <= cat["process"].map(BUILD_MM)) & (cat["overhang"] < 0.5)
req = make_requests_month(cat, 250, seed=3)
def outcome(r, approve):
c, days = buy_cost(r), r["lead_days"]
if approve:
real_ok = bool(r["printable"] and r["meas"] / (r["load_N"] / (r["min_wall_mm"] * np.sqrt(r["max_dim_mm"]))) >= SF_MIN)
pdays = r["build_h"] / 24 + 3.0
cp = r["print_cost"] + POST + pdays * DOWN[r["crit"]]
return (cp, pdays, True, real_ok) if real_ok else (cp + c + FAIL[r["crit"]], pdays + r["lead_days"], True, False)
return c, days, False, True
def policy(use_corr, thr):
out = []
for _, r in req.iterrows():
sf = r["pred"] * (corr[r["grp"]] if use_corr else 1.0) / (r["load_N"] / (r["min_wall_mm"] * np.sqrt(r["max_dim_mm"])))
out.append(outcome(r, bool(r["rule_ok"] and sf >= thr)))
return pd.DataFrame(out, columns=["cost", "days", "printed", "real_ok"]).join(req[["scrap_rate", "demand_month", "crit"]])
base = pd.DataFrame([outcome(r, False) for _, r in req.iterrows()], columns=["cost", "days", "printed", "real_ok"])
res = {"현행 기준": policy(False, SF_MIN), "재보정 기준": policy(True, thr_new)}
print("\n[월간 250건 모의: 구매 기준 대비]")
for k, d in res.items():
fail = int((d["printed"] & ~d["real_ok"]).sum())
save, cut_days = base["cost"].sum() - d["cost"].sum(), 1 - d["days"].mean() / base["days"].mean()
scr = (d.loc[d["printed"] & d["real_ok"], "scrap_rate"] * d.loc[d["printed"] & d["real_ok"], "demand_month"]).sum() * SCRAP_CUT
print(f" {k}: 3D 제작 {int(d['printed'].sum())}건(불합격 {fail}건) | 1) 비용 절감 {save:,.0f}만원({save / base['cost'].sum():.1%}) | 2) 공급 시간 단축 {cut_days:.1%}"
f" ({base['days'].mean():.1f}일 -> {d['days'].mean():.1f}일) | 3) 폐기 감소 {scr:.1f}개/월(가정 {SCRAP_CUT:.0%})")
print("ROI 로드맵: 단기(1~3개월) 폴리머 비핵심 부품 시범 -> 중기(6개월) 파괴 시험 결과로 분기별 보정 -> 장기(1년) 금속 핵심 부품 확대")
print("폐기 감소 수량은 대체 공급이 폐기를 줄인다는 가정 비율에 따른 추정이며, 모든 금액은 교육용 가정 단가의 상대 비교값이다. 실제 장착 승인은 정비기술관이 한다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
for g_, c in zip(["폴리머 얇은 벽", "금속 돌출 많음", "일반"], ["tab:red", "tab:orange", "tab:blue"]):
s = test[test["grp"] == g_]
ax[0].scatter(s["meas"], s["pred"], s=8, c=c, label={"폴리머 얇은 벽": "thin polymer", "금속 돌출 많음": "metal overhang", "일반": "other"}[g_])
ax[0].plot([0, 1000], [0, 1000], "k--"); ax[0].set_xlabel("measured (MPa)"); ax[0].set_ylabel("predicted"); ax[0].legend(); ax[0].set_title("Prediction vs destructive test")
ax[1].bar(["current", "recalibrated"], [unsafe_rate(test, False, SF_MIN)[0] * 100, unsafe_rate(test, True, thr_new)[0] * 100]); ax[1].set_ylabel("unsafe approval (%)"); ax[1].set_title("Unsafe approval rate (hold-out)")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[교육용가상자료] AAR: 과거 시험 250종 중 예측이 실측보다 10% 이상 높았던 위험 사례 110건 (평균 오차 +9.8%, MAPE 12.0%)
원인
금속 잔류응력(모델 미반영) 54
얇은 벽 미충전(모델 미반영) 34
인증서 없는 소재 편차 15
기타(시험 산포) 7
-> 원인 대부분이 모델에 없는 형상·공정 효과라 승인 기준 보정으로 줄일 수 있다. '기타'는 시험 산포라 보정이 아닌 안전율 여유로 대응한다.
보정 계수(실측/예측 중앙값): {'금속 돌출 많음': 0.831, '일반': 0.98, '폴리머 얇은 벽': 0.826}
과거 시험에서 위험 승인 비율 2% 이하를 만족하는 최소 임계 안전율: 1.50 (계수 보정 후에는 기본 1.5로 충분)
[이후 시험 250종 검증]
방식 위험 승인 비율 승인 가능 부품
현행(예측 안전율 1.5 이상) 7.5% 159
보정만(계수 적용) 2.6% 151
재보정(계수 + 임계 1.50) 2.6% 151
강도 예측 MAPE: 보정 전 11.2% -> 보정 후 6.5%
[월간 250건 모의: 구매 기준 대비]
현행 기준: 3D 제작 130건(불합격 2건) | 1) 비용 절감 37,491만원(29.0%) | 2) 공급 시간 단축 52.5% (40.9일 -> 19.5일) | 3) 폐기 감소 201.5개/월(가정 30%)
재보정 기준: 3D 제작 129건(불합격 1건) | 1) 비용 절감 37,822만원(29.3%) | 2) 공급 시간 단축 52.5% (40.9일 -> 19.4일) | 3) 폐기 감소 201.5개/월(가정 30%)
ROI 로드맵: 단기(1~3개월) 폴리머 비핵심 부품 시범 -> 중기(6개월) 파괴 시험 결과로 분기별 보정 -> 장기(1년) 금속 핵심 부품 확대
폐기 감소 수량은 대체 공급이 폐기를 줄인다는 가정 비율에 따른 추정이며, 모든 금액은 교육용 가정 단가의 상대 비교값이다. 실제 장착 승인은 정비기술관이 한다.
```
## 미션 10. 3D 프린팅 야전 긴급 부품 제작 타당성 및 신뢰성 평가 도구 최종 완성
- 난이도: 최고급 / 예상 소요시간: 3.5시간
- 코드 설명: 부품 요청 검증·손상 레코드 분리, 강도 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 군 규격 부합 점수와 안전율 기반 대체 제작 후보 선별, 정비기술관 승인 대기열 생성까지 야전 3D 프린팅 제작 타당성·신뢰성 평가 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비기술관 브리핑 요약을 출력한다.
```python
"""부품 요청 검증·손상 레코드 분리, 강도 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 군 규격 부합 점수와 안전율 기반 대체 제작 후보 선별, 정비기술관 승인 대기열 생성까지 야전 3D 프린팅 제작 타당성·신뢰성 평가 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비기술관 브리핑 요약을 출력한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
# ---------------------------------------------------------------------------
# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)
# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.
# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.
# ---------------------------------------------------------------------------
MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)
"PA12": ("polymer", 45, 1.0, 8, 3, 0.8, 18), "PETG": ("polymer", 50, 1.27, 5, 2, 0.8, 15),
"CF-PA": ("polymer", 70, 1.2, 14, 3, 1.0, 12), "AlSi10Mg": ("metal", 350, 2.67, 30, 25, 0.5, 4),
"SS316L": ("metal", 520, 7.9, 28, 25, 0.6, 3.5), "Ti64": ("metal", 900, 4.4, 90, 30, 0.6, 3)}
CATS = ["bracket", "housing", "clip", "gear", "duct", "handle"]
CAT_SCRAP = {"bracket": 0.04, "housing": 0.05, "clip": 0.12, "gear": 0.09, "duct": 0.08, "handle": 0.10} # 부품군별 기본 월 폐기율
BUILD_MM = {"polymer": 300, "metal": 200} # 가상 출력 가능 최대 치수
def make_parts(n=300, seed=0):
"""가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다."""
rng = np.random.default_rng(seed)
mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])
proc = np.array([MAT[m][0] for m in mat])
vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)
aspect = rng.lognormal(0.3, 0.4, n)
max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)
wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)
ovh = rng.beta(2, 5, n)
is_poly = proc == "polymer"
df = pd.DataFrame({
"part_id": [f"P{i:03d}" for i in range(n)], "cat": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), "material": mat, "process": proc,
"volume_cm3": vol, "max_dim_mm": max_dim, "area_cm2": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), "min_wall_mm": wall, "overhang": ovh,
"n_holes": rng.poisson(2.0, n), "thread": rng.random(n) < 0.25, "orient_deg": rng.uniform(0, 90, n),
"layer_mm": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),
"infill": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),
"load_N": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), "temp_C": rng.uniform(-20, 90, n),
"crit": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), "cert": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유
"import_part": rng.random(n) < 0.4})
df["scrap_rate"] = np.clip(df["cat"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df["load_N"] > 800)) * (1 + 0.3 * (df["temp_C"] > 60)), 0.005, 0.5)
df["lead_days"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df["import_part"], 2.2, 1.0), 5, 300)
# ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----
eff = np.where(is_poly, 0.55 + 0.45 * df["infill"] / 100, 1 - 0.3 * ovh * 0.2)
aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df["orient_deg"])) ** 2, 1 - 0.08 * np.sin(np.radians(df["orient_deg"])))
heat = np.where(is_poly, 1 - 0.35 * np.clip((df["temp_C"] - 40) / 80, 0, 1), 0.98)
base = np.array([MAT[m][1] for m in mat])
df["strength_mpa"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)
cross = df["min_wall_mm"] * np.sqrt(df["max_dim_mm"]) * 1.0 # 하중 지지 단면적 mm2(단순화)
df["stress_mpa"] = df["load_N"] / cross
df["safety"] = df["strength_mpa"] / df["stress_mpa"]
ratio = np.clip(df["stress_mpa"] / df["strength_mpa"], 0, 1.5)
df["log_cycles"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)
support = 1 + 0.5 * ovh
df["build_h"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5
mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000
df["print_cost"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df["build_h"] * np.array([MAT[m][4] for m in mat]) + 3
df["buy_cost"] = df["print_cost"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)
wall_ok = df["min_wall_mm"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)
dim_ok = df["max_dim_mm"] <= np.where(is_poly, BUILD_MM["polymer"], BUILD_MM["metal"])
warp = (~is_poly) & (df["max_dim_mm"] > 140) & (df["overhang"] > 0.3) # 금속 대형 오버행 변형
thr_bad = df["thread"] & (df["min_wall_mm"] < 1.5) & is_poly # 얇은 나사부 파손
df["printable"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad
risk = np.clip(1 - df["safety"] / 3, 0, 1)
df["conform"] = np.clip(100 * (0.45 * np.minimum(df["safety"] / 3, 1) + 0.25 * np.clip((df["log_cycles"] - 3) / 4.5, 0, 1)
+ 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df["cert"]) - 8 * risk * df["crit"] / 3 + rng.normal(0, 2, n), 0, 100)
df["demand_month"] = rng.poisson(np.where(df["cat"].isin(["clip", "handle"]), 40, 15)) + 1 # 월 소요수량(가상)
df["scrap_cost"] = df["scrap_rate"] * df["demand_month"] * df["buy_cost"] # 월 폐기 손실액(만원, 가상)
return df
# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----
def make_features(d):
"""간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개."""
X = pd.DataFrame(index=d.index)
base = d["material"].map(lambda m: MAT[m][1]).astype(float)
X["base_strength"] = base
X["is_metal"] = (d["process"] == "metal").astype(int)
X["cross_mm2"] = d["min_wall_mm"] * np.sqrt(d["max_dim_mm"])
X["kt"] = 1 + 0.15 * d["n_holes"] + 0.5 * d["thread"].astype(int) # 구멍·나사부 응력집중계수(간이)
X["fea_stress"] = d["load_N"] / X["cross_mm2"] * X["kt"] # 간이 해석 응력(MPa)
X["stress_ratio"] = X["fea_stress"] / base # 기준강도 대비 응력비
X["infill_eff"] = np.where(X["is_metal"] == 1, 1.0, 0.55 + 0.45 * d["infill"] / 100)
X["orient_sin2"] = np.sin(np.radians(d["orient_deg"])) ** 2
X["temp_derate"] = np.where(X["is_metal"] == 1, 0.0, np.clip((d["temp_C"] - 40) / 80, 0, 1))
X["overhang_layer"] = d["overhang"] * d["layer_mm"]
X["slender"] = d["max_dim_mm"] / d["min_wall_mm"]
X["surf_vol"] = d["area_cm2"] / d["volume_cm3"]
X["wall_ratio"] = d["min_wall_mm"] / d["material"].map(lambda m: MAT[m][5])
X["load_per_vol"] = d["load_N"] / d["volume_cm3"]
X["cert"] = d["cert"].astype(int)
X["crit"] = d["crit"]
return X
# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----
DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별
FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)
POST = 3.0 # 후처리·검사 비용(만원)
def make_requests_month(catalog, n=250, seed=0):
"""월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다."""
return catalog.sample(n, replace=True, weights=catalog["demand_month"], random_state=seed).reset_index(drop=True)
def buy_cost(r):
return r["buy_cost"] + r["lead_days"] * DOWN[r["crit"]]
def print_outcome(r, delay_d=1.0):
"""실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다."""
wait = (r["build_h"] / 24 + delay_d + 2.0) * DOWN[r["crit"]]
ok = bool(r["printable"] and r["conform"] >= 70)
c = r["print_cost"] + POST + wait
return (c, True) if ok else (c + buy_cost(r) + FAIL[r["crit"]], False)
CONFIG = {"sf_min": 1.5, "score_min": 70.0, "retrain_tolerance": 0.02, "scrap_cut": 0.30,
"corr": {"폴리머 얇은 벽": 0.83, "금속 돌출 많음": 0.83, "일반": 0.98}} # 미션 9 재보정 결과(모델을 교체하지 않을 때만 적용)
OPS_GUIDE = { # docs/mnt06_operations_guide.md 요약
"1. 목적과 범위": "폐기율이 높은 수리부속 중 야전 3D 프린팅 대체 제작 후보 선별. 장착 승인은 정비기술관.",
"2. 입력 데이터": "부품(CAD 요약 치수·소재·하중·폐기율·조달일), 파괴 시험 결과. 실제 부품·재고 정보는 입력하지 않는다.",
"3. 일일 운용 절차": "요청 검증 -> 후보 선별 -> 승인 대기열 -> 정비기술관 결정 -> 출력·후처리 -> 시험 결과 입력.",
"4. 승인(HITL) 기준": "모든 대체 제작은 정비기술관 승인, 핵심 부품은 소재 인증서 확보, 무응답 시 기존 조달 유지, 사유 기록.",
"5. 재학습 절차": "분기 1회 신규 파괴 시험으로 도전자 모델 학습, 검증 MAPE가 나빠지지 않을 때만 교체.",
"6. 장애 대응": "모델 오류 시 응력/기준강도 비 간이 규칙으로 대체, 손상 레코드는 수동 확인 목록으로.",
"7. 연동 인터페이스": "국방 군수 정보체계와 부품 식별코드·분류군·폐기수량 표준 필드를 CSV/API로 교환하는 안을 차기 과제로 둔다.",
}
def destructive_test(d, seed):
"""[교육용가상자료] 실물 파괴 시험 강도(모델이 모르는 형상·공정 효과 포함)."""
rng = np.random.default_rng(seed)
f = np.where((d["process"] == "polymer") & (d["min_wall_mm"] < 1.5), 0.86, 1.0) * np.where((d["process"] == "metal") & (d["overhang"] > 0.3), 0.88, 1.0) \
* np.where(d["cert"] == 0, 0.94, 1.0)
return d["strength_mpa"] * f * rng.lognormal(0, 0.03, len(d))
def group(d):
return np.select([(d["process"] == "polymer") & (d["min_wall_mm"] < 1.5), (d["process"] == "metal") & (d["overhang"] > 0.3)], ["폴리머 얇은 벽", "금속 돌출 많음"], "일반")
def validate(df):
ok = (df["min_wall_mm"] > 0) & (df["max_dim_mm"] > 0) & df["material"].isin(list(MAT)) & df["load_N"].notna()
return df[ok].copy(), df[~ok].copy()
def gb():
return GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0)
def fit_strength(d):
X = make_features(d)
return gb().fit(X, d["meas"] / X["base_strength"])
def pred_strength(model, d):
X = make_features(d)
return model.predict(X) * X["base_strength"]
def mape(a, p):
return float(np.mean(np.abs(np.asarray(a) - p) / np.asarray(a)))
def retrain(champion, train_df, new_df):
"""도전자 모델을 (기존+신규 앞 2/3)로 학습, 신규 뒤 1/3에서 챔피언과 비교해 더 낫거나 비슷하면 교체."""
cut = int(len(new_df) * 2 / 3)
hold = new_df.iloc[cut:]
challenger = fit_strength(pd.concat([train_df, new_df.iloc[:cut]], ignore_index=True))
m_old, m_new = mape(hold["meas"], pred_strength(champion, hold)), mape(hold["meas"], pred_strength(challenger, hold))
swap = m_new <= m_old + CONFIG["retrain_tolerance"]
return (challenger if swap else champion), {"챔피언 MAPE": round(m_old, 3), "도전자 MAPE": round(m_new, 3), "교체": swap}
def stress(d):
return d["load_N"] / (d["min_wall_mm"] * np.sqrt(d["max_dim_mm"]))
# ---------------- 1. 통합 실행 ----------------
t_start = time.perf_counter()
past = make_parts(800, seed=11); past["meas"] = past["strength_mpa"] # 과거 시험(모델이 모르는 효과 없음)
new = make_parts(300, seed=12); new["meas"] = destructive_test(new, 14) # 이번 분기 신규 파괴 시험
champion = fit_strength(past)
model, rt = retrain(champion, past, new)
print(f"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}")
Xp = make_features(past)
score_model = gb().fit(Xp, past["conform"])
nxt = make_parts(250, seed=22) # 다음 분기 대체 제작 요청 후보
nxt.loc[[5, 40], "min_wall_mm"] = [-1.0, 0.0] # 손상 레코드 2건 주입
ops, manual = validate(nxt)
ops = ops.reset_index(drop=True)
ops["pred"] = pred_strength(model, ops)
ops["grp"] = group(ops)
if not rt["교체"]:
ops["pred"] *= ops["grp"].map(CONFIG["corr"])
ops["sf"] = ops["pred"] / stress(ops)
ops["score"] = np.clip(score_model.predict(make_features(ops)), 0, 100)
ops["rule_ok"] = (ops["min_wall_mm"] >= 1.0) & (ops["max_dim_mm"] <= ops["process"].map(BUILD_MM)) & (ops["overhang"] < 0.5)
cand = ops[ops["rule_ok"] & (ops["sf"] >= CONFIG["sf_min"]) & (ops["score"] >= CONFIG["score_min"])].copy()
cand["status"] = "PENDING" # 정비기술관 결정 전까지 확정하지 않는다
cand["need_cert"] = (cand["crit"] == 3) & (cand["cert"] == 0)
sec = time.perf_counter() - t_start
print(f"후보 요청 {len(nxt)}건: 평가 {len(ops)}건, 수동 확인 {len(manual)}건, 대체 제작 후보 {len(cand)}건, 전체 실행 {sec:.1f}초")
# ---------------- 2. 자체 점검 ----------------
try:
bad_pred = pred_strength(None, ops.head(3))
fallback = "간이 규칙 전환 불필요"
except Exception:
fallback = "간이 규칙 대체"
simple = np.clip(100 * (1 - 1.8 * make_features(ops.head(3))["stress_ratio"]), 0, 100)
checks = {
"요청 유실 0건": len(ops) + len(manual) == len(nxt),
"손상 레코드 수동 확인 분리": len(manual) == 2,
"재학습 파이프라인 실행": "교체" in rt,
"예측 강도 양수": bool((ops["pred"] > 0).all()),
"모델 장애 시 간이 규칙 대체": fallback == "간이 규칙 대체" and bool(np.isfinite(simple).all()),
"모든 후보 점수·안전율 산출": bool(ops[["sf", "score"]].notna().all().all()),
"후보는 안전율 기준 이상": bool((cand["sf"] >= CONFIG["sf_min"]).all()),
"자동 승인 0건(승인 대상은 대기)": bool((cand["status"] == "PENDING").all()),
}
print(f"\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}")
for k, v in checks.items():
if not v:
print(" 실패:", k)
doc = sum(bool(v.strip()) for v in OPS_GUIDE.values()) / len(OPS_GUIDE)
print(f"운영 문서 {len(OPS_GUIDE)}개 장 작성률 {doc:.0%} (목표 100%: {'달성' if doc == 1 else '미달'}) - " + ", ".join(k.split(". ")[1] for k in OPS_GUIDE))
# ---------------- 3. 화면과 브리핑 ----------------
real_ok = (destructive_test(cand, 24) / stress(cand)) >= CONFIG["sf_min"] # 모의 정답: 승인 후 실제 파괴 시험을 했다면
cost_buy = cand.apply(buy_cost, axis=1).sum()
cost_print = sum(r["print_cost"] + POST + (r["build_h"] / 24 + 3.0) * DOWN[r["crit"]] for _, r in cand.iterrows())
days_buy, days_print = cand["lead_days"].mean(), (cand["build_h"] / 24 + 3.0).mean()
scrap = (cand["scrap_rate"] * cand["demand_month"]).sum() * CONFIG["scrap_cut"]
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].scatter(ops["score"], ops["sf"].clip(upper=10), s=8, c=np.where(ops.index.isin(cand.index), "tab:blue", "lightgray"))
ax[0].axvline(CONFIG["score_min"], ls="--", c="k"); ax[0].axhline(CONFIG["sf_min"], ls="--", c="k"); ax[0].set_xlabel("conformity score"); ax[0].set_ylabel("safety factor (clipped)")
ax[0].set_title("Candidates (blue) pending officer approval")
ax[1].bar(["purchase", "3D print"], [days_buy, days_print]); ax[1].set_title("Mean supply days (candidates)")
plt.tight_layout(); plt.show()
print("\n[정비기술관 브리핑 요약] [교육용가상자료]")
print(f"1. 다음 분기 요청 {len(nxt)}건 중 대체 제작 후보 {len(cand)}건(승인 대기), 후보 부품의 월 폐기 손실 합계 {cand['scrap_cost'].sum():,.0f}만원(가상)")
print(f"2. 공급 시간 평균 {days_buy:.1f}일 -> {days_print:.1f}일({1 - days_print / days_buy:.0%} 단축), 예상 비용 {cost_buy:,.0f} -> {cost_print:,.0f}만원(가정 단가)")
print(f"3. 핵심 부품 후보 {int((cand['crit'] == 3).sum())}건 중 소재 인증서 확보 필요 {int(cand['need_cert'].sum())}건, 폐기 감소 추정 {scrap:.0f}개/월(가정 {CONFIG['scrap_cut']:.0%})")
print(f"4. 후보 중 실물 시험을 하면 안전율 미달일 부품(모의 정답) {int((~real_ok).sum())}건, 손상 레코드 {len(manual)}건 수동 확인 -> 승인 전 대표 부품 파괴 시험을 편성한다")
print(f"5. 강도 예측 모델: {'신규 시험으로 교체' if rt['교체'] else '기존 유지'}(검증 MAPE {min(rt['챔피언 MAPE'], rt['도전자 MAPE']):.1%})")
print("6. 건의: 금속 핵심 부품은 시험 후 확대, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의. 장착 승인은 정비기술관 결정")
```
실행 결과 예시
```text
[교육용가상자료] 재학습: {'챔피언 MAPE': 0.12, '도전자 MAPE': 0.105, '교체': True} -> 도전자 교체
후보 요청 250건: 평가 248건, 수동 확인 2건, 대체 제작 후보 86건, 전체 실행 2.6초
자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행
운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스
[정비기술관 브리핑 요약] [교육용가상자료]
1. 다음 분기 요청 250건 중 대체 제작 후보 86건(승인 대기), 후보 부품의 월 폐기 손실 합계 104,000만원(가상)
2. 공급 시간 평균 40.1일 -> 3.4일(92% 단축), 예상 비용 35,338 -> 20,276만원(가정 단가)
3. 핵심 부품 후보 24건 중 소재 인증서 확보 필요 9건, 폐기 감소 추정 102개/월(가정 30%)
4. 후보 중 실물 시험을 하면 안전율 미달일 부품(모의 정답) 0건, 손상 레코드 2건 수동 확인 -> 승인 전 대표 부품 파괴 시험을 편성한다
5. 강도 예측 모델: 신규 시험으로 교체(검증 MAPE 10.5%)
6. 건의: 금속 핵심 부품은 시험 후 확대, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의. 장착 승인은 정비기술관 결정
```