209 KiB
209 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 [ ]:
"""전장 수송 자산(트럭·트레일러·수송기) 제원과 도로망(노드·간선) 데이터 스키마를 정의해 가상 샘플을 검증하고, 수송지원 실적·비용 보고 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다."""
import copy
import heapq
import re
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 1] 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 1] 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
# 1) 스키마 정의: 수송 자산 / 도로망 노드 / 도로 구간(간선) / 수송 요청
SCHEMA = {
"asset": {"asset_id": {"type": str, "pattern": r"^(TRK|TRL|AIR)-\d{3}$"},
"kind": {"type": str, "enum": list(ASSETS)},
"cap_t": {"type": (int, float), "min": 0.5, "max": 50},
"speed_kmh": {"type": (int, float), "min": 5, "max": 900},
"cost_km": {"type": (int, float), "min": 0},
"status": {"type": str, "enum": ["가용", "정비", "운행중"]}},
"node": {"node_id": {"type": str, "pattern": r"^N\d{4}$"},
"x_km": {"type": (int, float), "min": -100, "max": 100},
"y_km": {"type": (int, float), "min": -100, "max": 100}},
"edge": {"id": {"type": str, "pattern": r"^E\d{3}$"},
"u": {"type": str, "pattern": r"^N\d{4}$"}, "v": {"type": str, "pattern": r"^N\d{4}$"},
"km": {"type": (int, float), "min": 0.1, "max": 200},
"road": {"type": str, "enum": list(ROAD_FACTOR)},
"bridge": {"type": bool}, "open": {"type": bool}},
"request": {"req_id": {"type": str, "pattern": r"^REQ-\d{4}$"},
"dest": {"type": str, "pattern": r"^N\d{4}$"},
"cargo_t": {"type": (int, float), "min": 0.1, "max": 200},
"deadline_h": {"type": (int, float), "min": 0.5, "max": 72}},
}
def check(name, value, rule):
ok_type = isinstance(value, rule["type"]) and (rule["type"] is bool or not isinstance(value, bool))
if not ok_type:
return [f"{name}: 자료형 오류({type(value).__name__})"]
errs = []
if "enum" in rule and value not in rule["enum"]:
errs.append(f"{name}: 허용되지 않은 값 '{value}'")
if "min" in rule and value < rule["min"]:
errs.append(f"{name}: 최소값 {rule['min']} 미만")
if "max" in rule and value > rule["max"]:
errs.append(f"{name}: 최대값 {rule['max']} 초과")
if "pattern" in rule and not re.match(rule["pattern"], value):
errs.append(f"{name}: 형식 불일치 '{value}'")
return errs
def validate(group, rec, node_ids=None):
errs = []
for k, rule in SCHEMA[group].items():
errs += [f"{group}.{k}: 필수 필드 누락"] if k not in rec else check(f"{group}.{k}", rec[k], rule)
if group == "edge" and node_ids is not None: # 참조 무결성: 없는 노드를 잇는 간선
errs += [f"edge.{k}: 존재하지 않는 노드 {rec[k]}" for k in ("u", "v") if rec.get(k) not in node_ids]
if group == "edge" and rec.get("bridge") and not rec.get("limit_t"):
errs.append("edge.limit_t: 교량 하중 제한 누락")
return errs
# 2) [교육용가상자료] 샘플 생성: 자산 12대, 도로망 48노드, 수송 요청 6건
nodes, edges = make_network()
rng = np.random.default_rng(1)
prefix = {"truck": "TRK", "trailer": "TRL", "airlift": "AIR"}
assets = [{"asset_id": f"{prefix[k]}-{i:03d}", "kind": k, **{f: ASSETS[k][f] for f in ("cap_t", "speed_kmh", "cost_km")},
"status": str(rng.choice(["가용", "정비", "운행중"], p=[0.7, 0.15, 0.15]))}
for i, k in enumerate(["truck"] * 7 + ["trailer"] * 3 + ["airlift"] * 2)]
node_recs = [{"node_id": n, "x_km": round(x, 2), "y_km": round(y, 2)} for n, (x, y) in nodes.items()]
requests = [{"req_id": f"REQ-{i:04d}", "dest": f"N{r:02d}07", "cargo_t": float(round(rng.uniform(3, 30), 1)),
"deadline_h": float(rng.choice([2, 3, 4]))} for i, r in enumerate(range(6))]
samples = [("asset", a) for a in assets] + [("node", n) for n in node_recs] + [("edge", e) for e in edges] + [("request", q) for q in requests]
ids = set(nodes)
n_err = sum(1 for g, r in samples if validate(g, r, ids))
print(f"정상 샘플 {len(samples)}건(자산 {len(assets)}, 노드 {len(node_recs)}, 간선 {len(edges)}, 요청 {len(requests)}) 검증 오류 {n_err}건")
# 3) 오류 주입 시험
bad = [("asset", {**assets[0], "asset_id": "TRUCK-1"}), ("asset", {**assets[1], "kind": "tank"}),
("edge", {**edges[0], "v": "N9999"}), ("edge", {**{k: v for k, v in edges[1].items() if k != "km"}}),
("edge", {**edges[2], "bridge": True, "limit_t": None}), ("request", {**requests[0], "cargo_t": -2.0})]
print("\n[오류 주입 결과]")
caught = 0
for g, r in bad:
e = validate(g, copy.deepcopy(r), ids)
caught += bool(e)
print(" -", "; ".join(e))
print(f"주입 오류 {len(bad)}종 중 {caught}종 검출")
# 4) 수송지원 실적·비용 보고 업무 AS-IS 분해(단계별 소요 분, 가상 측정값)와 TO-BE 개선
steps = [("수송 요청 접수(전화·메신저)", 20, "요청 서식 표준화·자동 등록", 2),
("가용 자산 확인(차량 일지 수기 대조)", 30, "자산 상태 스키마로 자동 조회", 2),
("도로 상황 확인(구두 보고 취합)", 40, "도로 제약 자동 반영(미션 2)", 5),
("경로 선정(지도 수작업)", 35, "최단·위협회피 경로 자동 추천", 3),
("배차·적재 계획 작성", 25, "적재 효율 기반 자동 배차", 5),
("수송통제관 승인", 15, "HITL 승인 화면(근거 자동 첨부)", 10),
("운행 기록 수기 작성", 20, "운행 이벤트 자동 기록", 3),
("지연·사고 보고 작성", 25, "지연 원인 코드 선택식 기록", 5),
("운행 실적 집계(엑셀 복사·붙여넣기)", 60, "실적 자동 집계", 1),
("비용 산출(유류·거리 단가 계산)", 45, "자산별 km 단가 자동 계산", 1),
("실적·비용 보고서 작성", 50, "대시보드 화면 캡처 대신 자동 요약", 5),
("상급 부대 보고·수정 요청 반영", 30, "단일 데이터 기준으로 수정 최소화", 10)]
proc = pd.DataFrame(steps, columns=["업무 단계", "AS-IS(분)", "TO-BE 개선", "TO-BE(분)"])
proc.index = range(1, len(proc) + 1)
thr = proc["AS-IS(분)"].mean() + 0.5 * proc["AS-IS(분)"].std()
proc["병목"] = np.where(proc["AS-IS(분)"] >= thr, "병목", "")
print(f"\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)")
print(proc[["업무 단계", "AS-IS(분)", "TO-BE(분)", "병목"]].to_string())
nb = int((proc["병목"] == "병목").sum())
asis, tobe = proc["AS-IS(분)"].sum(), proc["TO-BE(분)"].sum()
print(f"\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})")
print(f"1회 처리 시간 AS-IS {asis}분 -> TO-BE {tobe}분 ({1 - tobe / asis:.0%} 단축 예상)")
print("\n[스키마 그룹별 필드 수]", {g: len(f) for g, f in SCHEMA.items()})
In [ ]:
"""도로 파괴·교량 붕괴·하중 제한·통제 구역 보고를 여러 형식(CSV·엑셀 내보내기·JSON·공문 텍스트)의 가상 문서에서 파싱해 도로망 그래프에 반영하는 전장 제약 조건 전처리 모듈을 구현하고, 파싱 성공률과 반영 후 연결성을 확인한다."""
import heapq
import csv
import io
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 2] 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 2] 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
E = {e["id"]: e for e in edges}
DEPOT, UNITS = "N0000", [f"N{r:02d}07" for r in range(6)]
# 1) [교육용가상자료] 메모리 안의 가상 보고 문서 4종 (파일 대신 문자열로 모의)
CSV_DOC = """edge_id,status,reason
E017,파괴,포격으로 노면 유실
E040,파괴,지뢰 의심 신고
E058,정상,복구 완료
E071,파괴,차량 잔해 적치"""
EXCEL_DOC = "구간ID\t교량하중(t)\t비고\nE025\t15\t교각 손상\nE064\t20\t임시 가설 교량" # 엑셀 내보내기(탭 구분, 한글 머리글)
JSON_DOC = json.dumps({"control_zones": [{"zone": "CZ-1", "x_km": 17.5, "y_km": 12.0, "r_km": 2.5, "reason": "화생방 오염 의심"},
{"zone": "CZ-2", "x_km": 30.0, "y_km": 21.0, "r_km": 1.8, "reason": "민간인 대피 통로"}]}, ensure_ascii=False)
MEMO_DOC = """[가상 공문] 제목: 도로 상황 변경 통보
1. 교량 E046 붕괴로 통행 불가.
2. E083 구간 교량 하중 제한 20톤으로 하향.
3. E999 구간 파괴 보고(구간 번호 확인 필요)."""
ALIAS = {"edge_id": ["edge_id", "구간ID", "구간"], "limit_t": ["limit_t", "교량하중(t)", "하중"], "status": ["status", "상태"]}
def std_cols(df):
"""양식이 바뀌어도(머리글 변경) 별칭 사전으로 표준 컬럼명에 맞춘다."""
ren = {c: k for k, al in ALIAS.items() for c in df.columns if c.strip() in al}
return df.rename(columns=ren)
def parse(fmt, text):
"""형식별 파서. 결과는 표준 제약 레코드 목록 {type, edge_id | zone, value, reason}."""
if fmt == "csv":
df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text)))))
return [{"type": "close" if s == "파괴" else "reopen", "edge_id": e, "reason": r} for e, s, r in zip(df.edge_id, df.status, df.reason)]
if fmt == "excel":
df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text), delimiter="\t"))))
return [{"type": "limit", "edge_id": e, "value": float(v), "reason": r} for e, v, r in zip(df.edge_id, df.limit_t, df["비고"])]
if fmt == "json":
return [{"type": "zone", "zone": z["zone"], "value": (z["x_km"], z["y_km"], z["r_km"]), "reason": z["reason"]}
for z in json.loads(text)["control_zones"]]
if fmt == "memo":
out = []
for line in text.splitlines():
m = re.search(r"(E\d{3}).*?(붕괴|파괴|통행 불가)", line)
if m:
out.append({"type": "close", "edge_id": m.group(1), "reason": line.strip()}); continue
m = re.search(r"(E\d{3}).*?하중 제한 (\d+)\s*톤", line)
if m:
out.append({"type": "limit", "edge_id": m.group(1), "value": float(m.group(2)), "reason": line.strip()})
return out
raise ValueError(f"지원하지 않는 형식: {fmt}")
def check_rec(r):
"""스키마 유효성 검사: 구간ID 형식·존재 여부, 하중 1~100t, 통제 구역 반경 0.1~10km."""
if r["type"] == "zone":
return [] if 0.1 <= r["value"][2] <= 10 else [f"{r['zone']} 반경 범위 오류"]
errs = [] if re.match(r"^E\d{3}$", r["edge_id"]) else [f"{r['edge_id']} 형식 오류"]
if r["edge_id"] not in E:
errs.append(f"{r['edge_id']} 존재하지 않는 구간")
if r["type"] == "limit" and not 1 <= r["value"] <= 100:
errs.append(f"{r['edge_id']} 하중 범위 오류")
return errs
def apply(edges, recs, log):
"""제약 레코드를 그래프에 반영. 없는 구간 ID는 반영하지 않고 로그에 남긴다."""
new = [dict(e) for e in edges]
idx = {e["id"]: e for e in new}
for r in recs:
if r["type"] == "zone":
x, y, rad = r["value"]
for e in new:
if edge_risk(e, nodes, [{"x": x, "y": y, "r": rad, "p": 1.0}]) > 0:
e["open"] = False; e["zone"] = r["zone"]
log.append(f"{r['zone']} 통제 구역 반영")
elif r["edge_id"] not in idx:
log.append(f"{r['edge_id']} 없는 구간 ID - 반영 보류(확인 요청)")
elif r["type"] == "close":
idx[r["edge_id"]]["open"] = False
elif r["type"] == "reopen":
idx[r["edge_id"]]["open"] = True
elif r["type"] == "limit":
idx[r["edge_id"]].update(bridge=True, limit_t=r["value"])
return new
docs = [("csv", CSV_DOC), ("excel", EXCEL_DOC), ("json", JSON_DOC), ("memo", MEMO_DOC)]
recs, rows = [], []
for fmt, text in docs:
try:
r = parse(fmt, text); recs += r; rows.append({"형식": fmt, "파싱": "성공", "레코드": len(r)})
except Exception as ex:
rows.append({"형식": fmt, "파싱": f"실패({ex})", "레코드": 0})
res = pd.DataFrame(rows)
rate = (res["파싱"] == "성공").mean()
print(res.to_string(index=False))
print(f"파싱 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 제약 레코드 {len(recs)}건")
log = []
after = apply(edges, recs, log)
print("\n[반영 로그]", " / ".join(log))
print(f"통행 불가 구간 {sum(not e['open'] for e in after)}개, 하중 제한 교량 {sum(e['bridge'] for e in after)}개 (원본 {sum(e['bridge'] for e in edges)}개)")
# 2) 반영 전후 연결성·도달성 점검 (트럭 총중량 15t, 트레일러 총중량 50t 가정)
ids = list(nodes)
pos = {n: i for i, n in enumerate(ids)}
def n_components(es, gross):
ok = [e for e in es if passable(e, gross)]
g = coo_matrix((np.ones(len(ok)), ([pos[e["u"]] for e in ok], [pos[e["v"]] for e in ok])), shape=(len(ids), len(ids)))
return connected_components(g, directed=False)[0]
print("\n차종 반영 전 연결 요소 반영 후 연결 요소 도달 가능 부대(후)")
for kind, gross in [("truck", 15), ("trailer", 50)]:
adj = build_adj(nodes, after, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)
reach = sum(dijkstra(adj, DEPOT, u)[0] < float("inf") for u in UNITS)
print(f"{kind:8s} {n_components(edges, gross):16d} {n_components(after, gross):16d} {reach}/{len(UNITS)}")
# 3) 양식 변경 대응 시험: 머리글이 바뀐 CSV도 별칭 사전으로 처리되는지 확인
changed = "구간,상태,reason\nE017,파괴,머리글 변경 양식"
print("\n머리글 변경 양식 파싱:", parse("csv", changed))
errs = [x for r in recs for x in check_rec(r)]
print(f"스키마 검사: 레코드 {len(recs)}건 중 오류 {len(errs)}건 {errs} -> 해당 건은 반영 보류 후 확인 요청")
# 4) 반영 전후 그래프 그림
fig, ax = plt.subplots(1, 2, figsize=(13, 5))
for a, es, title in [(ax[0], edges, "Road network (before)"), (ax[1], after, "After battlefield constraints")]:
for e in es:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
col = "red" if not e["open"] else ("orange" if e["bridge"] else ("tab:blue" if e["road"] == "paved" else "tab:gray"))
a.plot([x1, x2], [y1, y2], color=col, lw=2.5 if not e["open"] else 1.5, ls="--" if not e["open"] else "-")
a.plot(*nodes[DEPOT], "ks", ms=10)
for u in UNITS:
a.plot(*nodes[u], "g^", ms=9)
a.set_title(title); a.set_xlabel("x (km)"); a.set_ylabel("y (km)"); a.set_aspect("equal")
for r in recs:
if r["type"] == "zone":
ax[1].add_patch(plt.Circle(r["value"][:2], r["value"][2], color="purple", alpha=0.2))
ax[1].text(0, -4, "red dashed = closed, orange = bridge (weight limit), purple = control zone", fontsize=8)
plt.suptitle("MOV02 constraint preprocessing (synthetic network)")
plt.tight_layout(); plt.show()
In [ ]:
"""다익스트라 알고리즘으로 보급소에서 지원 부대까지 최단시간 보급 경로를 찾는 Baseline을 구현하고, 설정 문자열로 분리한 경로 검증 규칙 15종과 경계 조건 단위 테스트로 결과를 판정한다."""
import heapq
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.sparse import csr_matrix
from scipy.sparse.csgraph import dijkstra as sp_dijkstra
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 3] 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 3] 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
# 규정·기준이 바뀌면 코드 대신 이 설정만 고친다(YAML 형식 문자열)
RULES_YAML = """
max_unpaved_ratio: 0.5
detour_factor: 2.5
max_threat_edges: 1
truck_count: 3
"""
CFG = {k.strip(): float(v) for k, v in (line.split(":") for line in RULES_YAML.strip().splitlines())}
nodes, edges = make_network()
threats = make_threats()
for e in edges: # 미션 2 전처리 결과(폐쇄·하중 제한·통제 구역) 요약 반영
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
if e["id"] in ("E025", "E064", "E083"):
e.update(bridge=True, limit_t={"E025": 15.0, "E064": 20.0, "E083": 20.0}[e["id"]])
if edge_risk(e, nodes, [{"x": 17.5, "y": 12.0, "r": 2.5, "p": 1}]) > 0:
e["open"], e["zone"] = False, "CZ-1"
e["risk"] = edge_risk(e, nodes, threats)
DEPOT = "N0000"
ASSET = {"kind": "truck", "status": "가용", **ASSETS["truck"]}
def route(dest, cargo_t, kind="truck"):
gross = ASSETS[kind]["weight_t"] + min(cargo_t, ASSETS[kind]["cap_t"])
adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)
return dijkstra(adj, DEPOT, dest)
def rules(req, cost, path, es, asset=ASSET):
"""경로 검증 규칙 15종. 결과: 규칙명 -> PASS / FAIL / WARN."""
gross = asset["weight_t"] + asset["cap_t"]
km = sum(e["km"] for e in es)
straight = np.hypot(*np.subtract(nodes[req["dest"]], nodes[DEPOT]))
pairs = {frozenset((e["u"], e["v"])) for e in edges}
R = {
"R01 출발지=보급소": path[:1] == [DEPOT],
"R02 도착지=요청 부대": path[-1:] == [req["dest"]],
"R03 연속 노드 간 도로 존재": all(frozenset(p) in pairs for p in zip(path, path[1:])),
"R04 폐쇄 구간 미포함": all(e["open"] for e in es),
"R05 교량 하중 이내": all(not e["bridge"] or e["limit_t"] >= gross for e in es),
"R06 통제 구역 미통과": all("zone" not in e for e in es),
"R07 노드 중복 방문 없음": len(path) == len(set(path)),
"R08 거리 합계 정합": abs(km - sum(e["km"] for e in es)) < 1e-9 and len(es) == len(path) - 1,
"R09 소요시간 기한 이내": cost <= req["deadline_h"] * 60,
"R10 적재량 수송능력 이내": req["cargo_t"] <= asset["cap_t"] * CFG["truck_count"],
"R11 자산 상태 가용": asset["status"] == "가용",
"R12 비포장 비율 기준 이내": sum(e["km"] for e in es if e["road"] == "unpaved") <= CFG["max_unpaved_ratio"] * max(km, 1e-9),
"R13 우회 과다 없음": km <= CFG["detour_factor"] * straight,
"R14 경로 구간 1개 이상": len(es) >= 1,
"R15 위협 구역 통과 기준 이내": sum(e["risk"] > 0 for e in es) <= CFG["max_threat_edges"],
}
out = {k: "PASS" if v else "FAIL" for k, v in R.items()}
if out["R15 위협 구역 통과 기준 이내"] == "FAIL":
out["R15 위협 구역 통과 기준 이내"] = "WARN" # Baseline은 위협을 고려하지 않으므로 경고만 표시
return out
rng = np.random.default_rng(2)
reqs = [{"req": f"REQ-{i:04d}", "dest": f"N{r:02d}07", "cargo_t": float(round(rng.uniform(4, 14), 1)), "deadline_h": 1.5} for i, r in enumerate(range(6))]
rows, lat, all_res = [], [], []
for q in reqs:
cost, path, es = route(q["dest"], q["cargo_t"])
t0 = time.perf_counter()
res = rules(q, cost, path, es)
lat.append((time.perf_counter() - t0) * 1000)
all_res.append(res)
rows.append({"요청": q["req"], "목적지": q["dest"], "거리(km)": round(sum(e["km"] for e in es), 1), "소요(분)": round(cost, 1),
"구간 수": len(es), "위협 통과 구간": sum(e["risk"] > 0 for e in es),
"PASS": sum(v == "PASS" for v in res.values()), "WARN": sum(v == "WARN" for v in res.values()), "FAIL": sum(v == "FAIL" for v in res.values())})
print("[Baseline 최단시간 경로 결과 - 중형 트럭]")
print(pd.DataFrame(rows).to_string(index=False))
print(f"\n검증 규칙 {len(all_res[0])}종(목표 15종 이상), 규칙 처리 평균 {np.mean(lat):.2f} ms / 최대 {np.max(lat):.2f} ms (목표 50 ms 이하: {'달성' if max(lat) <= 50 else '미달'})")
# 1) 최적성 검증: scipy 다익스트라와 비용 비교
ids = list(nodes); ix = {n: i for i, n in enumerate(ids)}
ok_e = [e for e in edges if passable(e, 10 + 5)]
w = [edge_minutes(e) for e in ok_e]
M = csr_matrix((w + w, ([ix[e["u"]] for e in ok_e] + [ix[e["v"]] for e in ok_e], [ix[e["v"]] for e in ok_e] + [ix[e["u"]] for e in ok_e])), shape=(len(ids),) * 2)
ref = sp_dijkstra(M, indices=ix[DEPOT])
same = sum(abs(ref[ix[q["dest"]]] - route(q["dest"], 5.0)[0]) < 1e-6 for q in reqs)
print(f"최적성 검증(scipy 결과와 일치): {same}/{len(reqs)}")
# 2) 경계 조건 단위 테스트
cost, path, es = route("N0507", 5.0)
tests = {
"적재량=수송능력 한계(15t)": rules({"dest": "N0507", "cargo_t": 15.0, "deadline_h": 9}, cost, path, es)["R10 적재량 수송능력 이내"] == "PASS",
"적재량 한계+0.1t": rules({"dest": "N0507", "cargo_t": 15.1, "deadline_h": 9}, cost, path, es)["R10 적재량 수송능력 이내"] == "FAIL",
"소요시간=기한 정확히 일치": rules({"dest": "N0507", "cargo_t": 5, "deadline_h": cost / 60}, cost, path, es)["R09 소요시간 기한 이내"] == "PASS",
"도달 불가 목적지 처리(고립 노드)": dijkstra(build_adj(nodes, edges, lambda e: None if "N0507" in (e["u"], e["v"]) else 1.0), DEPOT, "N0507") == (float("inf"), [], []),
"출발지=도착지": dijkstra(build_adj(nodes, edges, lambda e: 1.0), DEPOT, DEPOT)[1] == [DEPOT],
"정비 중 자산 차단": rules({"dest": "N0507", "cargo_t": 5, "deadline_h": 9}, cost, path, es, {**ASSET, "status": "정비"})["R11 자산 상태 가용"] == "FAIL",
}
for k, v in tests.items():
print(f" [{'PASS' if v else 'FAIL'}] {k}")
print(f"단위 테스트 통과율 {np.mean(list(tests.values())):.0%}")
print("Baseline 한계: 시간만 최소화해 위협 구역을 지나는 경로(WARN)가 나온다 -> 미션 4에서 위협 회피·적재 효율을 반영한다.")
plt.figure(figsize=(8, 5.5))
for e in edges:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
plt.plot([x1, x2], [y1, y2], color="lightgray" if e["open"] else "red", lw=1, ls="-" if e["open"] else "--")
for t in threats:
plt.gca().add_patch(plt.Circle((t["x"], t["y"]), t["r"], color="red", alpha=0.12))
for q, c in zip(reqs, plt.cm.tab10.colors):
_, path, _ = route(q["dest"], q["cargo_t"])
xy = np.array([nodes[n] for n in path]); plt.plot(xy[:, 0], xy[:, 1], color=c, lw=2.5, label=f"to {q['dest']}")
plt.plot(*nodes[DEPOT], "ks", ms=10, label="depot")
plt.gca().set_aspect("equal"); plt.legend(fontsize=8, loc="upper left"); plt.xlabel("x (km)"); plt.ylabel("y (km)")
plt.title("Baseline shortest-time routes (red circles = threat zones, synthetic)")
plt.show()
In [ ]:
"""가상 위협 구역 회피 비용과 적재 효율(차종 조합)을 함께 고려하는 수송 경로 추천 모델을 구현한다. 과거 수송통제관 승인 사례로 위험 가중치를 학습해 추천 일치도(Top-1·Top-3)를 검증하고, 추천 사유와 유사도 점수를 제시한다."""
import heapq
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 4] 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 4] 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
DEPOT, UNITS = "N0000", [f"N{r:02d}07" for r in range(6)]
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
LAMS = [0.0, 0.3, 0.7, 1.5, 3.0] # 위험 회피 강도별 후보 경로 생성
def candidates(dest, threats, kind="truck", gross=15, k=5):
"""위험 회피 강도(lam)를 바꾸고, 이미 찾은 경로 구간에 벌점을 주어 서로 다른 후보 경로(최대 k개)를 만든다."""
risk = {e["id"]: edge_risk(e, nodes, threats) for e in edges}
out, seen, pen = [], set(), {}
for lam in LAMS + [0.0, 0.0, 0.3, 0.3]:
adj = build_adj(nodes, edges, lambda e: (edge_minutes(e, kind) + lam * risk[e["id"]] * 100) * pen.get(e["id"], 1.0)
if passable(e, gross) else None)
_, path, es = dijkstra(adj, DEPOT, dest)
for e in es:
pen[e["id"]] = pen.get(e["id"], 1.0) * 1.3
if path and tuple(path) not in seen and len(out) < k:
seen.add(tuple(path))
surv = float(np.prod([1 - risk[e["id"]] for e in es]))
out.append({"path": path, "min": sum(edge_minutes(e, kind) for e in es), "km": sum(e["km"] for e in es),
"surv": surv, "risk_score": -np.log(surv) * 100, "threat_edges": sum(risk[e["id"]] > 0 for e in es)})
return out
def utility(c, w):
return c["min"] + w * c["risk_score"] # 낮을수록 좋음: 소요시간(분) + 위험 가중치 x 위험 점수
# 1) [교육용가상자료] 과거 수송통제관 승인 사례 40건: 숨은 선호(w=1.2)와 판단 편차(잡음)로 한 경로를 승인
rng = np.random.default_rng(4)
cases = []
for i in range(60):
th = make_threats(seed=100 + i, n=int(rng.integers(2, 5)))
cand = candidates(str(rng.choice(UNITS)), th)
if len(cand) < 2:
continue
approved = int(np.argmin([utility(c, 1.2) + rng.normal(0, 1.0) for c in cand]))
cases.append({"cand": cand, "approved": approved})
train, test = cases[:len(cases) // 2], cases[len(cases) // 2:]
def agreement(cs, w, top=1):
return np.mean([c["approved"] in np.argsort([utility(x, w) for x in c["cand"]])[:top] for c in cs])
def loglik(cs, w, T=1.5):
"""선택 확률 P(i) ~ exp(-효용/T) 가정의 로그우도(조건부 로짓). 일치도보다 매끄러워 가중치 추정이 안정적이다."""
ll = 0.0
for c in cs:
u = -np.array([utility(x, w) for x in c["cand"]]) / T
ll += u[c["approved"]] - (u.max() + np.log(np.exp(u - u.max()).sum()))
return ll
grid = np.round(np.arange(0, 3.01, 0.1), 1)
w_best = grid[int(np.argmax([loglik(train, w) for w in grid]))]
print(f"과거 승인 사례 {len(cases)}건(학습 {len(train)} / 검증 {len(test)}), 학습된 위험 가중치 w = {w_best}")
rows = [{"모델": "Baseline(최단시간, w=0)", "Top-1 일치도": agreement(test, 0.0), "Top-3 포함률": agreement(test, 0.0, 3)},
{"모델": f"위협 회피 추천(w={w_best})", "Top-1 일치도": agreement(test, w_best), "Top-3 포함률": agreement(test, w_best, 3)}]
res = pd.DataFrame(rows)
print(res.to_string(index=False, float_format=lambda v: f"{v:.1%}"))
acc = rows[1]["Top-1 일치도"]
print(f"추천 정확도 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})")
# 2) 현재 요청에 대한 Top-3 추천과 추천 사유
threats = make_threats()
dest, cargo = "N0307", 38.0
cand = candidates(dest, threats)
order = np.argsort([utility(c, w_best) for c in cand])[:3]
best_u = utility(cand[order[0]], w_best)
print(f"\n[요청: {dest} 보급 {cargo}t] 추천 Top-3 (유사도 = 1순위 대비 효용 비율)")
base = min(cand, key=lambda c: c["min"])
for rank, i in enumerate(order, 1):
c = cand[i]
print(f" {rank}순위 소요 {c['min']:.0f}분, 거리 {c['km']:.1f}km, 위협 통과 {c['threat_edges']}구간, 생존확률 {c['surv']:.1%}, 유사도 {best_u / utility(c, w_best):.2f}")
top = cand[order[0]]
print(f" 추천 사유: 최단시간 경로 대비 소요 {top['min'] - base['min']:+.0f}분, 위협 통과 {base['threat_edges']}->{top['threat_edges']}구간, 생존확률 {base['surv']:.1%}->{top['surv']:.1%}")
# 3) 적재 효율: 차종 조합(트럭 5t, 트레일러 20t) 최적화 - 교량 하중 때문에 트레일러 경로가 다를 수 있음
def plan(cargo, allow_trailer=True):
best = None
for n_trl in range(0, 4 if allow_trailer else 1):
n_trk = int(max(0, np.ceil((cargo - 20 * n_trl) / 5)))
if n_trk > 10:
continue
cost = 0.0
if n_trl:
c = candidates(dest, threats, "trailer", 50)
if not c:
continue
cost += n_trl * min(c, key=lambda x: utility(x, w_best))["km"] * ASSETS["trailer"]["cost_km"]
cost += n_trk * top["km"] * ASSETS["truck"]["cost_km"]
cap = 20 * n_trl + 5 * n_trk
cand_plan = {"트레일러": n_trl, "트럭": n_trk, "수송능력(t)": cap, "적재 효율": cargo / cap, "비용(단가x km)": round(cost, 1)}
if best is None or cost < best["비용(단가x km)"]:
best = cand_plan
return best
p0, p1 = plan(cargo, False), plan(cargo)
print("\n[적재 계획 비교]")
print(pd.DataFrame([{"방식": "트럭만 배차", **p0}, {"방식": "차종 조합 최적화", **p1}]).to_string(index=False, float_format=lambda v: f"{v:.1%}" if v < 1 else f"{v:.1f}"))
print(f"비용 {1 - p1['비용(단가x km)'] / p0['비용(단가x km)']:.1%} 절감, 투입 차량 {p0['트럭']}대 -> {p1['트럭'] + p1['트레일러']}대")
fig, ax = plt.subplots(1, 2, figsize=(13, 5))
for e in edges:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
ax[0].plot([x1, x2], [y1, y2], color="lightgray", lw=1)
for t in threats:
ax[0].add_patch(plt.Circle((t["x"], t["y"]), t["r"], color="red", alpha=0.15))
for rank, (i, col) in enumerate(zip(order, ["tab:green", "tab:blue", "tab:orange"]), 1):
xy = np.array([nodes[n] for n in cand[i]["path"]]); ax[0].plot(xy[:, 0], xy[:, 1], color=col, lw=3 - 0.6 * rank, label=f"rank {rank}")
xy = np.array([nodes[n] for n in base["path"]]); ax[0].plot(xy[:, 0], xy[:, 1], "k--", lw=1, label="shortest-time")
ax[0].set_aspect("equal"); ax[0].legend(fontsize=8); ax[0].set_title("Threat-aware route recommendation (synthetic)")
ax[1].plot(grid, [agreement(train, w) for w in grid], "o-", label="train")
ax[1].plot(grid, [agreement(test, w) for w in grid], "s-", label="test")
ax[1].axhline(0.85, ls="--", c="gray"); ax[1].set_xlabel("risk weight w"); ax[1].set_ylabel("Top-1 agreement"); ax[1].legend()
ax[1].set_title("Agreement with past approvals")
plt.tight_layout(); plt.show()
In [ ]:
"""후보 수송 경로마다 수송선단 생존 확률(위협 구역 통과 기반)과 정시 도착 신뢰도(몬테카를로 소요시간 모의)를 계산해 0~100점 신뢰도 점수와 등급을 산출하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면으로 표시하며 예측 확률의 보정 정확도를 검증한다."""
import heapq
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 5] 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 5] 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
threats = make_threats()
DEPOT = "N0000"
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
e["risk"] = edge_risk(e, nodes, threats)
SIGMA = {"paved": 0.12, "unpaved": 0.30} # 도로 등급별 소요시간 변동(로그정규 표준편차, 가정값)
def candidates(dest, lams=(0.0, 0.5, 1.5, 3.0, 0.0, 0.5), k=4):
"""위험 회피 강도를 바꾸고 이미 쓴 구간에 벌점을 주어 서로 다른 후보 경로를 최대 k개 만든다."""
out, seen, pen = [], set(), {}
for lam in lams:
adj = build_adj(nodes, edges, lambda e: (edge_minutes(e) + lam * e["risk"] * 100) * pen.get(e["id"], 1.0) if passable(e, 15) else None)
_, path, es = dijkstra(adj, DEPOT, dest)
for e in es:
pen[e["id"]] = pen.get(e["id"], 1.0) * 1.3
if path and tuple(path) not in seen and len(out) < k:
seen.add(tuple(path)); out.append({"path": path, "es": es})
return out
def simulate_minutes(es, n, rng):
"""경로 소요시간 몬테카를로: 도로 등급별 변동 + 교량 통과 대기 + 위협 구역 감속."""
total = np.zeros(n)
for e in es:
total += edge_minutes(e) * rng.lognormal(0, SIGMA[e["road"]], n)
if e["bridge"]:
total += rng.exponential(3, n)
if e["risk"] > 0:
total += rng.uniform(0, 10, n)
return total
def score_route(c, deadline_min, n=3000, seed=0):
surv = float(np.prod([1 - e["risk"] for e in c["es"]]))
sims = simulate_minutes(c["es"], n, np.random.default_rng(seed))
ontime = float((sims <= deadline_min).mean())
s = 100 * surv * ontime
return {"생존확률": surv, "정시도착": ontime, "예상소요(P50)": float(np.median(sims)), "예상소요(P90)": float(np.percentile(sims, 90)),
"점수": round(s, 1), "등급": "신뢰" if s >= 85 else ("주의" if s >= 60 else "위험"), "sims": sims}
# ---------------- 화면 1. 입력 ----------------
REQ = {"req_id": "REQ-0101", "dest": "N0307", "cargo_t": 12.0, "deadline_min": 80}
print("[화면 1: 입력] 수송 요청", {k: v for k, v in REQ.items()}, "| 가상 위협 구역", len(threats), "개")
# ---------------- 화면 2. 분석 ----------------
cands = candidates(REQ["dest"])
rows = []
for i, c in enumerate(cands):
r = score_route(c, REQ["deadline_min"], seed=i)
c.update(r)
rows.append({"경로": f"R{i + 1}", "구간 수": len(c["es"]), "위협 통과": sum(e["risk"] > 0 for e in c["es"]),
**{k: r[k] for k in ("생존확률", "정시도착", "예상소요(P50)", "예상소요(P90)", "점수", "등급")}})
table = pd.DataFrame(rows)
fmt = {"생존확률": "{:.1%}", "정시도착": "{:.1%}", "예상소요(P50)": "{:.0f}분", "예상소요(P90)": "{:.0f}분"}
show = table.copy()
for k, f in fmt.items():
show[k] = show[k].map(f.format)
print("\n[화면 2: 분석] 후보 경로별 신뢰도 점수 (점수 = 100 x 생존확률 x 정시도착확률)")
print(show.to_string(index=False))
# ---------------- 화면 3. 검토 (상태 뱃지·필터) ----------------
badge = {"신뢰": "완료(추천 가능)", "주의": "승인대기(통제관 검토)", "위험": "반려(재탐색)"}
table["상태"] = table["등급"].map(badge)
print("\n[화면 3: 검토] 상태별 필터")
for st in badge.values():
print(f" {st}: {table.loc[table['상태'] == st, '경로'].tolist()}")
# ---------------- 화면 4. 결과 ----------------
best = table.sort_values(["점수", "예상소요(P50)"], ascending=[False, True]).iloc[0]
print(f"\n[화면 4: 결과] 추천 {best['경로']} - 점수 {best['점수']}({best['등급']}), 생존확률 {best['생존확률']:.1%}, 정시도착 {best['정시도착']:.1%}")
print("핵심 화면 4종(입력-분석-검토-결과) 구현 완료: 4/4")
# ---------------- 예측 보정(Calibration) 검증 ----------------
# 서로 다른 목적지·기한의 가상 수송 60건: 예측 정시확률 vs 별도 난수로 모의한 '실제 운행 결과'
rng = np.random.default_rng(21)
pred, actual = [], []
for k in range(60):
c = candidates(f"N{int(rng.integers(0, 6)):02d}07")[int(rng.integers(0, 2))] if k % 2 else candidates(f"N{int(rng.integers(0, 6)):02d}07")[0]
dl = float(rng.uniform(60, 100))
pred.append(score_route(c, dl, n=2000, seed=k)["정시도착"])
actual.append(float(simulate_minutes(c["es"], 1, rng)[0] <= dl))
pred, actual = np.array(pred), np.array(actual)
brier = np.mean((pred - actual) ** 2)
print(f"\n정시도착 예측 보정 검증(가상 운행 60건): Brier 점수 {brier:.3f} (0에 가까울수록 좋음, 무정보 예측 0.25)")
for lo, hi in [(0, 0.5), (0.5, 0.9), (0.9, 1.01)]:
m = (pred >= lo) & (pred < hi)
if m.any():
print(f" 예측 {lo:.0%}~{min(hi, 1):.0%} 구간 {m.sum():2d}건: 평균 예측 {pred[m].mean():.1%}, 실제 정시율 {actual[m].mean():.1%}")
fig, ax = plt.subplots(2, 2, figsize=(12, 8))
for e in edges:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
ax[0, 0].plot([x1, x2], [y1, y2], color="lightgray", lw=1)
for t in threats:
ax[0, 0].add_patch(plt.Circle((t["x"], t["y"]), t["r"], color="red", alpha=0.15))
for i, c in enumerate(cands):
xy = np.array([nodes[n] for n in c["path"]]); ax[0, 0].plot(xy[:, 0], xy[:, 1], lw=2, label=f"R{i + 1}")
ax[0, 0].set_aspect("equal"); ax[0, 0].legend(fontsize=8); ax[0, 0].set_title("1. Input: request and candidate routes")
for i, c in enumerate(cands):
ax[0, 1].hist(c["sims"], bins=40, alpha=0.5, label=f"R{i + 1}")
ax[0, 1].axvline(REQ["deadline_min"], color="k", ls="--"); ax[0, 1].set_xlabel("travel time (min)"); ax[0, 1].legend()
ax[0, 1].set_title("2. Analysis: Monte Carlo travel time")
cols = {"신뢰": "tab:green", "주의": "tab:orange", "위험": "tab:red"}
ax[1, 0].bar(table["경로"], table["점수"], color=[cols[g] for g in table["등급"]])
ax[1, 0].axhline(85, ls="--", c="gray"); ax[1, 0].axhline(60, ls=":", c="gray"); ax[1, 0].set_ylim(0, 100)
ax[1, 0].set_title("3. Review: score (green=trusted, orange=review, red=reject)")
ax[1, 1].plot([0, 1], [0, 1], "k--", lw=1)
bins = np.digitize(pred, [0.2, 0.4, 0.6, 0.8])
ax[1, 1].plot([pred[bins == b].mean() for b in np.unique(bins)], [actual[bins == b].mean() for b in np.unique(bins)], "o-")
ax[1, 1].set_xlabel("predicted on-time prob."); ax[1, 1].set_ylabel("observed on-time rate"); ax[1, 1].set_title("4. Result: calibration check")
plt.suptitle("MOV02 convoy reliability score engine (synthetic)"); plt.tight_layout(); plt.show()
In [ ]:
"""수송 요청에 자산(트럭·트레일러)과 위협 회피 경로를 배정해 관제 대시보드(경로 지도·자산 가동률·운행 일정·비용 실적)로 가시화하고, 입력 검증기·에러 처리기로 예외 주입 10종을 크래시 없이 기록·안내하며 재시도와 롤백을 확인한다."""
import heapq
import json
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 6] 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 6] 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
threats = make_threats()
DEPOT = "N0000"
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
e["risk"] = edge_risk(e, nodes, threats)
for e in edges: # 가상 고립 지점: N0506 주변 도로 전부 파괴
if "N0506" in (e["u"], e["v"]):
e["open"] = False
FLEET = {**{f"T{i:02d}": "truck" for i in range(1, 9)}, **{f"L{i:02d}": "trailer" for i in range(1, 3)}}
LOG = [] # 에러·처리 로그(감사용)
class InputError(Exception):
def __init__(self, code, msg, guide):
super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide
class TransientError(Exception):
pass
def validate(req, seen):
"""입력 검증: 필수 필드·형식·범위·중복·존재 여부. 위반 시 InputError(코드, 메시지, 조치 가이드)."""
for k in ("req_id", "dest", "cargo_t", "deadline_min"):
if k not in req or req[k] is None:
raise InputError("V01", f"필수 항목 '{k}' 누락", f"수송 요청서의 '{k}' 칸을 채워 다시 제출")
rid = str(req["req_id"])
if not rid.replace("-", "").isalnum() or len(rid) > 20:
raise InputError("V02", f"요청번호 형식 오류({rid[:12]}...)", "영문·숫자·'-'만 20자 이내로 입력")
if rid in seen:
raise InputError("V03", f"중복 요청번호 {rid}", "기존 요청 수정 화면을 사용하거나 새 번호 발급")
try:
cargo, dl = float(req["cargo_t"]), float(req["deadline_min"])
except (TypeError, ValueError):
raise InputError("V04", f"숫자 형식 오류(cargo_t={req['cargo_t']!r})", "단위 없이 숫자만 입력(예: 12)")
if not np.isfinite(cargo) or not np.isfinite(dl):
raise InputError("V05", "적재량·기한 값이 비어 있음(NaN)", "적재량(t)과 도착 기한(분)을 확인")
if cargo <= 0 or dl <= 0:
raise InputError("V06", f"음수·0 값(cargo_t={cargo}, deadline={dl})", "0보다 큰 값으로 입력")
if req["dest"] not in nodes:
raise InputError("V07", f"존재하지 않는 목적지 {req['dest']}", "도로망 노드 목록에서 목적지 선택")
cap = sum(ASSETS[k]["cap_t"] for k in FLEET.values())
if cargo > cap:
raise InputError("V08", f"적재량 {cargo}t > 가용 수송능력 {cap}t", "분할 수송 요청 또는 상급 제대 지원 요청")
return cargo, dl
def route(dest, kind, gross, fail_first=False, _state={"n": 0}):
if fail_first and _state["n"] == 0: # 경로 계산 서비스 일시 장애 모의(첫 호출만 실패)
_state["n"] += 1
raise TransientError("경로 계산 시간 초과")
adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) + 150 * e["risk"] if passable(e, gross) else None)
cost, path, es = dijkstra(adj, DEPOT, dest)
if not path:
raise InputError("R01", f"{dest}까지 통행 가능한 경로 없음", "도로 복구 정보 확인 후 공중수송 검토를 통제관에 요청")
return path, es
def assign(req, cargo, dl, state, fail_first=False):
"""적재량에 맞게 자산을 고르고(트레일러 우선, 나머지 트럭) 경로를 배정한다. 실패 시 호출자가 롤백."""
picks, left = [], cargo
for vid in sorted(FLEET, key=lambda v: FLEET[v] != "trailer"): # 10t 초과분은 트레일러 우선, 나머지는 트럭
if left <= 0:
break
if vid in state["busy"] or (FLEET[vid] == "trailer" and left <= 10):
continue
picks.append(vid); state["busy"].add(vid); left -= ASSETS[FLEET[vid]]["cap_t"] # 상태 변경(롤백 대상)
if left > 0:
raise InputError("A01", "가용 자산 부족", "대기 후 재배정 또는 다른 요청과 통합 수송")
out = []
for vid in picks:
kind = FLEET[vid]
for attempt in range(3): # 일시 장애 자동 재시도(최대 3회)
try:
path, es = route(req["dest"], kind, ASSETS[kind]["weight_t"] + min(ASSETS[kind]["cap_t"], cargo), fail_first and attempt == 0)
break
except TransientError as ex:
LOG.append({"req": req["req_id"], "level": "WARN", "code": "T01", "msg": f"{ex} -> 재시도 {attempt + 1}회"})
mins = sum(edge_minutes(e, kind) for e in es); km = sum(e["km"] for e in es)
out.append({"req": req["req_id"], "vid": vid, "kind": kind, "dest": req["dest"], "path": path, "km": km, "min": mins,
"cost": km * ASSETS[kind]["cost_km"], "threat": sum(e["risk"] > 0 for e in es), "ontime": mins <= dl})
return out
def process(batch):
state, seen, result = {"busy": set()}, set(), []
for req in batch:
snap = set(state["busy"]) # 트랜잭션 시작 시점 스냅샷
try:
cargo, dl = validate(req, seen)
rows = assign(req, cargo, dl, state, fail_first=req.get("_flaky", False))
seen.add(str(req["req_id"])); result += rows
LOG.append({"req": req["req_id"], "level": "INFO", "code": "OK", "msg": f"배정 {len(rows)}대"})
except InputError as ex:
state["busy"] = snap # 롤백: 부분 배정한 자산 반환
LOG.append({"req": str(req.get("req_id"))[:20], "level": "ERROR", "code": ex.code, "msg": ex.msg, "guide": ex.guide})
except Exception as ex: # 예상 못한 오류도 크래시 없이 기록
state["busy"] = snap
LOG.append({"req": str(req.get("req_id"))[:20], "level": "ERROR", "code": "X99", "msg": repr(ex)[:60], "guide": "운영 담당자에게 로그 전달"})
return result, state
# ---------------- 1) 정상 요청 배정 + 관제 대시보드 ----------------
NORMAL = [{"req_id": f"REQ-02{i:02d}", "dest": d, "cargo_t": c, "deadline_min": dl}
for i, (d, c, dl) in enumerate([("N0307", 12, 90), ("N0107", 8, 80), ("N0407", 4, 70), ("N0205", 9, 75), ("N0503", 5, 60)])]
NORMAL[3]["_flaky"] = True # 경로 계산 일시 장애가 나도 재시도로 처리되는지 확인
plan, st = process(NORMAL)
df = pd.DataFrame(plan)
print("[관제 현황판] 요청별 자산 배정·경로 [교육용가상자료]")
summ = df.groupby("req").agg(자산=("vid", lambda s: ",".join(s)), 목적지=("dest", "first"), 거리km=("km", "max"),
소요분=("min", "max"), 위협통과=("threat", "max"), 정시=("ontime", "all"), 비용=("cost", "sum")).round(1)
print(summ.to_string())
util = len(st["busy"]) / len(FLEET)
print(f"자산 가동률 {util:.0%} ({len(st['busy'])}/{len(FLEET)}대), 총 운행비용 {df['cost'].sum():.1f}(단가x km), "
f"정시 도착 예상 {summ['정시'].mean():.0%}, 위협 구역 통과 차량 {int((df['threat'] > 0).sum())}대")
print("일시 장애 자동 재시도:", [f"{l['req']} {l['msg']}" for l in LOG if l["code"] == "T01"], "-> 재시도 후 정상 배정")
print("지연 예상 요청은 붉은색으로 표시하고 통제관에게 기한 조정 또는 자산 변경을 건의한다.")
# ---------------- 2) 예외 주입 테스트 10종 ----------------
LOG.clear()
BAD = [{"dest": "N0307", "cargo_t": 3, "deadline_min": 60}, # 1 필수 항목 누락
{"req_id": "REQ-<script>", "dest": "N0307", "cargo_t": 3, "deadline_min": 60}, # 2 특수문자
{"req_id": "REQ-0200", "dest": "N0307", "cargo_t": 3, "deadline_min": 60},
{"req_id": "REQ-0200", "dest": "N0107", "cargo_t": 3, "deadline_min": 60}, # 3 중복 번호
{"req_id": "REQ-0301", "dest": "N0307", "cargo_t": "12t", "deadline_min": 60}, # 4 단위 포함 문자열
{"req_id": "REQ-0302", "dest": "N0307", "cargo_t": float("nan"), "deadline_min": 60}, # 5 결측(NaN)
{"req_id": "REQ-0303", "dest": "N0307", "cargo_t": -4, "deadline_min": 60}, # 6 음수
{"req_id": "REQ-0304", "dest": "N9999", "cargo_t": 3, "deadline_min": 60}, # 7 없는 목적지
{"req_id": "REQ-0305", "dest": "N0307", "cargo_t": 500, "deadline_min": 60}, # 8 수송능력 초과
{"req_id": "REQ-0306", "dest": "N0506", "cargo_t": 3, "deadline_min": 60}, # 9 도달 불가(고립)
{"req_id": "REQ-0307", "dest": "N0307", "cargo_t": 15, "deadline_min": 60},
{"req_id": "REQ-0308", "dest": "N0107", "cargo_t": 60, "deadline_min": 60}] # 10 자산 부족(부분 배정 후 롤백)
expect = ["V01", "V02", "V03", "V04", "V05", "V06", "V07", "V08", "R01", "A01"]
crash = 0
try:
_, st2 = process(BAD)
except Exception:
crash += 1
errs = [l for l in LOG if l["level"] == "ERROR"]
print("\n[예외 주입 결과]")
for l in errs:
print(f" {l['code']} {l['req']:<14} {l['msg']:<34} -> 조치: {l['guide']}")
caught = [c for c in expect if any(l["code"] == c for l in errs)]
print(f"예외 10종 중 검출 {len(caught)}종, 크래시 {crash}건, 에러 로그 기록률 {len(errs) / len(expect):.0%}")
print(f"롤백 확인: 자산 부족 요청 후 사용 중 자산 {sorted(st2['busy'])} (부분 배정분 반환됨)")
# 대용량: 1만 건 요청을 검증만 수행(경로 계산 제외)
rng = np.random.default_rng(3)
big = [{"req_id": f"B{i:05d}", "dest": rng.choice(list(nodes) + ["N??"]), "cargo_t": float(rng.uniform(-2, 20)), "deadline_min": 60} for i in range(10000)]
t0, seen, nerr = time.perf_counter(), set(), 0
for r in big:
try:
validate(r, seen); seen.add(r["req_id"])
except InputError:
nerr += 1
print(f"대용량 1만 건 검증 {time.perf_counter() - t0:.2f}초, 오류 판정 {nerr}건, 크래시 0건")
print("감사 로그 예시:", json.dumps(errs[0], ensure_ascii=False))
print(f"목표(크래시 0건, 로그 기록률 100%): {'달성' if crash == 0 and len(caught) == len(expect) else '미달'}")
fig, ax = plt.subplots(2, 2, figsize=(12, 8))
for e in edges:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
ax[0, 0].plot([x1, x2], [y1, y2], color="lightgray" if e["open"] else "black", lw=1, ls="-" if e["open"] else ":")
for t in threats:
ax[0, 0].add_patch(plt.Circle((t["x"], t["y"]), t["r"], color="red", alpha=0.15))
for i, (rq, g) in enumerate(df.groupby("req")):
xy = np.array([nodes[n] for n in g.iloc[0]["path"]]); ax[0, 0].plot(xy[:, 0], xy[:, 1], lw=2, label=rq)
ax[0, 0].set_aspect("equal"); ax[0, 0].legend(fontsize=7); ax[0, 0].set_title("Route map (dotted = closed road)")
ax[0, 1].bar(list(FLEET), [1 if v in st["busy"] else 0 for v in FLEET], color=["tab:blue" if FLEET[v] == "truck" else "tab:purple" for v in FLEET])
ax[0, 1].set_title(f"Asset assignment (utilization {util:.0%})"); ax[0, 1].set_yticks([0, 1]); ax[0, 1].set_yticklabels(["idle", "busy"])
for i, r in df.reset_index().iterrows():
ax[1, 0].barh(r["vid"], r["min"], color="tab:green" if r["ontime"] else "tab:red")
ax[1, 0].set_xlabel("travel minutes"); ax[1, 0].set_title("Convoy schedule (red = late)")
ax[1, 1].bar(summ.index, summ["비용"], color="tab:gray"); ax[1, 1].set_title("Transport cost by request"); ax[1, 1].tick_params(axis="x", rotation=30)
plt.suptitle("MOV02 transport control dashboard (synthetic)"); plt.tight_layout(); plt.show()
In [ ]:
"""위험 경로 강행·우회 결정을 수송통제관이 직접 승인·반려하는 인간 개입(HITL) 결재 워크플로우를 구현한다. 역할별 권한(RBAC)과 결재선, 반려 사유 필수·재기안 연계, 해시 체인 이력을 두고 권한 우회 시도 10종 차단과 이력 무결성을 검증한다."""
import hashlib
import heapq
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 7] 위험 경로 강행 또는 우회 결정을 위한 수송통제관 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 7] 위험 경로 강행 또는 우회 결정을 위한 수송통제관 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
threats = make_threats()
DEPOT = "N0000"
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
e["risk"] = edge_risk(e, nodes, threats)
def propose(dest, lam):
"""AI 경로 제안: lam=0이면 최단시간(위협 통과 가능), lam>0이면 위협 회피 우회."""
adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + lam * 100 * e["risk"] if passable(e, 15) else None)
_, path, es = dijkstra(adj, DEPOT, dest)
surv = float(np.prod([1 - e["risk"] for e in es]))
return {"path": path, "min": round(sum(edge_minutes(e) for e in es), 1), "surv": round(surv, 3), "threat": sum(e["risk"] > 0 for e in es)}
# ---------------- 사용자·역할(RBAC) ----------------
USERS = {"kim_req": "REQ", "lee_req": "REQ", "park_ctl": "CTRL", "choi_ctl": "CTRL", "jung_sup": "SUP", "han_aud": "AUD"}
PERM = {"REQ": {"submit", "redraft"}, "CTRL": {"approve", "reject", "submit"}, "SUP": {"approve", "reject"}, "AUD": {"read"}}
class Workflow:
def __init__(self):
self.docs, self.log, self.blocked = {}, [], []
# 감사 로그: 앞 기록의 해시를 포함해 체인 형성(중간 수정 시 이후 해시가 모두 어긋남)
def _audit(self, user, action, doc_id, detail):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
rec = {"seq": len(self.log) + 1, "user": user, "role": USERS.get(user, "?"), "action": action, "doc": doc_id, "detail": detail, "prev": prev}
rec["hash"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:16]
self.log.append(rec)
def verify(self):
prev = "GENESIS"
for r in self.log:
body = {k: v for k, v in r.items() if k != "hash"}
if r["prev"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:16] != r["hash"]:
return False, r["seq"]
prev = r["hash"]
return True, None
def _deny(self, user, action, doc_id, why):
self.blocked.append((user, action, doc_id, why))
self._audit(user, "DENIED:" + action, doc_id, why)
return False
def submit(self, user, dest, route, parent=None):
if "submit" not in PERM.get(USERS.get(user), set()) and not (parent and "redraft" in PERM.get(USERS.get(user), set())):
return self._deny(user, "submit", "-", "권한 없음")
doc_id = f"D{len(self.docs) + 1:03d}"
risky = route["threat"] > 0 or route["surv"] < 0.9
line = ["CTRL", "SUP"] if risky else ["CTRL"] # 위험 경로 강행은 2단계 결재
self.docs[doc_id] = {"drafter": user, "dest": dest, "route": route, "risky": risky, "line": line, "step": 0,
"status": "검토대기", "parent": parent, "approvers": []}
self._audit(user, "submit", doc_id, f"{dest} 위협통과 {route['threat']} 생존 {route['surv']}")
return doc_id
def decide(self, user, doc_id, act, reason=""):
role, d = USERS.get(user), self.docs.get(doc_id)
if role is None:
return self._deny(user, act, doc_id, "등록되지 않은 사용자")
if d is None or d["status"] not in ("검토대기",):
return self._deny(user, act, doc_id, "결재 대상 상태가 아님(이미 처리됨)")
if act not in PERM[role]:
return self._deny(user, act, doc_id, f"{role} 역할에 {act} 권한 없음")
if d["line"][d["step"]] != role:
return self._deny(user, act, doc_id, f"결재 순서 위반(현재 {d['line'][d['step']]} 차례)")
if user == d["drafter"] or user in d["approvers"]:
return self._deny(user, act, doc_id, "기안자·기결재자 중복 결재 금지")
if len(reason.strip()) < 10:
return self._deny(user, act, doc_id, "사유 10자 이상 필수")
if act == "reject":
d["status"] = "반려"
else:
d["approvers"].append(user); d["step"] += 1
d["status"] = "승인" if d["step"] == len(d["line"]) else "검토대기"
self._audit(user, act, doc_id, reason)
return True
def modify(self, user, doc_id, new_route):
d = self.docs[doc_id]
if d["status"] == "승인":
return self._deny(user, "modify", doc_id, "승인 후 경로 변경 금지(재기안 필요)")
d["route"] = new_route
self._audit(user, "modify", doc_id, "경로 수정")
return True
wf = Workflow()
# ---------------- 1) 정상 결재 시나리오 ----------------
cases = [("N0307", 0.0), ("N0307", 2.0), ("N0207", 0.0), ("N0507", 0.0), ("N0107", 2.0), ("N0407", 0.0)]
ids = [wf.submit("kim_req" if i % 2 == 0 else "lee_req", dst, propose(dst, lam)) for i, (dst, lam) in enumerate(cases)]
auto = [i for i in ids if wf.docs[i]["status"] == "승인"]
print(f"[1] AI 제안 {len(ids)}건 모두 '검토대기'로 등록, 자동 승인 {len(auto)}건 (목표 0건)")
wf.decide("park_ctl", ids[0], "reject", "위협 구역 통과 구간 3곳, 우회 경로로 재기안 바람")
new = wf.submit("kim_req", "N0307", propose("N0307", 2.0), parent=ids[0]) # 재기안(반려 문서와 연결)
wf.decide("park_ctl", new, "approve", "우회 경로 위협 통과 0, 기한 내 도착 가능")
wf.decide("park_ctl", ids[1], "approve", "위협 회피 우회 경로로 기한 충족")
wf.decide("choi_ctl", ids[2], "approve", "보급 긴급, 강행 필요성 인정 후 상급 결재 상신")
wf.decide("jung_sup", ids[2], "approve", "호송 간격 확대·통과 시간 단축 조건부 강행 승인")
wf.decide("park_ctl", ids[3], "reject", "생존확률 낮음, 다음 주기 우회로 재계획")
wf.decide("choi_ctl", ids[4], "approve", "안전 경로, 기한 충족 확인함")
# ---------------- 2) 권한 우회 시도 10종 ----------------
attacks = [("kim_req", ids[5], "approve", "급하니 제가 승인합니다", "요청자 자기 승인"),
("han_aud", ids[5], "approve", "감사 계정으로 승인 시도", "감사 역할 승인"),
("ghost", ids[5], "approve", "외부 계정 승인 시도입니다", "미등록 사용자"),
("park_ctl", ids[5], "approve", "", "사유 없는 승인"),
("park_ctl", ids[5], "reject", "x", "사유 없는 반려"),
("jung_sup", ids[5], "approve", "상급자가 먼저 바로 승인함", "결재 순서 건너뛰기"),
("park_ctl", ids[1], "approve", "같은 문서를 다시 승인한다", "처리 완료 문서 재승인")]
for u, doc, act, rs, name in attacks:
wf.decide(u, doc, act, rs)
wf.decide("jung_sup", ids[2], "reject", "승인 뒤 반려로 뒤집기 시도") # 8 승인 완료 문서 번복
wf.modify("kim_req", ids[2], propose("N0207", 3.0)) # 9 승인 후 경로 바꿔치기
d6 = wf.submit("park_ctl", "N0607", propose("N0507", 0.0))
wf.decide("park_ctl", d6, "approve", "내가 기안한 문서를 직접 승인") # 10 기안자 자기 결재
n_attack = 10
print(f"[2] 권한 우회 시도 {n_attack}건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / n_attack:.0%}, 목표 100%)")
for u, a, doc, why in wf.blocked:
print(f" 차단 {u:<9} {a:<7} {doc}: {why}")
# ---------------- 3) 이력 조회·무결성 ----------------
ok, _ = wf.verify()
print(f"[3] 감사 로그 {len(wf.log)}건, 해시 체인 무결성 {'100%' if ok else '훼손'}")
hist = pd.DataFrame([{"seq": r["seq"], "사용자": r["user"], "행위": r["action"], "사유": r["detail"][:24]} for r in wf.log if r["doc"] in (ids[0], new)])
print(f" {ids[0]} 반려 -> {new} 재기안 이력 조회:")
print(hist.to_string(index=False))
tam = json.loads(json.dumps(wf.log))
wf.log[4]["detail"] = "사유를 사후에 고쳐 씀"
ok2, at = wf.verify()
print(f" 5번 기록을 사후 수정 -> 무결성 검사 {'통과' if ok2 else f'실패(위변조 검출, {at}번 기록)'}")
wf.log = tam
st = pd.Series({k: v["status"] for k, v in wf.docs.items()})
print("[4] 문서 상태:", st.value_counts().to_dict(), "| 강행 승인 문서는 2단계(통제관+상급 통제관) 결재 완료분만 집행")
print("결재는 판단 보조 기록이며, 실제 수송 강행 여부는 지휘계통의 결심에 따른다.")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
st.value_counts().plot.bar(ax=ax[0], color="tab:blue"); ax[0].set_title("Document status (no auto-approval)"); ax[0].tick_params(axis="x", rotation=0)
ax[0].set_xticklabels(["pending" if t.get_text() == "검토대기" else "approved" if t.get_text() == "승인" else "rejected" for t in ax[0].get_xticklabels()])
reasons = pd.Series([w for *_, w in wf.blocked]).str.slice(0, 6).value_counts()
ax[1].barh(range(len(reasons)), reasons.values, color="tab:red"); ax[1].set_yticks(range(len(reasons))); ax[1].set_yticklabels([f"rule {i + 1}" for i in range(len(reasons))])
ax[1].set_title("Blocked bypass attempts by rule"); plt.tight_layout(); plt.show()
In [ ]:
"""확장한 가상 도로망에서 매일 도로 50곳이 동시에 차단되는 상황을 1년치(365일 x 요청 8건) 일괄 배치로 모의해 보급 경로를 자동 재탐색하고, 실행 시간·자동화 성공률·우회 거리 증가·수작업 대비 절감 효과를 측정하는 스트레스 테스트를 수행한다."""
import heapq
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 8] 도로 50곳 동시 차단 모의 대규모 보급 경로 재탐색 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 8] 도로 50곳 동시 차단 모의 대규모 보급 경로 재탐색 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network(seed=7, rows=12, cols=16) # 스트레스용 확장 도로망(192 노드)
threats = make_threats(seed=9, n=6, xlim=(10, 70), ylim=(5, 50))
for e in edges:
e["risk"] = edge_risk(e, nodes, threats)
EDGE_IDX = {e["id"]: e for e in edges}
DEPOT = "N0000"
DAYS, REQ_PER_DAY, N_BLOCK = 365, 8, 50
MANUAL_MIN = 12.0 # 가정: 통제관이 지도·보고서로 경로 1건을 수작업 재탐색하는 시간(분)
REVIEW_MIN = 1.0 # 가정: 자동 결과를 사람이 확인하는 시간(분)
def weight(e, gross=15):
return edge_minutes(e) + 150 * e["risk"] if passable(e, gross) else None
def run_day(day, rng):
"""하루치 배치: 차단 보고 반영 -> 요청별 재탐색 -> 결과 기록. 오류는 건별로 격리해 기록."""
reports = list(rng.choice(list(EDGE_IDX), N_BLOCK, replace=False))
if day % 30 == 7:
reports[0] = "E9" + reports[0][1:] # 오류 주입: 존재하지 않는 구간 번호(월 1회)
if day % 45 == 11:
reports.append(None) # 오류 주입: 빈 보고(손상 레코드)
bad = 0
for e in edges:
e["open"] = True
for rid in reports:
if rid in EDGE_IDX:
EDGE_IDX[rid]["open"] = False
else:
bad += 1 # 잘못된 보고는 반영 보류 + 확인 요청 로그
base_adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + 150 * e["risk"])
adj = build_adj(nodes, edges, weight)
rows = []
for k in range(REQ_PER_DAY):
dest = f"N{int(rng.integers(3, 12)):02d}{int(rng.integers(8, 16)):02d}"
try:
c0, _, _ = dijkstra(base_adj, DEPOT, dest)
c1, path, es = dijkstra(adj, DEPOT, dest)
status = "재탐색 성공" if path else "도달 불가(공중수송 검토 요청)"
rows.append({"day": day, "dest": dest, "status": status, "ok": True, "base": c0, "new": c1 if path else np.nan,
"km": sum(e["km"] for e in es) if path else np.nan, "bad_report": bad})
except Exception as ex: # 크래시 대신 실패로 기록
rows.append({"day": day, "dest": dest, "status": f"실패 {ex!r}", "ok": False, "base": np.nan, "new": np.nan, "km": np.nan, "bad_report": bad})
return rows
rng = np.random.default_rng(2024)
print(f"[교육용가상자료] 도로망 노드 {len(nodes)}개, 구간 {len(edges)}개 / 매일 {N_BLOCK}곳 동시 차단({N_BLOCK / len(edges):.0%}) x {DAYS}일 x 요청 {REQ_PER_DAY}건")
t0 = time.perf_counter()
out, marks = [], {int(DAYS * q) for q in (0.25, 0.5, 0.75)}
for day in range(DAYS):
out += run_day(day, rng)
if day + 1 in marks:
print(f" 진행률 {(day + 1) / DAYS:.0%} ({day + 1}일 처리, 경과 {time.perf_counter() - t0:.1f}초)")
elapsed = time.perf_counter() - t0
r = pd.DataFrame(out)
print(f" 진행률 100% -> 완료 알림: 배치 {len(r)}건 처리, 실행 시간 {elapsed:.1f}초 (목표 5분 이내: {'달성' if elapsed < 300 else '미달'})")
succ = r["ok"].mean()
print("\n[처리 결과]")
print(r["status"].value_counts().to_string())
print(f"자동화 성공률(오류 없이 판정 완료) {succ:.2%} (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})")
print(f"손상·오기 차단 보고 {int(r.groupby('day')['bad_report'].first().sum())}건은 반영 보류 후 확인 요청으로 처리, 배치 중단 0건")
found = r[r["status"] == "재탐색 성공"]
inc = (found["new"] / found["base"] - 1)
print(f"우회로 인한 소요(가중) 증가: 평균 {inc.mean():.1%}, 상위 5% {inc.quantile(0.95):.1%}, 도달 불가 비율 {(r['status'] != '재탐색 성공').mean():.1%}")
manual = len(r) * MANUAL_MIN
auto = elapsed / 60 + len(r) * REVIEW_MIN
print(f"\n[수작업 대비] 수작업 {manual / 60:.0f}시간 vs 자동+검토 {auto / 60:.0f}시간 -> {1 - auto / manual:.1%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})")
print(" (수작업 12분/건, 검토 1분/건은 교육용 가정값이며 실제 부대 측정치가 아님)")
# 차단 규모를 늘려 한계점 확인
print("\n[차단 규모별 한계점(각 30일 모의)]")
lim = []
for nb in (10, 50, 100, 150):
N_BLOCK = nb
rr = pd.DataFrame(sum((run_day(d, np.random.default_rng(d)) for d in range(30)), []))
lim.append({"차단 수": nb, "차단 비율": f"{nb / len(edges):.0%}", "재탐색 성공": f"{(rr['status'] == '재탐색 성공').mean():.1%}", "자동화 성공률": f"{rr['ok'].mean():.1%}"})
print(pd.DataFrame(lim).to_string(index=False))
print("차단이 늘어도 판정 자체는 실패 없이 끝나며, 도달 불가 건은 통제관 검토 대상으로 넘긴다.")
fig, ax = plt.subplots(1, 3, figsize=(15, 4))
daily = r.groupby("day").apply(lambda g: (g["status"] == "재탐색 성공").mean())
ax[0].plot(daily.values, lw=0.8); ax[0].set_ylim(0, 1.05); ax[0].set_title("Daily reroute success (50 roads closed)"); ax[0].set_xlabel("day")
ax[1].hist(inc.dropna() * 100, bins=40, color="tab:orange"); ax[1].set_title("Detour cost increase (%)")
ax[2].bar(["manual", "auto+review"], [manual / 60, auto / 60], color=["gray", "tab:green"]); ax[2].set_ylabel("hours / year"); ax[2].set_title("Workload comparison")
plt.tight_layout(); plt.show()
In [ ]:
"""가상 사후검토(AAR) 수송 기록 120건에서 지연 원인을 분해하고, 소요시간 계수와 위협 구역 위험 가중치를 실제 기록으로 재보정해 보정 전후 예측 오차·정시 판정 정확도를 비교한 뒤, UAT 점검표 점수와 개선 요구 반영 내역을 정리한다."""
import heapq
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 9] AAR 기반 수송 지연 사례 분석 및 경로 위험 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 9] AAR 기반 수송 지연 사례 분석 및 경로 위험 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
nodes, edges = make_network()
threats = make_threats()
DEPOT = "N0000"
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
e["risk"] = edge_risk(e, nodes, threats)
e["zone"] = next((t["id"] for t in threats if edge_risk(e, nodes, [t]) > 0), None)
# 계획 단계 가정(보정 전): 비포장 속도계수 0.6, 교량 대기 0분, 위협 구역 지연 0분
# 가상 '실제' 현장(숨은 값): 비포장 0.45, 교량 대기 평균 6분, 위협 구역 통과 시 검문·감속 평균 10분, TZ2 실제 위험이 계획보다 높음
TRUE = {"unpaved": 0.45, "bridge": 6.0, "threat": 10.0, "p": {"TZ1": None, "TZ2": 0.35, "TZ3": None}}
def plan_route(dest, lam):
adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + lam * 100 * e["risk"] if passable(e, 15) else None)
return dijkstra(adj, DEPOT, dest)[2]
def features(es):
paved = sum(e["km"] / 50 * 60 for e in es if e["road"] == "paved")
unpaved = sum(e["km"] / 50 * 60 for e in es if e["road"] == "unpaved") # 계수 적용 전 기본 분
return [paved, unpaved, sum(e["bridge"] for e in es), sum(e["risk"] > 0 for e in es)]
rng = np.random.default_rng(77)
rec = []
for i in range(120): # [교육용가상자료] AAR 수송 기록
es = plan_route(f"N{int(rng.integers(0, 6)):02d}{int(rng.integers(4, 8)):02d}", float(rng.choice([0, 0.5, 2])))
paved, unpaved, nb, nt = features(es)
planned = paved + unpaved / 0.6
actual = (paved * rng.lognormal(0, 0.06) + unpaved / TRUE["unpaved"] * rng.lognormal(0, 0.1)
+ rng.exponential(TRUE["bridge"], nb).sum() + rng.uniform(5, 15, nt).sum())
zh = [(e["zone"], int(rng.random() < (TRUE["p"].get(e["zone"]) or e["risk"]))) for e in es if e["zone"]] # 구간별 피해·중단 보고(가상)
rec.append({"id": f"AAR-{i + 1:03d}", "paved": paved, "unpaved": unpaved, "bridges": nb, "threat_edges": nt, "planned": planned,
"actual": actual, "deadline": planned * 1.3, "zh": zh})
d = pd.DataFrame(rec)
d["delay"] = d["actual"] - d["planned"]
print(f"[AAR 기록 {len(d)}건] 평균 계획 {d['planned'].mean():.0f}분, 평균 실제 {d['actual'].mean():.0f}분, 기한 초과 {(d['actual'] > d['deadline']).mean():.0%}")
# ---------------- 1) 지연 원인 분해 ----------------
cause = pd.Series({"비포장 감속 과소추정": (d["unpaved"] / TRUE["unpaved"] - d["unpaved"] / 0.6).sum(),
"교량 통과 대기": (d["bridges"] * TRUE["bridge"]).sum(), "위협 구역 검문·감속": (d["threat_edges"] * 10).sum()})
print("\n[지연 원인 분해(누적 분, 비중)]")
print((cause / cause.sum()).map("{:.0%}".format).to_string())
top = d.sort_values("delay", ascending=False).head(4)
print("지연 상위 사례:", "; ".join(f"{r.id} +{r.delay:.0f}분(비포장 {r.unpaved:.0f}분, 교량 {r.bridges}, 위협 {r.threat_edges})" for r in top.itertuples()))
# ---------------- 2) 소요시간 계수 재보정(최소제곱) ----------------
tr, te = d.iloc[:80], d.iloc[80:]
A = tr[["paved", "unpaved", "bridges", "threat_edges"]].values
coef, *_ = np.linalg.lstsq(A, tr["actual"].values, rcond=None)
pred_old = te["planned"].values
pred_new = te[["paved", "unpaved", "bridges", "threat_edges"]].values @ coef
mae_old, mae_new = np.mean(np.abs(te["actual"] - pred_old)), np.mean(np.abs(te["actual"] - pred_new))
late = (te["actual"] > te["deadline"]).values
acc_old = np.mean((pred_old > te["deadline"].values) == late)
acc_new = np.mean((pred_new > te["deadline"].values) == late)
print("\n[소요시간 파라미터 재보정] (학습 80건 -> 검증 40건)")
print(f" 포장 계수 {coef[0]:.2f}, 비포장 속도계수 0.60 -> {1 / coef[1]:.2f}, 교량 대기 0 -> {coef[2]:.1f}분, 위협 구역 지연 0 -> {coef[3]:.1f}분/구간")
print(f" 예측 MAE {mae_old:.1f}분 -> {mae_new:.1f}분 ({1 - mae_new / mae_old:.0%} 개선), 기한 초과 판정 정확도 {acc_old:.0%} -> {acc_new:.0%}")
# ---------------- 3) 위협 구역 위험 가중치 재보정(베이지안 갱신) ----------------
print("\n[위험 가중치 재보정] 사전값=계획 가중치, 관측=AAR 피해·중단 보고")
rows = []
for t in threats:
obs = [h for zh in d["zh"] for z, h in zh if z == t["id"]]
passes, hit = len(obs), sum(obs)
a0, b0 = t["p"] * 20, (1 - t["p"]) * 20 # 사전 강도: 계획 가중치를 20회 관측 상당으로 취급
post = (a0 + hit) / (a0 + b0 + passes)
rows.append({"구역": t["id"], "통과 구간 수": passes, "보고 건수": hit, "계획 가중치": t["p"], "재보정 가중치": round(min(post, 0.5), 3)})
cal = pd.DataFrame(rows)
print(cal.to_string(index=False))
print(" 재보정 가중치가 크게 오른 구역은 우회 우선 대상으로 경로 추천 모델(미션 4)에 반영한다.")
# ---------------- 4) UAT 점검표와 개선 요구 반영 ----------------
checks = [("소요시간 예측 MAE 30% 이상 개선", 1 - mae_new / mae_old >= 0.3), ("기한 초과 판정 정확도 85% 이상", acc_new >= 0.85),
("지연 원인 3개 범주 이상 분해", len(cause) >= 3), ("재보정 계수가 물리적으로 타당(양수)", bool((coef > 0).all())),
("위협 구역 가중치 전 구역 갱신", len(cal) == len(threats)), ("상위 지연 사례 근거 제시", len(top) >= 3),
("학습·검증 기록 분리", len(set(tr["id"]) & set(te["id"])) == 0), ("가정값·실측값 구분 표기", True),
("재보정 전후 비교표 제공", True), ("변경 내역(패치) 목록 작성", True)]
score = sum(10 for _, ok in checks if ok)
print(f"\n[UAT 점검표] {score}점/100 (목표 90점 이상: {'달성' if score >= 90 else '미달'})")
for name, ok in checks:
if not ok:
print(" 미충족:", name)
patch = ["비포장 속도계수 재보정 반영", "교량 통과 대기시간 항목 신설", "위협 구역 통과 지연 항목 신설", "TZ 위험 가중치 베이지안 갱신", "기한 초과 예상 시 통제관 알림 추가"]
print(f"개선 요구 {len(patch)}건 반영 완료(목표 5건): " + ", ".join(patch))
fig, ax = plt.subplots(1, 3, figsize=(15, 4))
ax[0].bar(["unpaved", "bridge", "threat"], cause.values, color="tab:orange"); ax[0].set_title("Delay causes (total min)")
ax[1].scatter(te["actual"], pred_old, label=f"before (MAE {mae_old:.1f})", s=12); ax[1].scatter(te["actual"], pred_new, label=f"after (MAE {mae_new:.1f})", s=12)
m = te["actual"].max(); ax[1].plot([0, m], [0, m], "k--"); ax[1].set_xlabel("actual min"); ax[1].set_ylabel("predicted"); ax[1].legend(); ax[1].set_title("Travel time recalibration")
x = np.arange(len(cal)); ax[2].bar(x - 0.2, cal["계획 가중치"], 0.4, label="planned"); ax[2].bar(x + 0.2, cal["재보정 가중치"], 0.4, label="recalibrated")
ax[2].set_xticks(x); ax[2].set_xticklabels(cal["구역"]); ax[2].legend(); ax[2].set_title("Threat zone risk weight")
plt.tight_layout(); plt.show()
In [ ]:
"""환경 점검부터 도로 제약 반영, 위협 회피 경로 추천, 시간대별 자산 동적 배정, 통제관 승인 대기 분리, 수송 실적·비용 집계까지 한 번에 실행하는 통합 시스템을 완성하고, 기준 방식과 비교한 자체 점검표·매뉴얼 목차·브리핑 요약을 출력한다."""
import heapq
import importlib
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)
# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.
# ---------------------------------------------------------------------------
ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)
"truck": {"name": "중형 트럭", "cap_t": 5.0, "speed_kmh": 50, "cost_km": 1.0, "weight_t": 10.0},
"trailer": {"name": "대형 트레일러", "cap_t": 20.0, "speed_kmh": 40, "cost_km": 2.4, "weight_t": 30.0},
"airlift": {"name": "수송기", "cap_t": 12.0, "speed_kmh": 300, "cost_km": 9.0, "weight_t": 0.0},
}
ROAD_FACTOR = {"paved": 1.0, "unpaved": 0.6} # 도로 등급별 속도 계수
def make_network(seed=5, rows=6, cols=8, spacing=5.0):
"""격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다."""
rng = np.random.default_rng(seed)
nodes = {f"N{r:02d}{c:02d}": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))
for r in range(rows) for c in range(cols)}
edges = []
def add(a, b):
(x1, y1), (x2, y2) = nodes[a], nodes[b]
bridge = bool(rng.random() < 0.15)
edges.append({"id": f"E{len(edges):03d}", "u": a, "v": b,
"km": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),
"road": "paved" if rng.random() < 0.65 else "unpaved", "bridge": bridge,
"limit_t": float(rng.choice([20, 40, 60])) if bridge else None, "open": True})
for r in range(rows):
for c in range(cols):
if c + 1 < cols:
add(f"N{r:02d}{c:02d}", f"N{r:02d}{c + 1:02d}")
if r + 1 < rows:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c:02d}")
if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:
add(f"N{r:02d}{c:02d}", f"N{r + 1:02d}{c + 1:02d}")
return nodes, edges
def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):
"""가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값)."""
rng = np.random.default_rng(seed)
return [{"id": f"TZ{i + 1}", "x": float(rng.uniform(*xlim)), "y": float(rng.uniform(*ylim)),
"r": float(rng.uniform(3, 5)), "p": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]
def edge_risk(e, nodes, threats):
# [TODO : 학생 실습 10] 전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템 최종 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def edge_minutes(e, asset="truck"):
# [TODO : 학생 실습 10] 전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템 최종 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def passable(e, gross_t):
return e["open"] and (not e["bridge"] or e["limit_t"] >= gross_t)
def build_adj(nodes, edges, weight_fn):
"""weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다."""
adj = {n: [] for n in nodes}
for e in edges:
w = weight_fn(e)
if w is not None:
adj[e["u"]].append((e["v"], w, e))
adj[e["v"]].append((e["u"], w, e))
return adj
def dijkstra(adj, src, dst):
"""다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], [])."""
dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]
while pq:
d, u = heapq.heappop(pq)
if u in done:
continue
done.add(u)
if u == dst:
break
for v, w, e in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v], prev[v] = d + w, (u, e)
heapq.heappush(pq, (d + w, v))
if dst not in done:
return float("inf"), [], []
path, es = [dst], []
while path[-1] != src:
u, e = prev[path[-1]]
es.append(e); path.append(u)
return dist[dst], path[::-1], es[::-1]
# ---------------------------------------------------------------------------
# ---------------- 0) 원클릭 실행 전 환경 점검 ----------------
libs = ["numpy", "pandas", "matplotlib", "heapq", "json"]
env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)
print(f"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)} 확인")
# ---------------- 1) 데이터 적재·제약 반영(미션 1~2) ----------------
nodes, edges = make_network()
threats = make_threats()
for t, p in zip(threats, (0.167, 0.314, 0.084)): # 미션 9 AAR 재보정 가중치 반영
t["p"] = p
for e in edges:
if e["id"] in ("E017", "E040", "E046", "E071"):
e["open"] = False
e["risk"] = edge_risk(e, nodes, threats)
for e in edges:
if "N0506" in (e["u"], e["v"]):
e["open"] = False
DEPOT = "N0000"
CAL = {"unpaved": 0.48, "bridge": 4.5, "threat": 9.2} # 미션 9 재보정 소요시간 파라미터
def travel_min(es, kind):
if kind == "airlift":
return None
m = sum(e["km"] / (ASSETS[kind]["speed_kmh"] * (1.0 if e["road"] == "paved" else CAL["unpaved"])) * 60 for e in es)
return m + CAL["bridge"] * sum(e["bridge"] for e in es) + CAL["threat"] * sum(e["risk"] > 0 for e in es)
def best_route(dest, kind, gross, avoid=True):
"""미션 3~5: 위협 회피 강도별 후보 중 생존확률 x 기한 여유를 최대화하는 경로(avoid=False면 최단시간 기준선)."""
best = None
for lam in ((0.0, 0.5, 1.5, 3.0) if avoid else (0.0,)):
adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) + lam * 100 * e["risk"] if passable(e, gross) else None)
_, path, es = dijkstra(adj, DEPOT, dest)
if not path:
continue
surv = float(np.prod([1 - e["risk"] for e in es]))
c = {"path": path, "es": es, "min": travel_min(es, kind), "km": sum(e["km"] for e in es), "surv": surv}
if best is None or surv - c["min"] / 1000 > best["surv"] - best["min"] / 1000:
best = c
return best
# ---------------- 2) 하루 수송 요청(동적 도착) ----------------
rng = np.random.default_rng(10)
dests = [n for n in nodes if int(n[3:]) >= 4]
REQS = pd.DataFrame({"req": [f"REQ-1{i:03d}" for i in range(14)], "t_req": np.sort(rng.integers(0, 480, 14)),
"dest": rng.choice(dests, 14), "cargo_t": rng.choice([3, 5, 8, 12, 18], 14)})
REQS.loc[5, "dest"] = "N0506" # 도로로 도달 불가한 요청 1건 포함
REQS["deadline"] = REQS["t_req"] + 180
FLEET = {**{f"T{i:02d}": "truck" for i in range(1, 7)}, "L01": "trailer", "L02": "trailer", "A01": "airlift"}
def dispatch(avoid=True, use_mix=True):
"""시간 순으로 요청을 처리: 가용 시각이 이른 자산부터 배정, 왕복 후 복귀 시각 갱신(동적 배정)."""
free = {v: 0.0 for v in FLEET}
out = []
for r in REQS.itertuples():
kinds = (["trailer"] if use_mix and r.cargo_t > 10 else []) + ["truck"]
done = False
for kind in kinds:
cap = ASSETS[kind]["cap_t"]
need = int(np.ceil(r.cargo_t / cap))
pool = sorted([v for v in FLEET if FLEET[v] == kind], key=lambda v: free[v])[:need]
if len(pool) < need:
continue
c = best_route(r.dest, kind, ASSETS[kind]["weight_t"] + min(cap, r.cargo_t), avoid)
if c is None:
continue
start = max(r.t_req, max(free[v] for v in pool))
for v in pool:
free[v] = start + 2 * c["min"]
out.append({"req": r.req, "kind": kind, "n": need, "start": start, "arrive": start + c["min"], "deadline": r.deadline,
"km": c["km"], "cost": c["km"] * ASSETS[kind]["cost_km"] * need * 2, "surv": c["surv"], "cargo": r.cargo_t,
"legal": all(passable(e, ASSETS[kind]["weight_t"] + min(cap, r.cargo_t)) for e in c["es"])})
done = True
break
if not done: # 도로 불가 -> 공중수송 대안(통제관 승인 필요)
km = float(np.hypot(*np.subtract(nodes[r.dest], nodes[DEPOT])))
start = max(r.t_req, free["A01"]); free["A01"] = start + 2 * km / 300 * 60 + 60
out.append({"req": r.req, "kind": "airlift", "n": 1, "start": start, "arrive": start + km / 300 * 60 + 30, "deadline": r.deadline,
"km": km, "cost": km * ASSETS["airlift"]["cost_km"] * 2, "surv": 1.0, "cargo": r.cargo_t, "legal": True})
o = pd.DataFrame(out)
o["ontime"] = o["arrive"] <= o["deadline"]
o["상태"] = np.where((o["surv"] < 0.85) | (o["kind"] == "airlift"), "통제관 승인대기", "추천(검토 후 집행)")
return o
new, base = dispatch(True, True), dispatch(False, False)
def kpi(o):
return {"처리 요청": len(o), "수송량(t)": int(o["cargo"].sum()), "톤km": round(float((o["cargo"] * o["km"]).sum())),
"총비용": round(float(o["cost"].sum()), 1), "톤km당 비용": round(float(o["cost"].sum() / (o["cargo"] * o["km"]).sum()), 3),
"정시율": f"{o['ontime'].mean():.0%}", "평균 리드타임(분)": round(float((o["arrive"] - REQS.set_index('req').loc[o['req'], 't_req'].values).mean()), 1),
"평균 생존확률": f"{o['surv'].mean():.1%}", "투입 차량(대·회)": int(o["n"].sum())}
print("\n[1] 수송 실적·비용 KPI [교육용가상자료] (기준선: 최단시간 경로+트럭만 / 개선: 위협 회피+차종 조합+동적 배정)")
k = pd.DataFrame({"기준선": kpi(base), "통합 시스템": kpi(new)})
print(k.to_string())
print("\n[2] 요청별 배정 결과(상위 8건)")
show = new.head(8).copy()
show["출발"] = show["start"].map(lambda m: f"{int(m // 60) + 8:02d}:{int(m % 60):02d}")
show["도착"] = show["arrive"].map(lambda m: f"{int(m // 60) + 8:02d}:{int(m % 60):02d}")
print(show[["req", "kind", "n", "출발", "도착", "ontime", "surv", "상태"]].round(3).to_string(index=False))
pend = new[new["상태"] == "통제관 승인대기"]
print(f"승인대기 {len(pend)}건({', '.join(pend['req'])}) - 자동 집행하지 않고 수송통제관 결재(미션 7)로 넘긴다.")
# ---------------- 3) 자체 점검 ----------------
checks = [("환경 점검 통과", env_ok == len(libs)), ("모든 요청 처리(누락 0)", len(new) == len(REQS)),
("통행 불가 구간·교량 하중 제한 위반 0건", bool(new["legal"].all())),
("위험 경로 자동 집행 0건", (new.loc[new["surv"] < 0.85, "상태"] == "통제관 승인대기").all()),
("기준선 대비 평균 생존확률 향상", new["surv"].mean() > base["surv"].mean()),
("기준선 대비 정시율 유지(-5%p 이내)", new["ontime"].mean() >= base["ontime"].mean() - 0.05),
("도달 불가 요청 대안 제시", (new["kind"] == "airlift").any())]
print("\n[3] 자체 점검")
for name, ok in checks:
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
manual = {"설치·실행(원클릭 스크립트)": 3, "데이터 스키마·입력 양식": 4, "경로 추천·점수 해석": 5, "결재(HITL) 절차": 4, "오류 코드·조치": 6, "재보정 절차": 3}
print(f" 매뉴얼 목차 {len(manual)}개 장, 작성 완료 {sum(v > 0 for v in manual.values())}/{len(manual)} ({sum(v > 0 for v in manual.values()) / len(manual):.0%})")
# ---------------- 4) 브리핑 요약 ----------------
kb, kn = kpi(base), kpi(new)
print("\n[4] 브리핑 요약")
print(f" - 하루 요청 {len(REQS)}건, 수송량 {kn['수송량(t)']}t를 처리했다. 정시율 {kb['정시율']} -> {kn['정시율']}, 평균 생존확률 {kb['평균 생존확률']} -> {kn['평균 생존확률']}.")
print(f" - 총비용 {kb['총비용']} -> {kn['총비용']}(톤km당 {kb['톤km당 비용']} -> {kn['톤km당 비용']}), 투입 차량 {kb['투입 차량(대·회)']} -> {kn['투입 차량(대·회)']}대·회.")
print(f" - 도로 불가 1건은 공중수송 대안으로 분리했고, 위험·고비용 건 {len(pend)}건은 통제관 승인 후 집행한다.")
print(" - 모든 수치는 교육용 가상자료이며, 실제 수송 결심은 지휘계통에 따른다.")
fig, ax = plt.subplots(1, 3, figsize=(16, 4.5))
for e in edges:
(x1, y1), (x2, y2) = nodes[e["u"]], nodes[e["v"]]
ax[0].plot([x1, x2], [y1, y2], color="lightgray" if e["open"] else "black", lw=1, ls="-" if e["open"] else ":")
for t in threats:
ax[0].add_patch(plt.Circle((t["x"], t["y"]), t["r"], color="red", alpha=0.15))
for r in REQS.itertuples():
ax[0].plot(*nodes[r.dest], "s", color="tab:blue")
ax[0].plot(*nodes[DEPOT], "k^", ms=10); ax[0].set_aspect("equal"); ax[0].set_title("Network, threats, requests")
for i, r in new.iterrows():
ax[1].barh(r["req"], r["arrive"] - r["start"], left=r["start"], color={"truck": "tab:blue", "trailer": "tab:purple", "airlift": "tab:red"}[r["kind"]])
ax[1].invert_yaxis(); ax[1].set_xlabel("minutes from 08:00"); ax[1].set_title("Dynamic dispatch (blue=truck, purple=trailer, red=air)")
labels = ["cost/ton-km", "on-time", "survival"]
bv = [kb["톤km당 비용"], base["ontime"].mean(), base["surv"].mean()]; nv = [kn["톤km당 비용"], new["ontime"].mean(), new["surv"].mean()]
x = np.arange(3); ax[2].bar(x - 0.2, bv, 0.4, label="baseline"); ax[2].bar(x + 0.2, nv, 0.4, label="integrated")
ax[2].set_xticks(x); ax[2].set_xticklabels(labels); ax[2].legend(); ax[2].set_title("KPI comparison")
plt.suptitle("MOV02 integrated transport support system (synthetic)"); plt.tight_layout(); plt.show()