212 KiB
212 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""폐기율이 높은 수리부속의 야전 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예측 대상: 월 폐기율, 시험 출력 성공 여부, 인장강도, 군 규격 부합 점수. 최종 활용: 폐기율이 높고 대체 제작이 가능한 부품의 우선 선정")
In [ ]:
"""부품 이력 레코드(표기·단위·중복·결측 오류), 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()
In [ ]:
"""부품의 형상(최소 벽두께·돌출 비율·최대 치수)만으로 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종 이상: 달성)")
In [ ]:
"""유한요소해석(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()
In [ ]:
"""긴급 야전 제작 부품의 군 규격 부합 신뢰도 점수(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()
In [ ]:
"""부품별 출력 소요 시간·제작 비용·신뢰성 점수와 폐기 손실을 한 화면에 보여 주는 대시보드를 그리고, 군 규격 부합 점수 예측마다 기여도 상위 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점수가 낮은 부품은 인자별 조치(인증서 확보, 충전율 상향, 벽두께 보강 등)를 정비기술관이 판단한다. 설명은 모델 근사이며 승인 결정을 대신하지 않는다.")
In [ ]:
"""야전 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()
In [ ]:
"""복잡 형상 부품 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()
In [ ]:
"""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()
In [ ]:
"""부품 요청 검증·손상 레코드 분리, 강도 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 군 규격 부합 점수와 안전율 기반 대체 제작 후보 선별, 정비기술관 승인 대기열 생성까지 야전 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. 건의: 금속 핵심 부품은 시험 후 확대, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의. 장착 승인은 정비기술관 결정")