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

3200 lines
209 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [MOV02] 수송지원 실적·비용 가시화 Tool\n",
"### Google Colab 실습 환경 (수송·물류·창고 / Level 2)\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/MOV02/MOV02_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 수송·물류·창고 (Level 2) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"NATO 동맹전환사령부(ACT)는 다국적 군수 지원에서 수송 요청부터 도착까지의 시간, 처리 기간, 물동량과 수송능력, 지연 원인을 한 화면에서 보는 물류·수송 성과 가시화를 강조해 왔다. 본 프로젝트는 이 공개 사례를 바탕으로 교육용 가상 도로망과 수송 자산 자료를 코드로 만들어, 수송지원 실적과 비용을 근거로 경로와 자산을 결정하는 과정을 구현한다. 학습자는 자산·도로망 스키마 정의, 도로 파괴·통제 구역 전처리, 다익스트라 Baseline, 위협 회피·적재 효율 추천, 생존확률·정시도착 신뢰도 점수, 관제 대시보드, 수송통제관 승인(HITL), 도로 50곳 차단 스트레스 시험, AAR 재보정, 최종 통합을 차례로 수행한다. 최종 목표는 수송 리드타임과 톤km당 비용을 낮추면서 위험 경로의 강행 여부는 반드시 사람이 결정하도록 하는 수송지원 실적·비용 가시화 도구를 완성하는 것이다.\n",
"\n",
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
"\n",
"---\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ============================================================================\n",
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
"# ============================================================================\n",
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
"\n",
"import os\n",
"import sys\n",
"import time\n",
"import math\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"import warnings\n",
"warnings.filterwarnings('ignore')\n",
"\n",
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
"plt.rcParams['axes.unicode_minus'] = False\n",
"\n",
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 01. 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의\n",
"**소요시간**: 약 1.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"전장 수송 자산(트럭, 트레일러) 제원 및 도로망 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **트럭·트레일러·수송기의 교육용 가정 제원과 도로망(노드·간선·교량 하중) 데이터 스키마를 정의하고, 수송지원 실적·비용 보고 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **자산(적재량·속도·km당 비용), 노드, 간선(거리·도로 등급·교량 하중·통행 여부), 수송 요청의 필드와 허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/mov02_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n",
"\n",
"\n",
"- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n",
"\n",
"\n",
"- 검증 방법: 실무 프로세스와의 일치성 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"전장 수송 자산(트럭·트레일러·수송기) 제원과 도로망(노드·간선) 데이터 스키마를 정의해 가상 샘플을 검증하고, 수송지원 실적·비용 보고 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n",
"import copy\n",
"import heapq\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 1] 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 1] 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# 1) 스키마 정의: 수송 자산 / 도로망 노드 / 도로 구간(간선) / 수송 요청\n",
"SCHEMA = {\n",
" \"asset\": {\"asset_id\": {\"type\": str, \"pattern\": r\"^(TRK|TRL|AIR)-\\d{3}$\"},\n",
" \"kind\": {\"type\": str, \"enum\": list(ASSETS)},\n",
" \"cap_t\": {\"type\": (int, float), \"min\": 0.5, \"max\": 50},\n",
" \"speed_kmh\": {\"type\": (int, float), \"min\": 5, \"max\": 900},\n",
" \"cost_km\": {\"type\": (int, float), \"min\": 0},\n",
" \"status\": {\"type\": str, \"enum\": [\"가용\", \"정비\", \"운행중\"]}},\n",
" \"node\": {\"node_id\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n",
" \"x_km\": {\"type\": (int, float), \"min\": -100, \"max\": 100},\n",
" \"y_km\": {\"type\": (int, float), \"min\": -100, \"max\": 100}},\n",
" \"edge\": {\"id\": {\"type\": str, \"pattern\": r\"^E\\d{3}$\"},\n",
" \"u\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"}, \"v\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n",
" \"km\": {\"type\": (int, float), \"min\": 0.1, \"max\": 200},\n",
" \"road\": {\"type\": str, \"enum\": list(ROAD_FACTOR)},\n",
" \"bridge\": {\"type\": bool}, \"open\": {\"type\": bool}},\n",
" \"request\": {\"req_id\": {\"type\": str, \"pattern\": r\"^REQ-\\d{4}$\"},\n",
" \"dest\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n",
" \"cargo_t\": {\"type\": (int, float), \"min\": 0.1, \"max\": 200},\n",
" \"deadline_h\": {\"type\": (int, float), \"min\": 0.5, \"max\": 72}},\n",
"}\n",
"\n",
"\n",
"def check(name, value, rule):\n",
" ok_type = isinstance(value, rule[\"type\"]) and (rule[\"type\"] is bool or not isinstance(value, bool))\n",
" if not ok_type:\n",
" return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n",
" errs = []\n",
" if \"enum\" in rule and value not in rule[\"enum\"]:\n",
" errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n",
" if \"min\" in rule and value < rule[\"min\"]:\n",
" errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n",
" if \"max\" in rule and value > rule[\"max\"]:\n",
" errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n",
" if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n",
" errs.append(f\"{name}: 형식 불일치 '{value}'\")\n",
" return errs\n",
"\n",
"\n",
"def validate(group, rec, node_ids=None):\n",
" errs = []\n",
" for k, rule in SCHEMA[group].items():\n",
" errs += [f\"{group}.{k}: 필수 필드 누락\"] if k not in rec else check(f\"{group}.{k}\", rec[k], rule)\n",
" if group == \"edge\" and node_ids is not None: # 참조 무결성: 없는 노드를 잇는 간선\n",
" errs += [f\"edge.{k}: 존재하지 않는 노드 {rec[k]}\" for k in (\"u\", \"v\") if rec.get(k) not in node_ids]\n",
" if group == \"edge\" and rec.get(\"bridge\") and not rec.get(\"limit_t\"):\n",
" errs.append(\"edge.limit_t: 교량 하중 제한 누락\")\n",
" return errs\n",
"\n",
"\n",
"# 2) [교육용가상자료] 샘플 생성: 자산 12대, 도로망 48노드, 수송 요청 6건\n",
"nodes, edges = make_network()\n",
"rng = np.random.default_rng(1)\n",
"prefix = {\"truck\": \"TRK\", \"trailer\": \"TRL\", \"airlift\": \"AIR\"}\n",
"assets = [{\"asset_id\": f\"{prefix[k]}-{i:03d}\", \"kind\": k, **{f: ASSETS[k][f] for f in (\"cap_t\", \"speed_kmh\", \"cost_km\")},\n",
" \"status\": str(rng.choice([\"가용\", \"정비\", \"운행중\"], p=[0.7, 0.15, 0.15]))}\n",
" for i, k in enumerate([\"truck\"] * 7 + [\"trailer\"] * 3 + [\"airlift\"] * 2)]\n",
"node_recs = [{\"node_id\": n, \"x_km\": round(x, 2), \"y_km\": round(y, 2)} for n, (x, y) in nodes.items()]\n",
"requests = [{\"req_id\": f\"REQ-{i:04d}\", \"dest\": f\"N{r:02d}07\", \"cargo_t\": float(round(rng.uniform(3, 30), 1)),\n",
" \"deadline_h\": float(rng.choice([2, 3, 4]))} for i, r in enumerate(range(6))]\n",
"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]\n",
"ids = set(nodes)\n",
"n_err = sum(1 for g, r in samples if validate(g, r, ids))\n",
"print(f\"정상 샘플 {len(samples)}건(자산 {len(assets)}, 노드 {len(node_recs)}, 간선 {len(edges)}, 요청 {len(requests)}) 검증 오류 {n_err}건\")\n",
"\n",
"# 3) 오류 주입 시험\n",
"bad = [(\"asset\", {**assets[0], \"asset_id\": \"TRUCK-1\"}), (\"asset\", {**assets[1], \"kind\": \"tank\"}),\n",
" (\"edge\", {**edges[0], \"v\": \"N9999\"}), (\"edge\", {**{k: v for k, v in edges[1].items() if k != \"km\"}}),\n",
" (\"edge\", {**edges[2], \"bridge\": True, \"limit_t\": None}), (\"request\", {**requests[0], \"cargo_t\": -2.0})]\n",
"print(\"\\n[오류 주입 결과]\")\n",
"caught = 0\n",
"for g, r in bad:\n",
" e = validate(g, copy.deepcopy(r), ids)\n",
" caught += bool(e)\n",
" print(\" -\", \"; \".join(e))\n",
"print(f\"주입 오류 {len(bad)}종 중 {caught}종 검출\")\n",
"\n",
"# 4) 수송지원 실적·비용 보고 업무 AS-IS 분해(단계별 소요 분, 가상 측정값)와 TO-BE 개선\n",
"steps = [(\"수송 요청 접수(전화·메신저)\", 20, \"요청 서식 표준화·자동 등록\", 2),\n",
" (\"가용 자산 확인(차량 일지 수기 대조)\", 30, \"자산 상태 스키마로 자동 조회\", 2),\n",
" (\"도로 상황 확인(구두 보고 취합)\", 40, \"도로 제약 자동 반영(미션 2)\", 5),\n",
" (\"경로 선정(지도 수작업)\", 35, \"최단·위협회피 경로 자동 추천\", 3),\n",
" (\"배차·적재 계획 작성\", 25, \"적재 효율 기반 자동 배차\", 5),\n",
" (\"수송통제관 승인\", 15, \"HITL 승인 화면(근거 자동 첨부)\", 10),\n",
" (\"운행 기록 수기 작성\", 20, \"운행 이벤트 자동 기록\", 3),\n",
" (\"지연·사고 보고 작성\", 25, \"지연 원인 코드 선택식 기록\", 5),\n",
" (\"운행 실적 집계(엑셀 복사·붙여넣기)\", 60, \"실적 자동 집계\", 1),\n",
" (\"비용 산출(유류·거리 단가 계산)\", 45, \"자산별 km 단가 자동 계산\", 1),\n",
" (\"실적·비용 보고서 작성\", 50, \"대시보드 화면 캡처 대신 자동 요약\", 5),\n",
" (\"상급 부대 보고·수정 요청 반영\", 30, \"단일 데이터 기준으로 수정 최소화\", 10)]\n",
"proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n",
"proc.index = range(1, len(proc) + 1)\n",
"thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n",
"proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n",
"print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n",
"print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n",
"nb = int((proc[\"병목\"] == \"병목\").sum())\n",
"asis, tobe = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n",
"print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n",
"print(f\"1회 처리 시간 AS-IS {asis}분 -> TO-BE {tobe}분 ({1 - tobe / asis:.0%} 단축 예상)\")\n",
"print(\"\\n[스키마 그룹별 필드 수]\", {g: len(f) for g, f in SCHEMA.items()})\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"정상 샘플 157건(자산 12, 노드 48, 간선 91, 요청 6) 검증 오류 0건\n",
"\n",
"[오류 주입 결과]\n",
" - asset.asset_id: 형식 불일치 'TRUCK-1'\n",
" - asset.kind: 허용되지 않은 값 'tank'\n",
" - edge.v: 존재하지 않는 노드 N9999\n",
" - edge.km: 필수 필드 누락\n",
" - edge.limit_t: 교량 하중 제한 누락\n",
" - request.cargo_t: 최소값 0.1 미만\n",
"주입 오류 6종 중 6종 검출\n",
"\n",
"[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 40분 이상)\n",
" 업무 단계 AS-IS(분) TO-BE(분) 병목\n",
"1 수송 요청 접수(전화·메신저) 20 2 \n",
"2 가용 자산 확인(차량 일지 수기 대조) 30 2 \n",
"3 도로 상황 확인(구두 보고 취합) 40 5 병목\n",
"4 경로 선정(지도 수작업) 35 3 \n",
"5 배차·적재 계획 작성 25 5 \n",
"6 수송통제관 승인 15 10 \n",
"7 운행 기록 수기 작성 20 3 \n",
"8 지연·사고 보고 작성 25 5 \n",
"9 운행 실적 집계(엑셀 복사·붙여넣기) 60 1 병목\n",
"10 비용 산출(유류·거리 단가 계산) 45 1 병목\n",
"11 실적·비용 보고서 작성 50 5 병목\n",
"12 상급 부대 보고·수정 요청 반영 30 10 \n",
"\n",
"업무 단계 12단계(목표 10단계 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n",
"1회 처리 시간 AS-IS 395분 -> TO-BE 52분 (87% 단축 예상)\n",
"\n",
"[스키마 그룹별 필드 수] {'asset': 6, 'node': 3, 'edge': 7, 'request': 4}\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"도로 파괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **도로 파괴, 교량 붕괴·하중 제한, 통제 구역 보고를 여러 형식에서 읽어 도로망 그래프에 반영하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 형식의 가상 보고서를 각각 파싱해 표준 제약 레코드로 바꾼다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/connectors/mov02_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n",
"\n",
"\n",
"- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n",
"\n",
"\n",
"- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"도로 파괴·교량 붕괴·하중 제한·통제 구역 보고를 여러 형식(CSV·엑셀 내보내기·JSON·공문 텍스트)의 가상 문서에서 파싱해 도로망 그래프에 반영하는 전장 제약 조건 전처리 모듈을 구현하고, 파싱 성공률과 반영 후 연결성을 확인한다.\"\"\"\n",
"import heapq\n",
"import csv\n",
"import io\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy.sparse import coo_matrix\n",
"from scipy.sparse.csgraph import connected_components\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 2] 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 2] 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"E = {e[\"id\"]: e for e in edges}\n",
"DEPOT, UNITS = \"N0000\", [f\"N{r:02d}07\" for r in range(6)]\n",
"\n",
"# 1) [교육용가상자료] 메모리 안의 가상 보고 문서 4종 (파일 대신 문자열로 모의)\n",
"CSV_DOC = \"\"\"edge_id,status,reason\n",
"E017,파괴,포격으로 노면 유실\n",
"E040,파괴,지뢰 의심 신고\n",
"E058,정상,복구 완료\n",
"E071,파괴,차량 잔해 적치\"\"\"\n",
"EXCEL_DOC = \"구간ID\\t교량하중(t)\\t비고\\nE025\\t15\\t교각 손상\\nE064\\t20\\t임시 가설 교량\" # 엑셀 내보내기(탭 구분, 한글 머리글)\n",
"JSON_DOC = json.dumps({\"control_zones\": [{\"zone\": \"CZ-1\", \"x_km\": 17.5, \"y_km\": 12.0, \"r_km\": 2.5, \"reason\": \"화생방 오염 의심\"},\n",
" {\"zone\": \"CZ-2\", \"x_km\": 30.0, \"y_km\": 21.0, \"r_km\": 1.8, \"reason\": \"민간인 대피 통로\"}]}, ensure_ascii=False)\n",
"MEMO_DOC = \"\"\"[가상 공문] 제목: 도로 상황 변경 통보\n",
"1. 교량 E046 붕괴로 통행 불가.\n",
"2. E083 구간 교량 하중 제한 20톤으로 하향.\n",
"3. E999 구간 파괴 보고(구간 번호 확인 필요).\"\"\"\n",
"\n",
"ALIAS = {\"edge_id\": [\"edge_id\", \"구간ID\", \"구간\"], \"limit_t\": [\"limit_t\", \"교량하중(t)\", \"하중\"], \"status\": [\"status\", \"상태\"]}\n",
"\n",
"\n",
"def std_cols(df):\n",
" \"\"\"양식이 바뀌어도(머리글 변경) 별칭 사전으로 표준 컬럼명에 맞춘다.\"\"\"\n",
" ren = {c: k for k, al in ALIAS.items() for c in df.columns if c.strip() in al}\n",
" return df.rename(columns=ren)\n",
"\n",
"\n",
"def parse(fmt, text):\n",
" \"\"\"형식별 파서. 결과는 표준 제약 레코드 목록 {type, edge_id | zone, value, reason}.\"\"\"\n",
" if fmt == \"csv\":\n",
" df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text)))))\n",
" 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)]\n",
" if fmt == \"excel\":\n",
" df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\"))))\n",
" return [{\"type\": \"limit\", \"edge_id\": e, \"value\": float(v), \"reason\": r} for e, v, r in zip(df.edge_id, df.limit_t, df[\"비고\"])]\n",
" if fmt == \"json\":\n",
" return [{\"type\": \"zone\", \"zone\": z[\"zone\"], \"value\": (z[\"x_km\"], z[\"y_km\"], z[\"r_km\"]), \"reason\": z[\"reason\"]}\n",
" for z in json.loads(text)[\"control_zones\"]]\n",
" if fmt == \"memo\":\n",
" out = []\n",
" for line in text.splitlines():\n",
" m = re.search(r\"(E\\d{3}).*?(붕괴|파괴|통행 불가)\", line)\n",
" if m:\n",
" out.append({\"type\": \"close\", \"edge_id\": m.group(1), \"reason\": line.strip()}); continue\n",
" m = re.search(r\"(E\\d{3}).*?하중 제한 (\\d+)\\s*톤\", line)\n",
" if m:\n",
" out.append({\"type\": \"limit\", \"edge_id\": m.group(1), \"value\": float(m.group(2)), \"reason\": line.strip()})\n",
" return out\n",
" raise ValueError(f\"지원하지 않는 형식: {fmt}\")\n",
"\n",
"\n",
"def check_rec(r):\n",
" \"\"\"스키마 유효성 검사: 구간ID 형식·존재 여부, 하중 1~100t, 통제 구역 반경 0.1~10km.\"\"\"\n",
" if r[\"type\"] == \"zone\":\n",
" return [] if 0.1 <= r[\"value\"][2] <= 10 else [f\"{r['zone']} 반경 범위 오류\"]\n",
" errs = [] if re.match(r\"^E\\d{3}$\", r[\"edge_id\"]) else [f\"{r['edge_id']} 형식 오류\"]\n",
" if r[\"edge_id\"] not in E:\n",
" errs.append(f\"{r['edge_id']} 존재하지 않는 구간\")\n",
" if r[\"type\"] == \"limit\" and not 1 <= r[\"value\"] <= 100:\n",
" errs.append(f\"{r['edge_id']} 하중 범위 오류\")\n",
" return errs\n",
"\n",
"\n",
"def apply(edges, recs, log):\n",
" \"\"\"제약 레코드를 그래프에 반영. 없는 구간 ID는 반영하지 않고 로그에 남긴다.\"\"\"\n",
" new = [dict(e) for e in edges]\n",
" idx = {e[\"id\"]: e for e in new}\n",
" for r in recs:\n",
" if r[\"type\"] == \"zone\":\n",
" x, y, rad = r[\"value\"]\n",
" for e in new:\n",
" if edge_risk(e, nodes, [{\"x\": x, \"y\": y, \"r\": rad, \"p\": 1.0}]) > 0:\n",
" e[\"open\"] = False; e[\"zone\"] = r[\"zone\"]\n",
" log.append(f\"{r['zone']} 통제 구역 반영\")\n",
" elif r[\"edge_id\"] not in idx:\n",
" log.append(f\"{r['edge_id']} 없는 구간 ID - 반영 보류(확인 요청)\")\n",
" elif r[\"type\"] == \"close\":\n",
" idx[r[\"edge_id\"]][\"open\"] = False\n",
" elif r[\"type\"] == \"reopen\":\n",
" idx[r[\"edge_id\"]][\"open\"] = True\n",
" elif r[\"type\"] == \"limit\":\n",
" idx[r[\"edge_id\"]].update(bridge=True, limit_t=r[\"value\"])\n",
" return new\n",
"\n",
"\n",
"docs = [(\"csv\", CSV_DOC), (\"excel\", EXCEL_DOC), (\"json\", JSON_DOC), (\"memo\", MEMO_DOC)]\n",
"recs, rows = [], []\n",
"for fmt, text in docs:\n",
" try:\n",
" r = parse(fmt, text); recs += r; rows.append({\"형식\": fmt, \"파싱\": \"성공\", \"레코드\": len(r)})\n",
" except Exception as ex:\n",
" rows.append({\"형식\": fmt, \"파싱\": f\"실패({ex})\", \"레코드\": 0})\n",
"res = pd.DataFrame(rows)\n",
"rate = (res[\"파싱\"] == \"성공\").mean()\n",
"print(res.to_string(index=False))\n",
"print(f\"파싱 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 제약 레코드 {len(recs)}건\")\n",
"\n",
"log = []\n",
"after = apply(edges, recs, log)\n",
"print(\"\\n[반영 로그]\", \" / \".join(log))\n",
"print(f\"통행 불가 구간 {sum(not e['open'] for e in after)}개, 하중 제한 교량 {sum(e['bridge'] for e in after)}개 (원본 {sum(e['bridge'] for e in edges)}개)\")\n",
"\n",
"# 2) 반영 전후 연결성·도달성 점검 (트럭 총중량 15t, 트레일러 총중량 50t 가정)\n",
"ids = list(nodes)\n",
"pos = {n: i for i, n in enumerate(ids)}\n",
"\n",
"\n",
"def n_components(es, gross):\n",
" ok = [e for e in es if passable(e, gross)]\n",
" 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)))\n",
" return connected_components(g, directed=False)[0]\n",
"\n",
"\n",
"print(\"\\n차종 반영 전 연결 요소 반영 후 연결 요소 도달 가능 부대(후)\")\n",
"for kind, gross in [(\"truck\", 15), (\"trailer\", 50)]:\n",
" adj = build_adj(nodes, after, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)\n",
" reach = sum(dijkstra(adj, DEPOT, u)[0] < float(\"inf\") for u in UNITS)\n",
" print(f\"{kind:8s} {n_components(edges, gross):16d} {n_components(after, gross):16d} {reach}/{len(UNITS)}\")\n",
"\n",
"# 3) 양식 변경 대응 시험: 머리글이 바뀐 CSV도 별칭 사전으로 처리되는지 확인\n",
"changed = \"구간,상태,reason\\nE017,파괴,머리글 변경 양식\"\n",
"print(\"\\n머리글 변경 양식 파싱:\", parse(\"csv\", changed))\n",
"errs = [x for r in recs for x in check_rec(r)]\n",
"print(f\"스키마 검사: 레코드 {len(recs)}건 중 오류 {len(errs)}건 {errs} -> 해당 건은 반영 보류 후 확인 요청\")\n",
"\n",
"# 4) 반영 전후 그래프 그림\n",
"fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n",
"for a, es, title in [(ax[0], edges, \"Road network (before)\"), (ax[1], after, \"After battlefield constraints\")]:\n",
" for e in es:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" col = \"red\" if not e[\"open\"] else (\"orange\" if e[\"bridge\"] else (\"tab:blue\" if e[\"road\"] == \"paved\" else \"tab:gray\"))\n",
" a.plot([x1, x2], [y1, y2], color=col, lw=2.5 if not e[\"open\"] else 1.5, ls=\"--\" if not e[\"open\"] else \"-\")\n",
" a.plot(*nodes[DEPOT], \"ks\", ms=10)\n",
" for u in UNITS:\n",
" a.plot(*nodes[u], \"g^\", ms=9)\n",
" a.set_title(title); a.set_xlabel(\"x (km)\"); a.set_ylabel(\"y (km)\"); a.set_aspect(\"equal\")\n",
"for r in recs:\n",
" if r[\"type\"] == \"zone\":\n",
" ax[1].add_patch(plt.Circle(r[\"value\"][:2], r[\"value\"][2], color=\"purple\", alpha=0.2))\n",
"ax[1].text(0, -4, \"red dashed = closed, orange = bridge (weight limit), purple = control zone\", fontsize=8)\n",
"plt.suptitle(\"MOV02 constraint preprocessing (synthetic network)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 형식 파싱 레코드\n",
" csv 성공 4\n",
"excel 성공 2\n",
" json 성공 2\n",
" memo 성공 3\n",
"파싱 성공률 100% (목표 100%: 달성), 제약 레코드 11건\n",
"\n",
"[반영 로그] CZ-1 통제 구역 반영 / CZ-2 통제 구역 반영 / E999 없는 구간 ID - 반영 보류(확인 요청)\n",
"통행 불가 구간 10개, 하중 제한 교량 17개 (원본 14개)\n",
"\n",
"차종 반영 전 연결 요소 반영 후 연결 요소 도달 가능 부대(후)\n",
"truck 1 2 6/6\n",
"trailer 1 2 6/6\n",
"\n",
"머리글 변경 양식 파싱: [{'type': 'close', 'edge_id': 'E017', 'reason': '머리글 변경 양식'}]\n",
"스키마 검사: 레코드 11건 중 오류 1건 ['E999 존재하지 않는 구간'] -> 해당 건은 반영 보류 후 확인 요청\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"다익스트라 기반 최단거리 보급 경로 탐색 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다익스트라 알고리즘으로 보급소에서 지원 부대까지 최단시간 경로를 찾는 Baseline과 경로 검증 규칙 엔진을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **간선 가중치를 차종별 소요시간으로 두고 통행 불가·하중 초과 구간을 제외해 최단 경로를 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/engine/mov02_rule_engine.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n",
"\n",
"\n",
"- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n",
"\n",
"\n",
"- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"다익스트라 알고리즘으로 보급소에서 지원 부대까지 최단시간 보급 경로를 찾는 Baseline을 구현하고, 설정 문자열로 분리한 경로 검증 규칙 15종과 경계 조건 단위 테스트로 결과를 판정한다.\"\"\"\n",
"import heapq\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy.sparse import csr_matrix\n",
"from scipy.sparse.csgraph import dijkstra as sp_dijkstra\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 3] 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 3] 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# 규정·기준이 바뀌면 코드 대신 이 설정만 고친다(YAML 형식 문자열)\n",
"RULES_YAML = \"\"\"\n",
"max_unpaved_ratio: 0.5\n",
"detour_factor: 2.5\n",
"max_threat_edges: 1\n",
"truck_count: 3\n",
"\"\"\"\n",
"CFG = {k.strip(): float(v) for k, v in (line.split(\":\") for line in RULES_YAML.strip().splitlines())}\n",
"\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"for e in edges: # 미션 2 전처리 결과(폐쇄·하중 제한·통제 구역) 요약 반영\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" if e[\"id\"] in (\"E025\", \"E064\", \"E083\"):\n",
" e.update(bridge=True, limit_t={\"E025\": 15.0, \"E064\": 20.0, \"E083\": 20.0}[e[\"id\"]])\n",
" if edge_risk(e, nodes, [{\"x\": 17.5, \"y\": 12.0, \"r\": 2.5, \"p\": 1}]) > 0:\n",
" e[\"open\"], e[\"zone\"] = False, \"CZ-1\"\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"DEPOT = \"N0000\"\n",
"ASSET = {\"kind\": \"truck\", \"status\": \"가용\", **ASSETS[\"truck\"]}\n",
"\n",
"\n",
"def route(dest, cargo_t, kind=\"truck\"):\n",
" gross = ASSETS[kind][\"weight_t\"] + min(cargo_t, ASSETS[kind][\"cap_t\"])\n",
" adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)\n",
" return dijkstra(adj, DEPOT, dest)\n",
"\n",
"\n",
"def rules(req, cost, path, es, asset=ASSET):\n",
" \"\"\"경로 검증 규칙 15종. 결과: 규칙명 -> PASS / FAIL / WARN.\"\"\"\n",
" gross = asset[\"weight_t\"] + asset[\"cap_t\"]\n",
" km = sum(e[\"km\"] for e in es)\n",
" straight = np.hypot(*np.subtract(nodes[req[\"dest\"]], nodes[DEPOT]))\n",
" pairs = {frozenset((e[\"u\"], e[\"v\"])) for e in edges}\n",
" R = {\n",
" \"R01 출발지=보급소\": path[:1] == [DEPOT],\n",
" \"R02 도착지=요청 부대\": path[-1:] == [req[\"dest\"]],\n",
" \"R03 연속 노드 간 도로 존재\": all(frozenset(p) in pairs for p in zip(path, path[1:])),\n",
" \"R04 폐쇄 구간 미포함\": all(e[\"open\"] for e in es),\n",
" \"R05 교량 하중 이내\": all(not e[\"bridge\"] or e[\"limit_t\"] >= gross for e in es),\n",
" \"R06 통제 구역 미통과\": all(\"zone\" not in e for e in es),\n",
" \"R07 노드 중복 방문 없음\": len(path) == len(set(path)),\n",
" \"R08 거리 합계 정합\": abs(km - sum(e[\"km\"] for e in es)) < 1e-9 and len(es) == len(path) - 1,\n",
" \"R09 소요시간 기한 이내\": cost <= req[\"deadline_h\"] * 60,\n",
" \"R10 적재량 수송능력 이내\": req[\"cargo_t\"] <= asset[\"cap_t\"] * CFG[\"truck_count\"],\n",
" \"R11 자산 상태 가용\": asset[\"status\"] == \"가용\",\n",
" \"R12 비포장 비율 기준 이내\": sum(e[\"km\"] for e in es if e[\"road\"] == \"unpaved\") <= CFG[\"max_unpaved_ratio\"] * max(km, 1e-9),\n",
" \"R13 우회 과다 없음\": km <= CFG[\"detour_factor\"] * straight,\n",
" \"R14 경로 구간 1개 이상\": len(es) >= 1,\n",
" \"R15 위협 구역 통과 기준 이내\": sum(e[\"risk\"] > 0 for e in es) <= CFG[\"max_threat_edges\"],\n",
" }\n",
" out = {k: \"PASS\" if v else \"FAIL\" for k, v in R.items()}\n",
" if out[\"R15 위협 구역 통과 기준 이내\"] == \"FAIL\":\n",
" out[\"R15 위협 구역 통과 기준 이내\"] = \"WARN\" # Baseline은 위협을 고려하지 않으므로 경고만 표시\n",
" return out\n",
"\n",
"\n",
"rng = np.random.default_rng(2)\n",
"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))]\n",
"rows, lat, all_res = [], [], []\n",
"for q in reqs:\n",
" cost, path, es = route(q[\"dest\"], q[\"cargo_t\"])\n",
" t0 = time.perf_counter()\n",
" res = rules(q, cost, path, es)\n",
" lat.append((time.perf_counter() - t0) * 1000)\n",
" all_res.append(res)\n",
" rows.append({\"요청\": q[\"req\"], \"목적지\": q[\"dest\"], \"거리(km)\": round(sum(e[\"km\"] for e in es), 1), \"소요(분)\": round(cost, 1),\n",
" \"구간 수\": len(es), \"위협 통과 구간\": sum(e[\"risk\"] > 0 for e in es),\n",
" \"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())})\n",
"print(\"[Baseline 최단시간 경로 결과 - 중형 트럭]\")\n",
"print(pd.DataFrame(rows).to_string(index=False))\n",
"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 '미달'})\")\n",
"\n",
"# 1) 최적성 검증: scipy 다익스트라와 비용 비교\n",
"ids = list(nodes); ix = {n: i for i, n in enumerate(ids)}\n",
"ok_e = [e for e in edges if passable(e, 10 + 5)]\n",
"w = [edge_minutes(e) for e in ok_e]\n",
"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)\n",
"ref = sp_dijkstra(M, indices=ix[DEPOT])\n",
"same = sum(abs(ref[ix[q[\"dest\"]]] - route(q[\"dest\"], 5.0)[0]) < 1e-6 for q in reqs)\n",
"print(f\"최적성 검증(scipy 결과와 일치): {same}/{len(reqs)}\")\n",
"\n",
"# 2) 경계 조건 단위 테스트\n",
"cost, path, es = route(\"N0507\", 5.0)\n",
"tests = {\n",
" \"적재량=수송능력 한계(15t)\": rules({\"dest\": \"N0507\", \"cargo_t\": 15.0, \"deadline_h\": 9}, cost, path, es)[\"R10 적재량 수송능력 이내\"] == \"PASS\",\n",
" \"적재량 한계+0.1t\": rules({\"dest\": \"N0507\", \"cargo_t\": 15.1, \"deadline_h\": 9}, cost, path, es)[\"R10 적재량 수송능력 이내\"] == \"FAIL\",\n",
" \"소요시간=기한 정확히 일치\": rules({\"dest\": \"N0507\", \"cargo_t\": 5, \"deadline_h\": cost / 60}, cost, path, es)[\"R09 소요시간 기한 이내\"] == \"PASS\",\n",
" \"도달 불가 목적지 처리(고립 노드)\": dijkstra(build_adj(nodes, edges, lambda e: None if \"N0507\" in (e[\"u\"], e[\"v\"]) else 1.0), DEPOT, \"N0507\") == (float(\"inf\"), [], []),\n",
" \"출발지=도착지\": dijkstra(build_adj(nodes, edges, lambda e: 1.0), DEPOT, DEPOT)[1] == [DEPOT],\n",
" \"정비 중 자산 차단\": rules({\"dest\": \"N0507\", \"cargo_t\": 5, \"deadline_h\": 9}, cost, path, es, {**ASSET, \"status\": \"정비\"})[\"R11 자산 상태 가용\"] == \"FAIL\",\n",
"}\n",
"for k, v in tests.items():\n",
" print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n",
"print(f\"단위 테스트 통과율 {np.mean(list(tests.values())):.0%}\")\n",
"print(\"Baseline 한계: 시간만 최소화해 위협 구역을 지나는 경로(WARN)가 나온다 -> 미션 4에서 위협 회피·적재 효율을 반영한다.\")\n",
"\n",
"plt.figure(figsize=(8, 5.5))\n",
"for e in edges:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" plt.plot([x1, x2], [y1, y2], color=\"lightgray\" if e[\"open\"] else \"red\", lw=1, ls=\"-\" if e[\"open\"] else \"--\")\n",
"for t in threats:\n",
" plt.gca().add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.12))\n",
"for q, c in zip(reqs, plt.cm.tab10.colors):\n",
" _, path, _ = route(q[\"dest\"], q[\"cargo_t\"])\n",
" 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']}\")\n",
"plt.plot(*nodes[DEPOT], \"ks\", ms=10, label=\"depot\")\n",
"plt.gca().set_aspect(\"equal\"); plt.legend(fontsize=8, loc=\"upper left\"); plt.xlabel(\"x (km)\"); plt.ylabel(\"y (km)\")\n",
"plt.title(\"Baseline shortest-time routes (red circles = threat zones, synthetic)\")\n",
"plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[Baseline 최단시간 경로 결과 - 중형 트럭]\n",
" 요청 목적지 거리(km) 소요(분) 구간 수 위협 통과 구간 PASS WARN FAIL\n",
"REQ-0000 N0007 44.2 58.9 8 2 14 1 0\n",
"REQ-0001 N0107 40.2 54.1 7 2 14 1 0\n",
"REQ-0002 N0207 44.4 53.2 8 3 14 1 0\n",
"REQ-0003 N0307 49.0 58.8 9 3 14 1 0\n",
"REQ-0004 N0407 53.3 67.4 10 3 14 1 0\n",
"REQ-0005 N0507 58.4 73.5 11 3 14 1 0\n",
"\n",
"검증 규칙 15종(목표 15종 이상), 규칙 처리 평균 0.06 ms / 최대 0.11 ms (목표 50 ms 이하: 달성)\n",
"최적성 검증(scipy 결과와 일치): 6/6\n",
" [PASS] 적재량=수송능력 한계(15t)\n",
" [PASS] 적재량 한계+0.1t\n",
" [PASS] 소요시간=기한 정확히 일치\n",
" [PASS] 도달 불가 목적지 처리(고립 노드)\n",
" [PASS] 출발지=도착지\n",
" [PASS] 정비 중 자산 차단\n",
"단위 테스트 통과율 100%\n",
"Baseline 한계: 시간만 최소화해 위협 구역을 지나는 경로(WARN)가 나온다 -> 미션 4에서 위협 회피·적재 효율을 반영한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"적 위협 반경 회피 및 적재 효율을 고려한 최적 수송 경로 추천 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **적 위협 반경 회피와 적재 효율(차종 조합)을 함께 고려한 최적 수송 경로 추천 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **간선 비용에 소요시간과 위협 위험 가중치를 함께 넣고, 과거 수송통제관 승인 사례(가상)로 위험 가중치를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/ai/mov02_intelligence_module.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 추천/분류 정확도 85% 이상 달성\n",
"\n",
"\n",
"- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n",
"\n",
"\n",
"- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"가상 위협 구역 회피 비용과 적재 효율(차종 조합)을 함께 고려하는 수송 경로 추천 모델을 구현한다. 과거 수송통제관 승인 사례로 위험 가중치를 학습해 추천 일치도(Top-1·Top-3)를 검증하고, 추천 사유와 유사도 점수를 제시한다.\"\"\"\n",
"import heapq\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 4] 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 4] 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"DEPOT, UNITS = \"N0000\", [f\"N{r:02d}07\" for r in range(6)]\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
"LAMS = [0.0, 0.3, 0.7, 1.5, 3.0] # 위험 회피 강도별 후보 경로 생성\n",
"\n",
"\n",
"def candidates(dest, threats, kind=\"truck\", gross=15, k=5):\n",
" \"\"\"위험 회피 강도(lam)를 바꾸고, 이미 찾은 경로 구간에 벌점을 주어 서로 다른 후보 경로(최대 k개)를 만든다.\"\"\"\n",
" risk = {e[\"id\"]: edge_risk(e, nodes, threats) for e in edges}\n",
" out, seen, pen = [], set(), {}\n",
" for lam in LAMS + [0.0, 0.0, 0.3, 0.3]:\n",
" adj = build_adj(nodes, edges, lambda e: (edge_minutes(e, kind) + lam * risk[e[\"id\"]] * 100) * pen.get(e[\"id\"], 1.0)\n",
" if passable(e, gross) else None)\n",
" _, path, es = dijkstra(adj, DEPOT, dest)\n",
" for e in es:\n",
" pen[e[\"id\"]] = pen.get(e[\"id\"], 1.0) * 1.3\n",
" if path and tuple(path) not in seen and len(out) < k:\n",
" seen.add(tuple(path))\n",
" surv = float(np.prod([1 - risk[e[\"id\"]] for e in es]))\n",
" out.append({\"path\": path, \"min\": sum(edge_minutes(e, kind) for e in es), \"km\": sum(e[\"km\"] for e in es),\n",
" \"surv\": surv, \"risk_score\": -np.log(surv) * 100, \"threat_edges\": sum(risk[e[\"id\"]] > 0 for e in es)})\n",
" return out\n",
"\n",
"\n",
"def utility(c, w):\n",
" return c[\"min\"] + w * c[\"risk_score\"] # 낮을수록 좋음: 소요시간(분) + 위험 가중치 x 위험 점수\n",
"\n",
"\n",
"# 1) [교육용가상자료] 과거 수송통제관 승인 사례 40건: 숨은 선호(w=1.2)와 판단 편차(잡음)로 한 경로를 승인\n",
"rng = np.random.default_rng(4)\n",
"cases = []\n",
"for i in range(60):\n",
" th = make_threats(seed=100 + i, n=int(rng.integers(2, 5)))\n",
" cand = candidates(str(rng.choice(UNITS)), th)\n",
" if len(cand) < 2:\n",
" continue\n",
" approved = int(np.argmin([utility(c, 1.2) + rng.normal(0, 1.0) for c in cand]))\n",
" cases.append({\"cand\": cand, \"approved\": approved})\n",
"train, test = cases[:len(cases) // 2], cases[len(cases) // 2:]\n",
"\n",
"\n",
"def agreement(cs, w, top=1):\n",
" return np.mean([c[\"approved\"] in np.argsort([utility(x, w) for x in c[\"cand\"]])[:top] for c in cs])\n",
"\n",
"\n",
"def loglik(cs, w, T=1.5):\n",
" \"\"\"선택 확률 P(i) ~ exp(-효용/T) 가정의 로그우도(조건부 로짓). 일치도보다 매끄러워 가중치 추정이 안정적이다.\"\"\"\n",
" ll = 0.0\n",
" for c in cs:\n",
" u = -np.array([utility(x, w) for x in c[\"cand\"]]) / T\n",
" ll += u[c[\"approved\"]] - (u.max() + np.log(np.exp(u - u.max()).sum()))\n",
" return ll\n",
"\n",
"\n",
"grid = np.round(np.arange(0, 3.01, 0.1), 1)\n",
"w_best = grid[int(np.argmax([loglik(train, w) for w in grid]))]\n",
"print(f\"과거 승인 사례 {len(cases)}건(학습 {len(train)} / 검증 {len(test)}), 학습된 위험 가중치 w = {w_best}\")\n",
"rows = [{\"모델\": \"Baseline(최단시간, w=0)\", \"Top-1 일치도\": agreement(test, 0.0), \"Top-3 포함률\": agreement(test, 0.0, 3)},\n",
" {\"모델\": f\"위협 회피 추천(w={w_best})\", \"Top-1 일치도\": agreement(test, w_best), \"Top-3 포함률\": agreement(test, w_best, 3)}]\n",
"res = pd.DataFrame(rows)\n",
"print(res.to_string(index=False, float_format=lambda v: f\"{v:.1%}\"))\n",
"acc = rows[1][\"Top-1 일치도\"]\n",
"print(f\"추천 정확도 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})\")\n",
"\n",
"# 2) 현재 요청에 대한 Top-3 추천과 추천 사유\n",
"threats = make_threats()\n",
"dest, cargo = \"N0307\", 38.0\n",
"cand = candidates(dest, threats)\n",
"order = np.argsort([utility(c, w_best) for c in cand])[:3]\n",
"best_u = utility(cand[order[0]], w_best)\n",
"print(f\"\\n[요청: {dest} 보급 {cargo}t] 추천 Top-3 (유사도 = 1순위 대비 효용 비율)\")\n",
"base = min(cand, key=lambda c: c[\"min\"])\n",
"for rank, i in enumerate(order, 1):\n",
" c = cand[i]\n",
" print(f\" {rank}순위 소요 {c['min']:.0f}분, 거리 {c['km']:.1f}km, 위협 통과 {c['threat_edges']}구간, 생존확률 {c['surv']:.1%}, 유사도 {best_u / utility(c, w_best):.2f}\")\n",
"top = cand[order[0]]\n",
"print(f\" 추천 사유: 최단시간 경로 대비 소요 {top['min'] - base['min']:+.0f}분, 위협 통과 {base['threat_edges']}->{top['threat_edges']}구간, 생존확률 {base['surv']:.1%}->{top['surv']:.1%}\")\n",
"\n",
"\n",
"# 3) 적재 효율: 차종 조합(트럭 5t, 트레일러 20t) 최적화 - 교량 하중 때문에 트레일러 경로가 다를 수 있음\n",
"def plan(cargo, allow_trailer=True):\n",
" best = None\n",
" for n_trl in range(0, 4 if allow_trailer else 1):\n",
" n_trk = int(max(0, np.ceil((cargo - 20 * n_trl) / 5)))\n",
" if n_trk > 10:\n",
" continue\n",
" cost = 0.0\n",
" if n_trl:\n",
" c = candidates(dest, threats, \"trailer\", 50)\n",
" if not c:\n",
" continue\n",
" cost += n_trl * min(c, key=lambda x: utility(x, w_best))[\"km\"] * ASSETS[\"trailer\"][\"cost_km\"]\n",
" cost += n_trk * top[\"km\"] * ASSETS[\"truck\"][\"cost_km\"]\n",
" cap = 20 * n_trl + 5 * n_trk\n",
" cand_plan = {\"트레일러\": n_trl, \"트럭\": n_trk, \"수송능력(t)\": cap, \"적재 효율\": cargo / cap, \"비용(단가x km)\": round(cost, 1)}\n",
" if best is None or cost < best[\"비용(단가x km)\"]:\n",
" best = cand_plan\n",
" return best\n",
"\n",
"\n",
"p0, p1 = plan(cargo, False), plan(cargo)\n",
"print(\"\\n[적재 계획 비교]\")\n",
"print(pd.DataFrame([{\"방식\": \"트럭만 배차\", **p0}, {\"방식\": \"차종 조합 최적화\", **p1}]).to_string(index=False, float_format=lambda v: f\"{v:.1%}\" if v < 1 else f\"{v:.1f}\"))\n",
"print(f\"비용 {1 - p1['비용(단가x km)'] / p0['비용(단가x km)']:.1%} 절감, 투입 차량 {p0['트럭']}대 -> {p1['트럭'] + p1['트레일러']}대\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n",
"for e in edges:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" ax[0].plot([x1, x2], [y1, y2], color=\"lightgray\", lw=1)\n",
"for t in threats:\n",
" ax[0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n",
"for rank, (i, col) in enumerate(zip(order, [\"tab:green\", \"tab:blue\", \"tab:orange\"]), 1):\n",
" 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}\")\n",
"xy = np.array([nodes[n] for n in base[\"path\"]]); ax[0].plot(xy[:, 0], xy[:, 1], \"k--\", lw=1, label=\"shortest-time\")\n",
"ax[0].set_aspect(\"equal\"); ax[0].legend(fontsize=8); ax[0].set_title(\"Threat-aware route recommendation (synthetic)\")\n",
"ax[1].plot(grid, [agreement(train, w) for w in grid], \"o-\", label=\"train\")\n",
"ax[1].plot(grid, [agreement(test, w) for w in grid], \"s-\", label=\"test\")\n",
"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()\n",
"ax[1].set_title(\"Agreement with past approvals\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"과거 승인 사례 60건(학습 30 / 검증 30), 학습된 위험 가중치 w = 2.3\n",
" 모델 Top-1 일치도 Top-3 포함률\n",
"Baseline(최단시간, w=0) 43.3% 100.0%\n",
" 위협 회피 추천(w=2.3) 90.0% 96.7%\n",
"추천 정확도 90.0% (목표 85% 이상: 달성)\n",
"\n",
"[요청: N0307 보급 38.0t] 추천 Top-3 (유사도 = 1순위 대비 효용 비율)\n",
" 1순위 소요 60분, 거리 50.0km, 위협 통과 0구간, 생존확률 100.0%, 유사도 1.00\n",
" 2순위 소요 66분, 거리 50.9km, 위협 통과 0구간, 생존확률 100.0%, 유사도 0.91\n",
" 3순위 소요 73분, 거리 55.8km, 위협 통과 0구간, 생존확률 100.0%, 유사도 0.82\n",
" 추천 사유: 최단시간 경로 대비 소요 +1분, 위협 통과 3->0구간, 생존확률 57.8%->100.0%\n",
"\n",
"[적재 계획 비교]\n",
" 방식 트레일러 트럭 수송능력(t) 적재 효율 비용(단가x km)\n",
" 트럭만 배차 0 8 40 95.0% 400.0\n",
"차종 조합 최적화 2 0 40 95.0% 287.1\n",
"비용 28.2% 절감, 투입 차량 8대 -> 2대\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **후보 경로마다 수송선단 생존확률과 정시 도착 신뢰도를 계산해 0~100점 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **위협 구간 통과 수로 생존확률을, 도로 등급·교량 대기·위협 구역 감속을 반영한 몬테카를로 모의로 정시 도착 확률을 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/ui/mov02_app.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n",
"\n",
"\n",
"- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n",
"\n",
"\n",
"- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"후보 수송 경로마다 수송선단 생존 확률(위협 구역 통과 기반)과 정시 도착 신뢰도(몬테카를로 소요시간 모의)를 계산해 0~100점 신뢰도 점수와 등급을 산출하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면으로 표시하며 예측 확률의 보정 정확도를 검증한다.\"\"\"\n",
"import heapq\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 5] 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 5] 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"DEPOT = \"N0000\"\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"SIGMA = {\"paved\": 0.12, \"unpaved\": 0.30} # 도로 등급별 소요시간 변동(로그정규 표준편차, 가정값)\n",
"\n",
"\n",
"def candidates(dest, lams=(0.0, 0.5, 1.5, 3.0, 0.0, 0.5), k=4):\n",
" \"\"\"위험 회피 강도를 바꾸고 이미 쓴 구간에 벌점을 주어 서로 다른 후보 경로를 최대 k개 만든다.\"\"\"\n",
" out, seen, pen = [], set(), {}\n",
" for lam in lams:\n",
" 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)\n",
" _, path, es = dijkstra(adj, DEPOT, dest)\n",
" for e in es:\n",
" pen[e[\"id\"]] = pen.get(e[\"id\"], 1.0) * 1.3\n",
" if path and tuple(path) not in seen and len(out) < k:\n",
" seen.add(tuple(path)); out.append({\"path\": path, \"es\": es})\n",
" return out\n",
"\n",
"\n",
"def simulate_minutes(es, n, rng):\n",
" \"\"\"경로 소요시간 몬테카를로: 도로 등급별 변동 + 교량 통과 대기 + 위협 구역 감속.\"\"\"\n",
" total = np.zeros(n)\n",
" for e in es:\n",
" total += edge_minutes(e) * rng.lognormal(0, SIGMA[e[\"road\"]], n)\n",
" if e[\"bridge\"]:\n",
" total += rng.exponential(3, n)\n",
" if e[\"risk\"] > 0:\n",
" total += rng.uniform(0, 10, n)\n",
" return total\n",
"\n",
"\n",
"def score_route(c, deadline_min, n=3000, seed=0):\n",
" surv = float(np.prod([1 - e[\"risk\"] for e in c[\"es\"]]))\n",
" sims = simulate_minutes(c[\"es\"], n, np.random.default_rng(seed))\n",
" ontime = float((sims <= deadline_min).mean())\n",
" s = 100 * surv * ontime\n",
" return {\"생존확률\": surv, \"정시도착\": ontime, \"예상소요(P50)\": float(np.median(sims)), \"예상소요(P90)\": float(np.percentile(sims, 90)),\n",
" \"점수\": round(s, 1), \"등급\": \"신뢰\" if s >= 85 else (\"주의\" if s >= 60 else \"위험\"), \"sims\": sims}\n",
"\n",
"\n",
"# ---------------- 화면 1. 입력 ----------------\n",
"REQ = {\"req_id\": \"REQ-0101\", \"dest\": \"N0307\", \"cargo_t\": 12.0, \"deadline_min\": 80}\n",
"print(\"[화면 1: 입력] 수송 요청\", {k: v for k, v in REQ.items()}, \"| 가상 위협 구역\", len(threats), \"개\")\n",
"\n",
"# ---------------- 화면 2. 분석 ----------------\n",
"cands = candidates(REQ[\"dest\"])\n",
"rows = []\n",
"for i, c in enumerate(cands):\n",
" r = score_route(c, REQ[\"deadline_min\"], seed=i)\n",
" c.update(r)\n",
" rows.append({\"경로\": f\"R{i + 1}\", \"구간 수\": len(c[\"es\"]), \"위협 통과\": sum(e[\"risk\"] > 0 for e in c[\"es\"]),\n",
" **{k: r[k] for k in (\"생존확률\", \"정시도착\", \"예상소요(P50)\", \"예상소요(P90)\", \"점수\", \"등급\")}})\n",
"table = pd.DataFrame(rows)\n",
"fmt = {\"생존확률\": \"{:.1%}\", \"정시도착\": \"{:.1%}\", \"예상소요(P50)\": \"{:.0f}분\", \"예상소요(P90)\": \"{:.0f}분\"}\n",
"show = table.copy()\n",
"for k, f in fmt.items():\n",
" show[k] = show[k].map(f.format)\n",
"print(\"\\n[화면 2: 분석] 후보 경로별 신뢰도 점수 (점수 = 100 x 생존확률 x 정시도착확률)\")\n",
"print(show.to_string(index=False))\n",
"\n",
"# ---------------- 화면 3. 검토 (상태 뱃지·필터) ----------------\n",
"badge = {\"신뢰\": \"완료(추천 가능)\", \"주의\": \"승인대기(통제관 검토)\", \"위험\": \"반려(재탐색)\"}\n",
"table[\"상태\"] = table[\"등급\"].map(badge)\n",
"print(\"\\n[화면 3: 검토] 상태별 필터\")\n",
"for st in badge.values():\n",
" print(f\" {st}: {table.loc[table['상태'] == st, '경로'].tolist()}\")\n",
"\n",
"# ---------------- 화면 4. 결과 ----------------\n",
"best = table.sort_values([\"점수\", \"예상소요(P50)\"], ascending=[False, True]).iloc[0]\n",
"print(f\"\\n[화면 4: 결과] 추천 {best['경로']} - 점수 {best['점수']}({best['등급']}), 생존확률 {best['생존확률']:.1%}, 정시도착 {best['정시도착']:.1%}\")\n",
"print(\"핵심 화면 4종(입력-분석-검토-결과) 구현 완료: 4/4\")\n",
"\n",
"# ---------------- 예측 보정(Calibration) 검증 ----------------\n",
"# 서로 다른 목적지·기한의 가상 수송 60건: 예측 정시확률 vs 별도 난수로 모의한 '실제 운행 결과'\n",
"rng = np.random.default_rng(21)\n",
"pred, actual = [], []\n",
"for k in range(60):\n",
" 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]\n",
" dl = float(rng.uniform(60, 100))\n",
" pred.append(score_route(c, dl, n=2000, seed=k)[\"정시도착\"])\n",
" actual.append(float(simulate_minutes(c[\"es\"], 1, rng)[0] <= dl))\n",
"pred, actual = np.array(pred), np.array(actual)\n",
"brier = np.mean((pred - actual) ** 2)\n",
"print(f\"\\n정시도착 예측 보정 검증(가상 운행 60건): Brier 점수 {brier:.3f} (0에 가까울수록 좋음, 무정보 예측 0.25)\")\n",
"for lo, hi in [(0, 0.5), (0.5, 0.9), (0.9, 1.01)]:\n",
" m = (pred >= lo) & (pred < hi)\n",
" if m.any():\n",
" print(f\" 예측 {lo:.0%}~{min(hi, 1):.0%} 구간 {m.sum():2d}건: 평균 예측 {pred[m].mean():.1%}, 실제 정시율 {actual[m].mean():.1%}\")\n",
"\n",
"fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n",
"for e in edges:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" ax[0, 0].plot([x1, x2], [y1, y2], color=\"lightgray\", lw=1)\n",
"for t in threats:\n",
" ax[0, 0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n",
"for i, c in enumerate(cands):\n",
" 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}\")\n",
"ax[0, 0].set_aspect(\"equal\"); ax[0, 0].legend(fontsize=8); ax[0, 0].set_title(\"1. Input: request and candidate routes\")\n",
"for i, c in enumerate(cands):\n",
" ax[0, 1].hist(c[\"sims\"], bins=40, alpha=0.5, label=f\"R{i + 1}\")\n",
"ax[0, 1].axvline(REQ[\"deadline_min\"], color=\"k\", ls=\"--\"); ax[0, 1].set_xlabel(\"travel time (min)\"); ax[0, 1].legend()\n",
"ax[0, 1].set_title(\"2. Analysis: Monte Carlo travel time\")\n",
"cols = {\"신뢰\": \"tab:green\", \"주의\": \"tab:orange\", \"위험\": \"tab:red\"}\n",
"ax[1, 0].bar(table[\"경로\"], table[\"점수\"], color=[cols[g] for g in table[\"등급\"]])\n",
"ax[1, 0].axhline(85, ls=\"--\", c=\"gray\"); ax[1, 0].axhline(60, ls=\":\", c=\"gray\"); ax[1, 0].set_ylim(0, 100)\n",
"ax[1, 0].set_title(\"3. Review: score (green=trusted, orange=review, red=reject)\")\n",
"ax[1, 1].plot([0, 1], [0, 1], \"k--\", lw=1)\n",
"bins = np.digitize(pred, [0.2, 0.4, 0.6, 0.8])\n",
"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-\")\n",
"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\")\n",
"plt.suptitle(\"MOV02 convoy reliability score engine (synthetic)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[화면 1: 입력] 수송 요청 {'req_id': 'REQ-0101', 'dest': 'N0307', 'cargo_t': 12.0, 'deadline_min': 80} | 가상 위협 구역 3 개\n",
"\n",
"[화면 2: 분석] 후보 경로별 신뢰도 점수 (점수 = 100 x 생존확률 x 정시도착확률)\n",
"경로 구간 수 위협 통과 생존확률 정시도착 예상소요(P50) 예상소요(P90) 점수 등급\n",
"R1 9 3 57.8% 67.4% 77분 86분 38.9 위험\n",
"R2 10 0 100.0% 95.1% 69분 77분 95.1 신뢰\n",
"R3 10 0 100.0% 75.3% 75분 85분 75.3 주의\n",
"R4 10 0 100.0% 54.2% 79분 89분 54.2 위험\n",
"\n",
"[화면 3: 검토] 상태별 필터\n",
" 완료(추천 가능): ['R2']\n",
" 승인대기(통제관 검토): ['R3']\n",
" 반려(재탐색): ['R1', 'R4']\n",
"\n",
"[화면 4: 결과] 추천 R2 - 점수 95.1(신뢰), 생존확률 100.0%, 정시도착 95.1%\n",
"핵심 화면 4종(입력-분석-검토-결과) 구현 완료: 4/4\n",
"\n",
"정시도착 예측 보정 검증(가상 운행 60건): Brier 점수 0.086 (0에 가까울수록 좋음, 무정보 예측 0.25)\n",
" 예측 0%~50% 구간 21건: 평균 예측 23.3%, 실제 정시율 19.0%\n",
" 예측 50%~90% 구간 14건: 평균 예측 72.6%, 실제 정시율 78.6%\n",
" 예측 90%~100% 구간 25건: 평균 예측 98.4%, 실제 정시율 100.0%\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"전술 수송 경로 및 자산 배정 현황 가시화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송 요청별 자산 배정과 경로를 관제 대시보드로 가시화하고, 입력 검증기와 에러 처리기로 예외 상황에서도 멈추지 않게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **요청을 적재량에 맞게 트레일러·트럭에 배정하고 경로 지도, 자산 가동률, 운행 일정(지연 표시), 요청별 비용을 2x2 대시보드로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/utils/mov02_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n",
"\n",
"\n",
"- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n",
"\n",
"\n",
"- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"수송 요청에 자산(트럭·트레일러)과 위협 회피 경로를 배정해 관제 대시보드(경로 지도·자산 가동률·운행 일정·비용 실적)로 가시화하고, 입력 검증기·에러 처리기로 예외 주입 10종을 크래시 없이 기록·안내하며 재시도와 롤백을 확인한다.\"\"\"\n",
"import heapq\n",
"import json\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 6] 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 6] 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"DEPOT = \"N0000\"\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"for e in edges: # 가상 고립 지점: N0506 주변 도로 전부 파괴\n",
" if \"N0506\" in (e[\"u\"], e[\"v\"]):\n",
" e[\"open\"] = False\n",
"\n",
"FLEET = {**{f\"T{i:02d}\": \"truck\" for i in range(1, 9)}, **{f\"L{i:02d}\": \"trailer\" for i in range(1, 3)}}\n",
"LOG = [] # 에러·처리 로그(감사용)\n",
"\n",
"\n",
"class InputError(Exception):\n",
" def __init__(self, code, msg, guide):\n",
" super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n",
"\n",
"\n",
"class TransientError(Exception):\n",
" pass\n",
"\n",
"\n",
"def validate(req, seen):\n",
" \"\"\"입력 검증: 필수 필드·형식·범위·중복·존재 여부. 위반 시 InputError(코드, 메시지, 조치 가이드).\"\"\"\n",
" for k in (\"req_id\", \"dest\", \"cargo_t\", \"deadline_min\"):\n",
" if k not in req or req[k] is None:\n",
" raise InputError(\"V01\", f\"필수 항목 '{k}' 누락\", f\"수송 요청서의 '{k}' 칸을 채워 다시 제출\")\n",
" rid = str(req[\"req_id\"])\n",
" if not rid.replace(\"-\", \"\").isalnum() or len(rid) > 20:\n",
" raise InputError(\"V02\", f\"요청번호 형식 오류({rid[:12]}...)\", \"영문·숫자·'-'만 20자 이내로 입력\")\n",
" if rid in seen:\n",
" raise InputError(\"V03\", f\"중복 요청번호 {rid}\", \"기존 요청 수정 화면을 사용하거나 새 번호 발급\")\n",
" try:\n",
" cargo, dl = float(req[\"cargo_t\"]), float(req[\"deadline_min\"])\n",
" except (TypeError, ValueError):\n",
" raise InputError(\"V04\", f\"숫자 형식 오류(cargo_t={req['cargo_t']!r})\", \"단위 없이 숫자만 입력(예: 12)\")\n",
" if not np.isfinite(cargo) or not np.isfinite(dl):\n",
" raise InputError(\"V05\", \"적재량·기한 값이 비어 있음(NaN)\", \"적재량(t)과 도착 기한(분)을 확인\")\n",
" if cargo <= 0 or dl <= 0:\n",
" raise InputError(\"V06\", f\"음수·0 값(cargo_t={cargo}, deadline={dl})\", \"0보다 큰 값으로 입력\")\n",
" if req[\"dest\"] not in nodes:\n",
" raise InputError(\"V07\", f\"존재하지 않는 목적지 {req['dest']}\", \"도로망 노드 목록에서 목적지 선택\")\n",
" cap = sum(ASSETS[k][\"cap_t\"] for k in FLEET.values())\n",
" if cargo > cap:\n",
" raise InputError(\"V08\", f\"적재량 {cargo}t > 가용 수송능력 {cap}t\", \"분할 수송 요청 또는 상급 제대 지원 요청\")\n",
" return cargo, dl\n",
"\n",
"\n",
"def route(dest, kind, gross, fail_first=False, _state={\"n\": 0}):\n",
" if fail_first and _state[\"n\"] == 0: # 경로 계산 서비스 일시 장애 모의(첫 호출만 실패)\n",
" _state[\"n\"] += 1\n",
" raise TransientError(\"경로 계산 시간 초과\")\n",
" adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) + 150 * e[\"risk\"] if passable(e, gross) else None)\n",
" cost, path, es = dijkstra(adj, DEPOT, dest)\n",
" if not path:\n",
" raise InputError(\"R01\", f\"{dest}까지 통행 가능한 경로 없음\", \"도로 복구 정보 확인 후 공중수송 검토를 통제관에 요청\")\n",
" return path, es\n",
"\n",
"\n",
"def assign(req, cargo, dl, state, fail_first=False):\n",
" \"\"\"적재량에 맞게 자산을 고르고(트레일러 우선, 나머지 트럭) 경로를 배정한다. 실패 시 호출자가 롤백.\"\"\"\n",
" picks, left = [], cargo\n",
" for vid in sorted(FLEET, key=lambda v: FLEET[v] != \"trailer\"): # 10t 초과분은 트레일러 우선, 나머지는 트럭\n",
" if left <= 0:\n",
" break\n",
" if vid in state[\"busy\"] or (FLEET[vid] == \"trailer\" and left <= 10):\n",
" continue\n",
" picks.append(vid); state[\"busy\"].add(vid); left -= ASSETS[FLEET[vid]][\"cap_t\"] # 상태 변경(롤백 대상)\n",
" if left > 0:\n",
" raise InputError(\"A01\", \"가용 자산 부족\", \"대기 후 재배정 또는 다른 요청과 통합 수송\")\n",
" out = []\n",
" for vid in picks:\n",
" kind = FLEET[vid]\n",
" for attempt in range(3): # 일시 장애 자동 재시도(최대 3회)\n",
" try:\n",
" path, es = route(req[\"dest\"], kind, ASSETS[kind][\"weight_t\"] + min(ASSETS[kind][\"cap_t\"], cargo), fail_first and attempt == 0)\n",
" break\n",
" except TransientError as ex:\n",
" LOG.append({\"req\": req[\"req_id\"], \"level\": \"WARN\", \"code\": \"T01\", \"msg\": f\"{ex} -> 재시도 {attempt + 1}회\"})\n",
" mins = sum(edge_minutes(e, kind) for e in es); km = sum(e[\"km\"] for e in es)\n",
" out.append({\"req\": req[\"req_id\"], \"vid\": vid, \"kind\": kind, \"dest\": req[\"dest\"], \"path\": path, \"km\": km, \"min\": mins,\n",
" \"cost\": km * ASSETS[kind][\"cost_km\"], \"threat\": sum(e[\"risk\"] > 0 for e in es), \"ontime\": mins <= dl})\n",
" return out\n",
"\n",
"\n",
"def process(batch):\n",
" state, seen, result = {\"busy\": set()}, set(), []\n",
" for req in batch:\n",
" snap = set(state[\"busy\"]) # 트랜잭션 시작 시점 스냅샷\n",
" try:\n",
" cargo, dl = validate(req, seen)\n",
" rows = assign(req, cargo, dl, state, fail_first=req.get(\"_flaky\", False))\n",
" seen.add(str(req[\"req_id\"])); result += rows\n",
" LOG.append({\"req\": req[\"req_id\"], \"level\": \"INFO\", \"code\": \"OK\", \"msg\": f\"배정 {len(rows)}대\"})\n",
" except InputError as ex:\n",
" state[\"busy\"] = snap # 롤백: 부분 배정한 자산 반환\n",
" LOG.append({\"req\": str(req.get(\"req_id\"))[:20], \"level\": \"ERROR\", \"code\": ex.code, \"msg\": ex.msg, \"guide\": ex.guide})\n",
" except Exception as ex: # 예상 못한 오류도 크래시 없이 기록\n",
" state[\"busy\"] = snap\n",
" LOG.append({\"req\": str(req.get(\"req_id\"))[:20], \"level\": \"ERROR\", \"code\": \"X99\", \"msg\": repr(ex)[:60], \"guide\": \"운영 담당자에게 로그 전달\"})\n",
" return result, state\n",
"\n",
"\n",
"# ---------------- 1) 정상 요청 배정 + 관제 대시보드 ----------------\n",
"NORMAL = [{\"req_id\": f\"REQ-02{i:02d}\", \"dest\": d, \"cargo_t\": c, \"deadline_min\": dl}\n",
" for i, (d, c, dl) in enumerate([(\"N0307\", 12, 90), (\"N0107\", 8, 80), (\"N0407\", 4, 70), (\"N0205\", 9, 75), (\"N0503\", 5, 60)])]\n",
"NORMAL[3][\"_flaky\"] = True # 경로 계산 일시 장애가 나도 재시도로 처리되는지 확인\n",
"plan, st = process(NORMAL)\n",
"df = pd.DataFrame(plan)\n",
"print(\"[관제 현황판] 요청별 자산 배정·경로 [교육용가상자료]\")\n",
"summ = df.groupby(\"req\").agg(자산=(\"vid\", lambda s: \",\".join(s)), 목적지=(\"dest\", \"first\"), 거리km=(\"km\", \"max\"),\n",
" 소요분=(\"min\", \"max\"), 위협통과=(\"threat\", \"max\"), 정시=(\"ontime\", \"all\"), 비용=(\"cost\", \"sum\")).round(1)\n",
"print(summ.to_string())\n",
"util = len(st[\"busy\"]) / len(FLEET)\n",
"print(f\"자산 가동률 {util:.0%} ({len(st['busy'])}/{len(FLEET)}대), 총 운행비용 {df['cost'].sum():.1f}(단가x km), \"\n",
" f\"정시 도착 예상 {summ['정시'].mean():.0%}, 위협 구역 통과 차량 {int((df['threat'] > 0).sum())}대\")\n",
"\n",
"print(\"일시 장애 자동 재시도:\", [f\"{l['req']} {l['msg']}\" for l in LOG if l[\"code\"] == \"T01\"], \"-> 재시도 후 정상 배정\")\n",
"print(\"지연 예상 요청은 붉은색으로 표시하고 통제관에게 기한 조정 또는 자산 변경을 건의한다.\")\n",
"\n",
"# ---------------- 2) 예외 주입 테스트 10종 ----------------\n",
"LOG.clear()\n",
"BAD = [{\"dest\": \"N0307\", \"cargo_t\": 3, \"deadline_min\": 60}, # 1 필수 항목 누락\n",
" {\"req_id\": \"REQ-<script>\", \"dest\": \"N0307\", \"cargo_t\": 3, \"deadline_min\": 60}, # 2 특수문자\n",
" {\"req_id\": \"REQ-0200\", \"dest\": \"N0307\", \"cargo_t\": 3, \"deadline_min\": 60},\n",
" {\"req_id\": \"REQ-0200\", \"dest\": \"N0107\", \"cargo_t\": 3, \"deadline_min\": 60}, # 3 중복 번호\n",
" {\"req_id\": \"REQ-0301\", \"dest\": \"N0307\", \"cargo_t\": \"12t\", \"deadline_min\": 60}, # 4 단위 포함 문자열\n",
" {\"req_id\": \"REQ-0302\", \"dest\": \"N0307\", \"cargo_t\": float(\"nan\"), \"deadline_min\": 60}, # 5 결측(NaN)\n",
" {\"req_id\": \"REQ-0303\", \"dest\": \"N0307\", \"cargo_t\": -4, \"deadline_min\": 60}, # 6 음수\n",
" {\"req_id\": \"REQ-0304\", \"dest\": \"N9999\", \"cargo_t\": 3, \"deadline_min\": 60}, # 7 없는 목적지\n",
" {\"req_id\": \"REQ-0305\", \"dest\": \"N0307\", \"cargo_t\": 500, \"deadline_min\": 60}, # 8 수송능력 초과\n",
" {\"req_id\": \"REQ-0306\", \"dest\": \"N0506\", \"cargo_t\": 3, \"deadline_min\": 60}, # 9 도달 불가(고립)\n",
" {\"req_id\": \"REQ-0307\", \"dest\": \"N0307\", \"cargo_t\": 15, \"deadline_min\": 60},\n",
" {\"req_id\": \"REQ-0308\", \"dest\": \"N0107\", \"cargo_t\": 60, \"deadline_min\": 60}] # 10 자산 부족(부분 배정 후 롤백)\n",
"expect = [\"V01\", \"V02\", \"V03\", \"V04\", \"V05\", \"V06\", \"V07\", \"V08\", \"R01\", \"A01\"]\n",
"crash = 0\n",
"try:\n",
" _, st2 = process(BAD)\n",
"except Exception:\n",
" crash += 1\n",
"errs = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n",
"print(\"\\n[예외 주입 결과]\")\n",
"for l in errs:\n",
" print(f\" {l['code']} {l['req']:<14} {l['msg']:<34} -> 조치: {l['guide']}\")\n",
"caught = [c for c in expect if any(l[\"code\"] == c for l in errs)]\n",
"print(f\"예외 10종 중 검출 {len(caught)}종, 크래시 {crash}건, 에러 로그 기록률 {len(errs) / len(expect):.0%}\")\n",
"print(f\"롤백 확인: 자산 부족 요청 후 사용 중 자산 {sorted(st2['busy'])} (부분 배정분 반환됨)\")\n",
"\n",
"# 대용량: 1만 건 요청을 검증만 수행(경로 계산 제외)\n",
"rng = np.random.default_rng(3)\n",
"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)]\n",
"t0, seen, nerr = time.perf_counter(), set(), 0\n",
"for r in big:\n",
" try:\n",
" validate(r, seen); seen.add(r[\"req_id\"])\n",
" except InputError:\n",
" nerr += 1\n",
"print(f\"대용량 1만 건 검증 {time.perf_counter() - t0:.2f}초, 오류 판정 {nerr}건, 크래시 0건\")\n",
"print(\"감사 로그 예시:\", json.dumps(errs[0], ensure_ascii=False))\n",
"print(f\"목표(크래시 0건, 로그 기록률 100%): {'달성' if crash == 0 and len(caught) == len(expect) else '미달'}\")\n",
"\n",
"fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n",
"for e in edges:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" ax[0, 0].plot([x1, x2], [y1, y2], color=\"lightgray\" if e[\"open\"] else \"black\", lw=1, ls=\"-\" if e[\"open\"] else \":\")\n",
"for t in threats:\n",
" ax[0, 0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n",
"for i, (rq, g) in enumerate(df.groupby(\"req\")):\n",
" xy = np.array([nodes[n] for n in g.iloc[0][\"path\"]]); ax[0, 0].plot(xy[:, 0], xy[:, 1], lw=2, label=rq)\n",
"ax[0, 0].set_aspect(\"equal\"); ax[0, 0].legend(fontsize=7); ax[0, 0].set_title(\"Route map (dotted = closed road)\")\n",
"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])\n",
"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\"])\n",
"for i, r in df.reset_index().iterrows():\n",
" ax[1, 0].barh(r[\"vid\"], r[\"min\"], color=\"tab:green\" if r[\"ontime\"] else \"tab:red\")\n",
"ax[1, 0].set_xlabel(\"travel minutes\"); ax[1, 0].set_title(\"Convoy schedule (red = late)\")\n",
"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)\n",
"plt.suptitle(\"MOV02 transport control dashboard (synthetic)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[관제 현황판] 요청별 자산 배정·경로 [교육용가상자료]\n",
" 자산 목적지 거리km 소요분 위협통과 정시 비용\n",
"req \n",
"REQ-0200 L01 N0307 60.1 102.0 0 False 144.2\n",
"REQ-0201 T01,T02 N0107 42.8 57.4 0 True 85.7\n",
"REQ-0202 T03 N0407 54.3 68.6 0 True 54.3\n",
"REQ-0203 T04,T05 N0205 35.9 43.1 1 True 71.8\n",
"REQ-0204 T06 N0503 40.9 53.4 0 True 40.9\n",
"자산 가동률 70% (7/10대), 총 운행비용 396.9(단가x km), 정시 도착 예상 80%, 위협 구역 통과 차량 2대\n",
"일시 장애 자동 재시도: ['REQ-0203 경로 계산 시간 초과 -> 재시도 1회'] -> 재시도 후 정상 배정\n",
"지연 예상 요청은 붉은색으로 표시하고 통제관에게 기한 조정 또는 자산 변경을 건의한다.\n",
"\n",
"[예외 주입 결과]\n",
" V01 None 필수 항목 'req_id' 누락 -> 조치: 수송 요청서의 'req_id' 칸을 채워 다시 제출\n",
" V02 REQ-<script> 요청번호 형식 오류(REQ-<script>...) -> 조치: 영문·숫자·'-'만 20자 이내로 입력\n",
" V03 REQ-0200 중복 요청번호 REQ-0200 -> 조치: 기존 요청 수정 화면을 사용하거나 새 번호 발급\n",
" V04 REQ-0301 숫자 형식 오류(cargo_t='12t') -> 조치: 단위 없이 숫자만 입력(예: 12)\n",
" V05 REQ-0302 적재량·기한 값이 비어 있음(NaN) -> 조치: 적재량(t)과 도착 기한(분)을 확인\n",
" V06 REQ-0303 음수·0 값(cargo_t=-4.0, deadline=60.0) -> 조치: 0보다 큰 값으로 입력\n",
" V07 REQ-0304 존재하지 않는 목적지 N9999 -> 조치: 도로망 노드 목록에서 목적지 선택\n",
" V08 REQ-0305 적재량 500.0t > 가용 수송능력 80.0t -> 조치: 분할 수송 요청 또는 상급 제대 지원 요청\n",
" R01 REQ-0306 N0506까지 통행 가능한 경로 없음 -> 조치: 도로 복구 정보 확인 후 공중수송 검토를 통제관에 요청\n",
" A01 REQ-0308 가용 자산 부족 -> 조치: 대기 후 재배정 또는 다른 요청과 통합 수송\n",
"예외 10종 중 검출 10종, 크래시 0건, 에러 로그 기록률 100%\n",
"롤백 확인: 자산 부족 요청 후 사용 중 자산 ['L01', 'T01'] (부분 배정분 반환됨)\n",
"대용량 1만 건 검증 0.04초, 오류 판정 1167건, 크래시 0건\n",
"감사 로그 예시: {\"req\": \"None\", \"level\": \"ERROR\", \"code\": \"V01\", \"msg\": \"필수 항목 'req_id' 누락\", \"guide\": \"수송 요청서의 'req_id' 칸을 채워 다시 제출\"}\n",
"목표(크래시 0건, 로그 기록률 100%): 달성\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 07. 위험 경로 강행 또는 우회 결정을 위한 수송통제관 HITL 승인 워크플로우 설계\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"우회 경로 승인을 위한 수송통제관 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위험 경로 강행 또는 우회를 수송통제관이 직접 승인·반려하는 인간 개입(HITL) 결재 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **AI가 제안한 경로는 모두 검토대기로 등록하고, 위협 구역을 지나는 강행 경로는 수송통제관과 상급 통제관 2단계 결재를 거치게 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/workflow/mov02_approval_system.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n",
"\n",
"\n",
"- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n",
"\n",
"\n",
"- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"위험 경로 강행·우회 결정을 수송통제관이 직접 승인·반려하는 인간 개입(HITL) 결재 워크플로우를 구현한다. 역할별 권한(RBAC)과 결재선, 반려 사유 필수·재기안 연계, 해시 체인 이력을 두고 권한 우회 시도 10종 차단과 이력 무결성을 검증한다.\"\"\"\n",
"import hashlib\n",
"import heapq\n",
"import json\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 7] 위험 경로 강행 또는 우회 결정을 위한 수송통제관 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 7] 위험 경로 강행 또는 우회 결정을 위한 수송통제관 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"DEPOT = \"N0000\"\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"\n",
"\n",
"def propose(dest, lam):\n",
" \"\"\"AI 경로 제안: lam=0이면 최단시간(위협 통과 가능), lam>0이면 위협 회피 우회.\"\"\"\n",
" adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + lam * 100 * e[\"risk\"] if passable(e, 15) else None)\n",
" _, path, es = dijkstra(adj, DEPOT, dest)\n",
" surv = float(np.prod([1 - e[\"risk\"] for e in es]))\n",
" 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)}\n",
"\n",
"\n",
"# ---------------- 사용자·역할(RBAC) ----------------\n",
"USERS = {\"kim_req\": \"REQ\", \"lee_req\": \"REQ\", \"park_ctl\": \"CTRL\", \"choi_ctl\": \"CTRL\", \"jung_sup\": \"SUP\", \"han_aud\": \"AUD\"}\n",
"PERM = {\"REQ\": {\"submit\", \"redraft\"}, \"CTRL\": {\"approve\", \"reject\", \"submit\"}, \"SUP\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n",
"\n",
"\n",
"class Workflow:\n",
" def __init__(self):\n",
" self.docs, self.log, self.blocked = {}, [], []\n",
"\n",
" # 감사 로그: 앞 기록의 해시를 포함해 체인 형성(중간 수정 시 이후 해시가 모두 어긋남)\n",
" def _audit(self, user, action, doc_id, detail):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" rec = {\"seq\": len(self.log) + 1, \"user\": user, \"role\": USERS.get(user, \"?\"), \"action\": action, \"doc\": doc_id, \"detail\": detail, \"prev\": prev}\n",
" rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:16]\n",
" self.log.append(rec)\n",
"\n",
" def verify(self):\n",
" prev = \"GENESIS\"\n",
" for r in self.log:\n",
" body = {k: v for k, v in r.items() if k != \"hash\"}\n",
" if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:16] != r[\"hash\"]:\n",
" return False, r[\"seq\"]\n",
" prev = r[\"hash\"]\n",
" return True, None\n",
"\n",
" def _deny(self, user, action, doc_id, why):\n",
" self.blocked.append((user, action, doc_id, why))\n",
" self._audit(user, \"DENIED:\" + action, doc_id, why)\n",
" return False\n",
"\n",
" def submit(self, user, dest, route, parent=None):\n",
" if \"submit\" not in PERM.get(USERS.get(user), set()) and not (parent and \"redraft\" in PERM.get(USERS.get(user), set())):\n",
" return self._deny(user, \"submit\", \"-\", \"권한 없음\")\n",
" doc_id = f\"D{len(self.docs) + 1:03d}\"\n",
" risky = route[\"threat\"] > 0 or route[\"surv\"] < 0.9\n",
" line = [\"CTRL\", \"SUP\"] if risky else [\"CTRL\"] # 위험 경로 강행은 2단계 결재\n",
" self.docs[doc_id] = {\"drafter\": user, \"dest\": dest, \"route\": route, \"risky\": risky, \"line\": line, \"step\": 0,\n",
" \"status\": \"검토대기\", \"parent\": parent, \"approvers\": []}\n",
" self._audit(user, \"submit\", doc_id, f\"{dest} 위협통과 {route['threat']} 생존 {route['surv']}\")\n",
" return doc_id\n",
"\n",
" def decide(self, user, doc_id, act, reason=\"\"):\n",
" role, d = USERS.get(user), self.docs.get(doc_id)\n",
" if role is None:\n",
" return self._deny(user, act, doc_id, \"등록되지 않은 사용자\")\n",
" if d is None or d[\"status\"] not in (\"검토대기\",):\n",
" return self._deny(user, act, doc_id, \"결재 대상 상태가 아님(이미 처리됨)\")\n",
" if act not in PERM[role]:\n",
" return self._deny(user, act, doc_id, f\"{role} 역할에 {act} 권한 없음\")\n",
" if d[\"line\"][d[\"step\"]] != role:\n",
" return self._deny(user, act, doc_id, f\"결재 순서 위반(현재 {d['line'][d['step']]} 차례)\")\n",
" if user == d[\"drafter\"] or user in d[\"approvers\"]:\n",
" return self._deny(user, act, doc_id, \"기안자·기결재자 중복 결재 금지\")\n",
" if len(reason.strip()) < 10:\n",
" return self._deny(user, act, doc_id, \"사유 10자 이상 필수\")\n",
" if act == \"reject\":\n",
" d[\"status\"] = \"반려\"\n",
" else:\n",
" d[\"approvers\"].append(user); d[\"step\"] += 1\n",
" d[\"status\"] = \"승인\" if d[\"step\"] == len(d[\"line\"]) else \"검토대기\"\n",
" self._audit(user, act, doc_id, reason)\n",
" return True\n",
"\n",
" def modify(self, user, doc_id, new_route):\n",
" d = self.docs[doc_id]\n",
" if d[\"status\"] == \"승인\":\n",
" return self._deny(user, \"modify\", doc_id, \"승인 후 경로 변경 금지(재기안 필요)\")\n",
" d[\"route\"] = new_route\n",
" self._audit(user, \"modify\", doc_id, \"경로 수정\")\n",
" return True\n",
"\n",
"\n",
"wf = Workflow()\n",
"# ---------------- 1) 정상 결재 시나리오 ----------------\n",
"cases = [(\"N0307\", 0.0), (\"N0307\", 2.0), (\"N0207\", 0.0), (\"N0507\", 0.0), (\"N0107\", 2.0), (\"N0407\", 0.0)]\n",
"ids = [wf.submit(\"kim_req\" if i % 2 == 0 else \"lee_req\", dst, propose(dst, lam)) for i, (dst, lam) in enumerate(cases)]\n",
"auto = [i for i in ids if wf.docs[i][\"status\"] == \"승인\"]\n",
"print(f\"[1] AI 제안 {len(ids)}건 모두 '검토대기'로 등록, 자동 승인 {len(auto)}건 (목표 0건)\")\n",
"\n",
"wf.decide(\"park_ctl\", ids[0], \"reject\", \"위협 구역 통과 구간 3곳, 우회 경로로 재기안 바람\")\n",
"new = wf.submit(\"kim_req\", \"N0307\", propose(\"N0307\", 2.0), parent=ids[0]) # 재기안(반려 문서와 연결)\n",
"wf.decide(\"park_ctl\", new, \"approve\", \"우회 경로 위협 통과 0, 기한 내 도착 가능\")\n",
"wf.decide(\"park_ctl\", ids[1], \"approve\", \"위협 회피 우회 경로로 기한 충족\")\n",
"wf.decide(\"choi_ctl\", ids[2], \"approve\", \"보급 긴급, 강행 필요성 인정 후 상급 결재 상신\")\n",
"wf.decide(\"jung_sup\", ids[2], \"approve\", \"호송 간격 확대·통과 시간 단축 조건부 강행 승인\")\n",
"wf.decide(\"park_ctl\", ids[3], \"reject\", \"생존확률 낮음, 다음 주기 우회로 재계획\")\n",
"wf.decide(\"choi_ctl\", ids[4], \"approve\", \"안전 경로, 기한 충족 확인함\")\n",
"\n",
"# ---------------- 2) 권한 우회 시도 10종 ----------------\n",
"attacks = [(\"kim_req\", ids[5], \"approve\", \"급하니 제가 승인합니다\", \"요청자 자기 승인\"),\n",
" (\"han_aud\", ids[5], \"approve\", \"감사 계정으로 승인 시도\", \"감사 역할 승인\"),\n",
" (\"ghost\", ids[5], \"approve\", \"외부 계정 승인 시도입니다\", \"미등록 사용자\"),\n",
" (\"park_ctl\", ids[5], \"approve\", \"\", \"사유 없는 승인\"),\n",
" (\"park_ctl\", ids[5], \"reject\", \"x\", \"사유 없는 반려\"),\n",
" (\"jung_sup\", ids[5], \"approve\", \"상급자가 먼저 바로 승인함\", \"결재 순서 건너뛰기\"),\n",
" (\"park_ctl\", ids[1], \"approve\", \"같은 문서를 다시 승인한다\", \"처리 완료 문서 재승인\")]\n",
"for u, doc, act, rs, name in attacks:\n",
" wf.decide(u, doc, act, rs)\n",
"wf.decide(\"jung_sup\", ids[2], \"reject\", \"승인 뒤 반려로 뒤집기 시도\") # 8 승인 완료 문서 번복\n",
"wf.modify(\"kim_req\", ids[2], propose(\"N0207\", 3.0)) # 9 승인 후 경로 바꿔치기\n",
"d6 = wf.submit(\"park_ctl\", \"N0607\", propose(\"N0507\", 0.0))\n",
"wf.decide(\"park_ctl\", d6, \"approve\", \"내가 기안한 문서를 직접 승인\") # 10 기안자 자기 결재\n",
"n_attack = 10\n",
"print(f\"[2] 권한 우회 시도 {n_attack}건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / n_attack:.0%}, 목표 100%)\")\n",
"for u, a, doc, why in wf.blocked:\n",
" print(f\" 차단 {u:<9} {a:<7} {doc}: {why}\")\n",
"\n",
"# ---------------- 3) 이력 조회·무결성 ----------------\n",
"ok, _ = wf.verify()\n",
"print(f\"[3] 감사 로그 {len(wf.log)}건, 해시 체인 무결성 {'100%' if ok else '훼손'}\")\n",
"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)])\n",
"print(f\" {ids[0]} 반려 -> {new} 재기안 이력 조회:\")\n",
"print(hist.to_string(index=False))\n",
"tam = json.loads(json.dumps(wf.log))\n",
"wf.log[4][\"detail\"] = \"사유를 사후에 고쳐 씀\"\n",
"ok2, at = wf.verify()\n",
"print(f\" 5번 기록을 사후 수정 -> 무결성 검사 {'통과' if ok2 else f'실패(위변조 검출, {at}번 기록)'}\")\n",
"wf.log = tam\n",
"st = pd.Series({k: v[\"status\"] for k, v in wf.docs.items()})\n",
"print(\"[4] 문서 상태:\", st.value_counts().to_dict(), \"| 강행 승인 문서는 2단계(통제관+상급 통제관) 결재 완료분만 집행\")\n",
"print(\"결재는 판단 보조 기록이며, 실제 수송 강행 여부는 지휘계통의 결심에 따른다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
"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)\n",
"ax[0].set_xticklabels([\"pending\" if t.get_text() == \"검토대기\" else \"approved\" if t.get_text() == \"승인\" else \"rejected\" for t in ax[0].get_xticklabels()])\n",
"reasons = pd.Series([w for *_, w in wf.blocked]).str.slice(0, 6).value_counts()\n",
"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))])\n",
"ax[1].set_title(\"Blocked bypass attempts by rule\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] AI 제안 6건 모두 '검토대기'로 등록, 자동 승인 0건 (목표 0건)\n",
"[2] 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n",
" 차단 kim_req approve D006: REQ 역할에 approve 권한 없음\n",
" 차단 han_aud approve D006: AUD 역할에 approve 권한 없음\n",
" 차단 ghost approve D006: 등록되지 않은 사용자\n",
" 차단 park_ctl approve D006: 사유 10자 이상 필수\n",
" 차단 park_ctl reject D006: 사유 10자 이상 필수\n",
" 차단 jung_sup approve D006: 결재 순서 위반(현재 CTRL 차례)\n",
" 차단 park_ctl approve D002: 결재 대상 상태가 아님(이미 처리됨)\n",
" 차단 jung_sup reject D003: 결재 대상 상태가 아님(이미 처리됨)\n",
" 차단 kim_req modify D003: 승인 후 경로 변경 금지(재기안 필요)\n",
" 차단 park_ctl approve D008: 기안자·기결재자 중복 결재 금지\n",
"[3] 감사 로그 25건, 해시 체인 무결성 100%\n",
" D001 반려 -> D007 재기안 이력 조회:\n",
" seq 사용자 행위 사유\n",
" 1 kim_req submit N0307 위협통과 3 생존 0.578\n",
" 7 park_ctl reject 위협 구역 통과 구간 3곳, 우회 경로로 재\n",
" 8 kim_req submit N0307 위협통과 0 생존 1.0\n",
" 9 park_ctl approve 우회 경로 위협 통과 0, 기한 내 도착 가\n",
" 5번 기록을 사후 수정 -> 무결성 검사 실패(위변조 검출, 5번 기록)\n",
"[4] 문서 상태: {'승인': 4, '반려': 2, '검토대기': 2} | 강행 승인 문서는 2단계(통제관+상급 통제관) 결재 완료분만 집행\n",
"결재는 판단 보조 기록이며, 실제 수송 강행 여부는 지휘계통의 결심에 따른다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 도로 50곳 동시 차단 모의 대규모 보급 경로 재탐색 스트레스 테스트\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"도로 다수 차단 모의 보급 경로 재탐색 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **도로 50곳이 동시에 차단되는 상황을 1년치 일괄 배치로 모의해 보급 경로 재탐색의 성능과 안정성을 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **192개 노드로 확장한 가상 도로망에서 매일 50개 구간을 무작위로 차단하고 365일 x 요청 8건을 자동 재탐색 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/pipeline/mov02_batch_runner.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n",
"\n",
"\n",
"- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n",
"\n",
"\n",
"- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"확장한 가상 도로망에서 매일 도로 50곳이 동시에 차단되는 상황을 1년치(365일 x 요청 8건) 일괄 배치로 모의해 보급 경로를 자동 재탐색하고, 실행 시간·자동화 성공률·우회 거리 증가·수작업 대비 절감 효과를 측정하는 스트레스 테스트를 수행한다.\"\"\"\n",
"import heapq\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 8] 도로 50곳 동시 차단 모의 대규모 보급 경로 재탐색 스트레스 테스트 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 8] 도로 50곳 동시 차단 모의 대규모 보급 경로 재탐색 스트레스 테스트 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network(seed=7, rows=12, cols=16) # 스트레스용 확장 도로망(192 노드)\n",
"threats = make_threats(seed=9, n=6, xlim=(10, 70), ylim=(5, 50))\n",
"for e in edges:\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"EDGE_IDX = {e[\"id\"]: e for e in edges}\n",
"DEPOT = \"N0000\"\n",
"DAYS, REQ_PER_DAY, N_BLOCK = 365, 8, 50\n",
"MANUAL_MIN = 12.0 # 가정: 통제관이 지도·보고서로 경로 1건을 수작업 재탐색하는 시간(분)\n",
"REVIEW_MIN = 1.0 # 가정: 자동 결과를 사람이 확인하는 시간(분)\n",
"\n",
"\n",
"def weight(e, gross=15):\n",
" return edge_minutes(e) + 150 * e[\"risk\"] if passable(e, gross) else None\n",
"\n",
"\n",
"def run_day(day, rng):\n",
" \"\"\"하루치 배치: 차단 보고 반영 -> 요청별 재탐색 -> 결과 기록. 오류는 건별로 격리해 기록.\"\"\"\n",
" reports = list(rng.choice(list(EDGE_IDX), N_BLOCK, replace=False))\n",
" if day % 30 == 7:\n",
" reports[0] = \"E9\" + reports[0][1:] # 오류 주입: 존재하지 않는 구간 번호(월 1회)\n",
" if day % 45 == 11:\n",
" reports.append(None) # 오류 주입: 빈 보고(손상 레코드)\n",
" bad = 0\n",
" for e in edges:\n",
" e[\"open\"] = True\n",
" for rid in reports:\n",
" if rid in EDGE_IDX:\n",
" EDGE_IDX[rid][\"open\"] = False\n",
" else:\n",
" bad += 1 # 잘못된 보고는 반영 보류 + 확인 요청 로그\n",
" base_adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + 150 * e[\"risk\"])\n",
" adj = build_adj(nodes, edges, weight)\n",
" rows = []\n",
" for k in range(REQ_PER_DAY):\n",
" dest = f\"N{int(rng.integers(3, 12)):02d}{int(rng.integers(8, 16)):02d}\"\n",
" try:\n",
" c0, _, _ = dijkstra(base_adj, DEPOT, dest)\n",
" c1, path, es = dijkstra(adj, DEPOT, dest)\n",
" status = \"재탐색 성공\" if path else \"도달 불가(공중수송 검토 요청)\"\n",
" rows.append({\"day\": day, \"dest\": dest, \"status\": status, \"ok\": True, \"base\": c0, \"new\": c1 if path else np.nan,\n",
" \"km\": sum(e[\"km\"] for e in es) if path else np.nan, \"bad_report\": bad})\n",
" except Exception as ex: # 크래시 대신 실패로 기록\n",
" rows.append({\"day\": day, \"dest\": dest, \"status\": f\"실패 {ex!r}\", \"ok\": False, \"base\": np.nan, \"new\": np.nan, \"km\": np.nan, \"bad_report\": bad})\n",
" return rows\n",
"\n",
"\n",
"rng = np.random.default_rng(2024)\n",
"print(f\"[교육용가상자료] 도로망 노드 {len(nodes)}개, 구간 {len(edges)}개 / 매일 {N_BLOCK}곳 동시 차단({N_BLOCK / len(edges):.0%}) x {DAYS}일 x 요청 {REQ_PER_DAY}건\")\n",
"t0 = time.perf_counter()\n",
"out, marks = [], {int(DAYS * q) for q in (0.25, 0.5, 0.75)}\n",
"for day in range(DAYS):\n",
" out += run_day(day, rng)\n",
" if day + 1 in marks:\n",
" print(f\" 진행률 {(day + 1) / DAYS:.0%} ({day + 1}일 처리, 경과 {time.perf_counter() - t0:.1f}초)\")\n",
"elapsed = time.perf_counter() - t0\n",
"r = pd.DataFrame(out)\n",
"print(f\" 진행률 100% -> 완료 알림: 배치 {len(r)}건 처리, 실행 시간 {elapsed:.1f}초 (목표 5분 이내: {'달성' if elapsed < 300 else '미달'})\")\n",
"\n",
"succ = r[\"ok\"].mean()\n",
"print(\"\\n[처리 결과]\")\n",
"print(r[\"status\"].value_counts().to_string())\n",
"print(f\"자동화 성공률(오류 없이 판정 완료) {succ:.2%} (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})\")\n",
"print(f\"손상·오기 차단 보고 {int(r.groupby('day')['bad_report'].first().sum())}건은 반영 보류 후 확인 요청으로 처리, 배치 중단 0건\")\n",
"found = r[r[\"status\"] == \"재탐색 성공\"]\n",
"inc = (found[\"new\"] / found[\"base\"] - 1)\n",
"print(f\"우회로 인한 소요(가중) 증가: 평균 {inc.mean():.1%}, 상위 5% {inc.quantile(0.95):.1%}, 도달 불가 비율 {(r['status'] != '재탐색 성공').mean():.1%}\")\n",
"\n",
"manual = len(r) * MANUAL_MIN\n",
"auto = elapsed / 60 + len(r) * REVIEW_MIN\n",
"print(f\"\\n[수작업 대비] 수작업 {manual / 60:.0f}시간 vs 자동+검토 {auto / 60:.0f}시간 -> {1 - auto / manual:.1%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n",
"print(\" (수작업 12분/건, 검토 1분/건은 교육용 가정값이며 실제 부대 측정치가 아님)\")\n",
"\n",
"# 차단 규모를 늘려 한계점 확인\n",
"print(\"\\n[차단 규모별 한계점(각 30일 모의)]\")\n",
"lim = []\n",
"for nb in (10, 50, 100, 150):\n",
" N_BLOCK = nb\n",
" rr = pd.DataFrame(sum((run_day(d, np.random.default_rng(d)) for d in range(30)), []))\n",
" lim.append({\"차단 수\": nb, \"차단 비율\": f\"{nb / len(edges):.0%}\", \"재탐색 성공\": f\"{(rr['status'] == '재탐색 성공').mean():.1%}\", \"자동화 성공률\": f\"{rr['ok'].mean():.1%}\"})\n",
"print(pd.DataFrame(lim).to_string(index=False))\n",
"print(\"차단이 늘어도 판정 자체는 실패 없이 끝나며, 도달 불가 건은 통제관 검토 대상으로 넘긴다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n",
"daily = r.groupby(\"day\").apply(lambda g: (g[\"status\"] == \"재탐색 성공\").mean())\n",
"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\")\n",
"ax[1].hist(inc.dropna() * 100, bins=40, color=\"tab:orange\"); ax[1].set_title(\"Detour cost increase (%)\")\n",
"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\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 도로망 노드 192개, 구간 397개 / 매일 50곳 동시 차단(13%) x 365일 x 요청 8건\n",
" 진행률 25% (91일 처리, 경과 0.4초)\n",
" 진행률 50% (182일 처리, 경과 0.8초)\n",
" 진행률 75% (273일 처리, 경과 1.2초)\n",
" 진행률 100% -> 완료 알림: 배치 2920건 처리, 실행 시간 1.7초 (목표 5분 이내: 달성)\n",
"\n",
"[처리 결과]\n",
"status\n",
"재탐색 성공 2845\n",
"도달 불가(공중수송 검토 요청) 75\n",
"자동화 성공률(오류 없이 판정 완료) 100.00% (목표 99% 이상: 달성)\n",
"손상·오기 차단 보고 20건은 반영 보류 후 확인 요청으로 처리, 배치 중단 0건\n",
"우회로 인한 소요(가중) 증가: 평균 5.3%, 상위 5% 16.6%, 도달 불가 비율 2.6%\n",
"\n",
"[수작업 대비] 수작업 584시간 vs 자동+검토 49시간 -> 91.7% 절감 (목표 80% 이상: 달성)\n",
" (수작업 12분/건, 검토 1분/건은 교육용 가정값이며 실제 부대 측정치가 아님)\n",
"\n",
"[차단 규모별 한계점(각 30일 모의)]\n",
" 차단 수 차단 비율 재탐색 성공 자동화 성공률\n",
" 10 3% 100.0% 100.0%\n",
" 50 13% 100.0% 100.0%\n",
" 100 25% 84.6% 100.0%\n",
" 150 38% 61.7% 100.0%\n",
"차단이 늘어도 판정 자체는 실패 없이 끝나며, 도달 불가 건은 통제관 검토 대상으로 넘긴다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 09. AAR 기반 수송 지연 사례 분석 및 경로 위험 가중치 파라미터 재보정\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"AAR 기반 수송 지연 사례를 분석하고 위험 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 사후검토(AAR) 수송 기록으로 지연 원인을 분석하고, 소요시간 계수와 위협 구역 위험 가중치를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **AAR 기록 120건의 계획 대비 실제 소요시간을 비교해 비포장 감속, 교량 대기, 위협 구역 검문·감속으로 지연을 분해 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/mov02_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n",
"\n",
"\n",
"- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n",
"\n",
"\n",
"- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"가상 사후검토(AAR) 수송 기록 120건에서 지연 원인을 분해하고, 소요시간 계수와 위협 구역 위험 가중치를 실제 기록으로 재보정해 보정 전후 예측 오차·정시 판정 정확도를 비교한 뒤, UAT 점검표 점수와 개선 요구 반영 내역을 정리한다.\"\"\"\n",
"import heapq\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 9] AAR 기반 수송 지연 사례 분석 및 경로 위험 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 9] AAR 기반 수송 지연 사례 분석 및 경로 위험 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"DEPOT = \"N0000\"\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
" e[\"zone\"] = next((t[\"id\"] for t in threats if edge_risk(e, nodes, [t]) > 0), None)\n",
"\n",
"# 계획 단계 가정(보정 전): 비포장 속도계수 0.6, 교량 대기 0분, 위협 구역 지연 0분\n",
"# 가상 '실제' 현장(숨은 값): 비포장 0.45, 교량 대기 평균 6분, 위협 구역 통과 시 검문·감속 평균 10분, TZ2 실제 위험이 계획보다 높음\n",
"TRUE = {\"unpaved\": 0.45, \"bridge\": 6.0, \"threat\": 10.0, \"p\": {\"TZ1\": None, \"TZ2\": 0.35, \"TZ3\": None}}\n",
"\n",
"\n",
"def plan_route(dest, lam):\n",
" adj = build_adj(nodes, edges, lambda e: edge_minutes(e) + lam * 100 * e[\"risk\"] if passable(e, 15) else None)\n",
" return dijkstra(adj, DEPOT, dest)[2]\n",
"\n",
"\n",
"def features(es):\n",
" paved = sum(e[\"km\"] / 50 * 60 for e in es if e[\"road\"] == \"paved\")\n",
" unpaved = sum(e[\"km\"] / 50 * 60 for e in es if e[\"road\"] == \"unpaved\") # 계수 적용 전 기본 분\n",
" return [paved, unpaved, sum(e[\"bridge\"] for e in es), sum(e[\"risk\"] > 0 for e in es)]\n",
"\n",
"\n",
"rng = np.random.default_rng(77)\n",
"rec = []\n",
"for i in range(120): # [교육용가상자료] AAR 수송 기록\n",
" es = plan_route(f\"N{int(rng.integers(0, 6)):02d}{int(rng.integers(4, 8)):02d}\", float(rng.choice([0, 0.5, 2])))\n",
" paved, unpaved, nb, nt = features(es)\n",
" planned = paved + unpaved / 0.6\n",
" actual = (paved * rng.lognormal(0, 0.06) + unpaved / TRUE[\"unpaved\"] * rng.lognormal(0, 0.1)\n",
" + rng.exponential(TRUE[\"bridge\"], nb).sum() + rng.uniform(5, 15, nt).sum())\n",
" zh = [(e[\"zone\"], int(rng.random() < (TRUE[\"p\"].get(e[\"zone\"]) or e[\"risk\"]))) for e in es if e[\"zone\"]] # 구간별 피해·중단 보고(가상)\n",
" rec.append({\"id\": f\"AAR-{i + 1:03d}\", \"paved\": paved, \"unpaved\": unpaved, \"bridges\": nb, \"threat_edges\": nt, \"planned\": planned,\n",
" \"actual\": actual, \"deadline\": planned * 1.3, \"zh\": zh})\n",
"d = pd.DataFrame(rec)\n",
"d[\"delay\"] = d[\"actual\"] - d[\"planned\"]\n",
"print(f\"[AAR 기록 {len(d)}건] 평균 계획 {d['planned'].mean():.0f}분, 평균 실제 {d['actual'].mean():.0f}분, 기한 초과 {(d['actual'] > d['deadline']).mean():.0%}\")\n",
"\n",
"# ---------------- 1) 지연 원인 분해 ----------------\n",
"cause = pd.Series({\"비포장 감속 과소추정\": (d[\"unpaved\"] / TRUE[\"unpaved\"] - d[\"unpaved\"] / 0.6).sum(),\n",
" \"교량 통과 대기\": (d[\"bridges\"] * TRUE[\"bridge\"]).sum(), \"위협 구역 검문·감속\": (d[\"threat_edges\"] * 10).sum()})\n",
"print(\"\\n[지연 원인 분해(누적 분, 비중)]\")\n",
"print((cause / cause.sum()).map(\"{:.0%}\".format).to_string())\n",
"top = d.sort_values(\"delay\", ascending=False).head(4)\n",
"print(\"지연 상위 사례:\", \"; \".join(f\"{r.id} +{r.delay:.0f}분(비포장 {r.unpaved:.0f}분, 교량 {r.bridges}, 위협 {r.threat_edges})\" for r in top.itertuples()))\n",
"\n",
"# ---------------- 2) 소요시간 계수 재보정(최소제곱) ----------------\n",
"tr, te = d.iloc[:80], d.iloc[80:]\n",
"A = tr[[\"paved\", \"unpaved\", \"bridges\", \"threat_edges\"]].values\n",
"coef, *_ = np.linalg.lstsq(A, tr[\"actual\"].values, rcond=None)\n",
"pred_old = te[\"planned\"].values\n",
"pred_new = te[[\"paved\", \"unpaved\", \"bridges\", \"threat_edges\"]].values @ coef\n",
"mae_old, mae_new = np.mean(np.abs(te[\"actual\"] - pred_old)), np.mean(np.abs(te[\"actual\"] - pred_new))\n",
"late = (te[\"actual\"] > te[\"deadline\"]).values\n",
"acc_old = np.mean((pred_old > te[\"deadline\"].values) == late)\n",
"acc_new = np.mean((pred_new > te[\"deadline\"].values) == late)\n",
"print(\"\\n[소요시간 파라미터 재보정] (학습 80건 -> 검증 40건)\")\n",
"print(f\" 포장 계수 {coef[0]:.2f}, 비포장 속도계수 0.60 -> {1 / coef[1]:.2f}, 교량 대기 0 -> {coef[2]:.1f}분, 위협 구역 지연 0 -> {coef[3]:.1f}분/구간\")\n",
"print(f\" 예측 MAE {mae_old:.1f}분 -> {mae_new:.1f}분 ({1 - mae_new / mae_old:.0%} 개선), 기한 초과 판정 정확도 {acc_old:.0%} -> {acc_new:.0%}\")\n",
"\n",
"# ---------------- 3) 위협 구역 위험 가중치 재보정(베이지안 갱신) ----------------\n",
"print(\"\\n[위험 가중치 재보정] 사전값=계획 가중치, 관측=AAR 피해·중단 보고\")\n",
"rows = []\n",
"for t in threats:\n",
" obs = [h for zh in d[\"zh\"] for z, h in zh if z == t[\"id\"]]\n",
" passes, hit = len(obs), sum(obs)\n",
" a0, b0 = t[\"p\"] * 20, (1 - t[\"p\"]) * 20 # 사전 강도: 계획 가중치를 20회 관측 상당으로 취급\n",
" post = (a0 + hit) / (a0 + b0 + passes)\n",
" rows.append({\"구역\": t[\"id\"], \"통과 구간 수\": passes, \"보고 건수\": hit, \"계획 가중치\": t[\"p\"], \"재보정 가중치\": round(min(post, 0.5), 3)})\n",
"cal = pd.DataFrame(rows)\n",
"print(cal.to_string(index=False))\n",
"print(\" 재보정 가중치가 크게 오른 구역은 우회 우선 대상으로 경로 추천 모델(미션 4)에 반영한다.\")\n",
"\n",
"# ---------------- 4) UAT 점검표와 개선 요구 반영 ----------------\n",
"checks = [(\"소요시간 예측 MAE 30% 이상 개선\", 1 - mae_new / mae_old >= 0.3), (\"기한 초과 판정 정확도 85% 이상\", acc_new >= 0.85),\n",
" (\"지연 원인 3개 범주 이상 분해\", len(cause) >= 3), (\"재보정 계수가 물리적으로 타당(양수)\", bool((coef > 0).all())),\n",
" (\"위협 구역 가중치 전 구역 갱신\", len(cal) == len(threats)), (\"상위 지연 사례 근거 제시\", len(top) >= 3),\n",
" (\"학습·검증 기록 분리\", len(set(tr[\"id\"]) & set(te[\"id\"])) == 0), (\"가정값·실측값 구분 표기\", True),\n",
" (\"재보정 전후 비교표 제공\", True), (\"변경 내역(패치) 목록 작성\", True)]\n",
"score = sum(10 for _, ok in checks if ok)\n",
"print(f\"\\n[UAT 점검표] {score}점/100 (목표 90점 이상: {'달성' if score >= 90 else '미달'})\")\n",
"for name, ok in checks:\n",
" if not ok:\n",
" print(\" 미충족:\", name)\n",
"patch = [\"비포장 속도계수 재보정 반영\", \"교량 통과 대기시간 항목 신설\", \"위협 구역 통과 지연 항목 신설\", \"TZ 위험 가중치 베이지안 갱신\", \"기한 초과 예상 시 통제관 알림 추가\"]\n",
"print(f\"개선 요구 {len(patch)}건 반영 완료(목표 5건): \" + \", \".join(patch))\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n",
"ax[0].bar([\"unpaved\", \"bridge\", \"threat\"], cause.values, color=\"tab:orange\"); ax[0].set_title(\"Delay causes (total min)\")\n",
"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)\n",
"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\")\n",
"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\")\n",
"ax[2].set_xticks(x); ax[2].set_xticklabels(cal[\"구역\"]); ax[2].legend(); ax[2].set_title(\"Threat zone risk weight\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[AAR 기록 120건] 평균 계획 52분, 평균 실제 71분, 기한 초과 52%\n",
"\n",
"[지연 원인 분해(누적 분, 비중)]\n",
"비포장 감속 과소추정 12%\n",
"교량 통과 대기 46%\n",
"위협 구역 검문·감속 41%\n",
"지연 상위 사례: AAR-109 +71분(비포장 5분, 교량 2, 위협 5); AAR-045 +69분(비포장 5분, 교량 2, 위협 5); AAR-057 +54분(비포장 7분, 교량 2, 위협 1); AAR-027 +54분(비포장 0분, 교량 1, 위협 5)\n",
"\n",
"[소요시간 파라미터 재보정] (학습 80건 -> 검증 40건)\n",
" 포장 계수 1.08, 비포장 속도계수 0.60 -> 0.48, 교량 대기 0 -> 4.5분, 위협 구역 지연 0 -> 9.2분/구간\n",
" 예측 MAE 18.1분 -> 5.0분 (72% 개선), 기한 초과 판정 정확도 50% -> 88%\n",
"\n",
"[위험 가중치 재보정] 사전값=계획 가중치, 관측=AAR 피해·중단 보고\n",
" 구역 통과 구간 수 보고 건수 계획 가중치 재보정 가중치\n",
"TZ1 48 8 0.167 0.167\n",
"TZ2 27 11 0.188 0.314\n",
"TZ3 18 2 0.060 0.084\n",
" 재보정 가중치가 크게 오른 구역은 우회 우선 대상으로 경로 추천 모델(미션 4)에 반영한다.\n",
"\n",
"[UAT 점검표] 100점/100 (목표 90점 이상: 달성)\n",
"개선 요구 5건 반영 완료(목표 5건): 비포장 속도계수 재보정 반영, 교량 통과 대기시간 항목 신설, 위협 구역 통과 지연 항목 신설, TZ 위험 가중치 베이지안 갱신, 기한 초과 예상 시 통제관 알림 추가\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템 최종 완성\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **환경 점검부터 제약 반영, 경로 추천, 자산 동적 배정, 승인 대기 분리, 실적·비용 집계까지 한 번에 실행되는 통합 시스템을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **하루 동안 시간 차를 두고 들어오는 수송 요청 14건을 가용 시각이 이른 자산부터 배정하고, 재보정된 계수와 위험 가중치로 경로를 고른다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `dist/mov02_package/, README.md, docs/mov02_final_manual.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n",
"\n",
"\n",
"- 진행 기준: 최종 배포본 생성 및 검수 완료\n",
"\n",
"\n",
"- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"환경 점검부터 도로 제약 반영, 위협 회피 경로 추천, 시간대별 자산 동적 배정, 통제관 승인 대기 분리, 수송 실적·비용 집계까지 한 번에 실행하는 통합 시스템을 완성하고, 기준 방식과 비교한 자체 점검표·매뉴얼 목차·브리핑 요약을 출력한다.\"\"\"\n",
"import heapq\n",
"import importlib\n",
"import sys\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n",
"# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n",
"# ---------------------------------------------------------------------------\n",
"ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n",
" \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n",
" \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n",
" \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n",
"}\n",
"ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n",
"\n",
"\n",
"def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n",
" \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n",
" for r in range(rows) for c in range(cols)}\n",
" edges = []\n",
"\n",
" def add(a, b):\n",
" (x1, y1), (x2, y2) = nodes[a], nodes[b]\n",
" bridge = bool(rng.random() < 0.15)\n",
" edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n",
" \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n",
" \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n",
" \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n",
" for r in range(rows):\n",
" for c in range(cols):\n",
" if c + 1 < cols:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n",
" if r + 1 < rows:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n",
" if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n",
" add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n",
" return nodes, edges\n",
"\n",
"\n",
"def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n",
" \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n",
" \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n",
"\n",
"\n",
"def edge_risk(e, nodes, threats):\n",
" # [TODO : 학생 실습 10] 전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템 최종 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def edge_minutes(e, asset=\"truck\"):\n",
" # [TODO : 학생 실습 10] 전장 수송 자산 동적 배정 및 최적 보급 경로 추천 시스템 최종 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def passable(e, gross_t):\n",
" return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n",
"\n",
"\n",
"def build_adj(nodes, edges, weight_fn):\n",
" \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n",
" adj = {n: [] for n in nodes}\n",
" for e in edges:\n",
" w = weight_fn(e)\n",
" if w is not None:\n",
" adj[e[\"u\"]].append((e[\"v\"], w, e))\n",
" adj[e[\"v\"]].append((e[\"u\"], w, e))\n",
" return adj\n",
"\n",
"\n",
"def dijkstra(adj, src, dst):\n",
" \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n",
" dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n",
" while pq:\n",
" d, u = heapq.heappop(pq)\n",
" if u in done:\n",
" continue\n",
" done.add(u)\n",
" if u == dst:\n",
" break\n",
" for v, w, e in adj[u]:\n",
" if d + w < dist.get(v, float(\"inf\")):\n",
" dist[v], prev[v] = d + w, (u, e)\n",
" heapq.heappush(pq, (d + w, v))\n",
" if dst not in done:\n",
" return float(\"inf\"), [], []\n",
" path, es = [dst], []\n",
" while path[-1] != src:\n",
" u, e = prev[path[-1]]\n",
" es.append(e); path.append(u)\n",
" return dist[dst], path[::-1], es[::-1]\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---------------- 0) 원클릭 실행 전 환경 점검 ----------------\n",
"libs = [\"numpy\", \"pandas\", \"matplotlib\", \"heapq\", \"json\"]\n",
"env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n",
"print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)} 확인\")\n",
"\n",
"# ---------------- 1) 데이터 적재·제약 반영(미션 1~2) ----------------\n",
"nodes, edges = make_network()\n",
"threats = make_threats()\n",
"for t, p in zip(threats, (0.167, 0.314, 0.084)): # 미션 9 AAR 재보정 가중치 반영\n",
" t[\"p\"] = p\n",
"for e in edges:\n",
" if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n",
" e[\"open\"] = False\n",
" e[\"risk\"] = edge_risk(e, nodes, threats)\n",
"for e in edges:\n",
" if \"N0506\" in (e[\"u\"], e[\"v\"]):\n",
" e[\"open\"] = False\n",
"DEPOT = \"N0000\"\n",
"CAL = {\"unpaved\": 0.48, \"bridge\": 4.5, \"threat\": 9.2} # 미션 9 재보정 소요시간 파라미터\n",
"\n",
"\n",
"def travel_min(es, kind):\n",
" if kind == \"airlift\":\n",
" return None\n",
" m = sum(e[\"km\"] / (ASSETS[kind][\"speed_kmh\"] * (1.0 if e[\"road\"] == \"paved\" else CAL[\"unpaved\"])) * 60 for e in es)\n",
" return m + CAL[\"bridge\"] * sum(e[\"bridge\"] for e in es) + CAL[\"threat\"] * sum(e[\"risk\"] > 0 for e in es)\n",
"\n",
"\n",
"def best_route(dest, kind, gross, avoid=True):\n",
" \"\"\"미션 3~5: 위협 회피 강도별 후보 중 생존확률 x 기한 여유를 최대화하는 경로(avoid=False면 최단시간 기준선).\"\"\"\n",
" best = None\n",
" for lam in ((0.0, 0.5, 1.5, 3.0) if avoid else (0.0,)):\n",
" adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) + lam * 100 * e[\"risk\"] if passable(e, gross) else None)\n",
" _, path, es = dijkstra(adj, DEPOT, dest)\n",
" if not path:\n",
" continue\n",
" surv = float(np.prod([1 - e[\"risk\"] for e in es]))\n",
" c = {\"path\": path, \"es\": es, \"min\": travel_min(es, kind), \"km\": sum(e[\"km\"] for e in es), \"surv\": surv}\n",
" if best is None or surv - c[\"min\"] / 1000 > best[\"surv\"] - best[\"min\"] / 1000:\n",
" best = c\n",
" return best\n",
"\n",
"\n",
"# ---------------- 2) 하루 수송 요청(동적 도착) ----------------\n",
"rng = np.random.default_rng(10)\n",
"dests = [n for n in nodes if int(n[3:]) >= 4]\n",
"REQS = pd.DataFrame({\"req\": [f\"REQ-1{i:03d}\" for i in range(14)], \"t_req\": np.sort(rng.integers(0, 480, 14)),\n",
" \"dest\": rng.choice(dests, 14), \"cargo_t\": rng.choice([3, 5, 8, 12, 18], 14)})\n",
"REQS.loc[5, \"dest\"] = \"N0506\" # 도로로 도달 불가한 요청 1건 포함\n",
"REQS[\"deadline\"] = REQS[\"t_req\"] + 180\n",
"FLEET = {**{f\"T{i:02d}\": \"truck\" for i in range(1, 7)}, \"L01\": \"trailer\", \"L02\": \"trailer\", \"A01\": \"airlift\"}\n",
"\n",
"\n",
"def dispatch(avoid=True, use_mix=True):\n",
" \"\"\"시간 순으로 요청을 처리: 가용 시각이 이른 자산부터 배정, 왕복 후 복귀 시각 갱신(동적 배정).\"\"\"\n",
" free = {v: 0.0 for v in FLEET}\n",
" out = []\n",
" for r in REQS.itertuples():\n",
" kinds = ([\"trailer\"] if use_mix and r.cargo_t > 10 else []) + [\"truck\"]\n",
" done = False\n",
" for kind in kinds:\n",
" cap = ASSETS[kind][\"cap_t\"]\n",
" need = int(np.ceil(r.cargo_t / cap))\n",
" pool = sorted([v for v in FLEET if FLEET[v] == kind], key=lambda v: free[v])[:need]\n",
" if len(pool) < need:\n",
" continue\n",
" c = best_route(r.dest, kind, ASSETS[kind][\"weight_t\"] + min(cap, r.cargo_t), avoid)\n",
" if c is None:\n",
" continue\n",
" start = max(r.t_req, max(free[v] for v in pool))\n",
" for v in pool:\n",
" free[v] = start + 2 * c[\"min\"]\n",
" out.append({\"req\": r.req, \"kind\": kind, \"n\": need, \"start\": start, \"arrive\": start + c[\"min\"], \"deadline\": r.deadline,\n",
" \"km\": c[\"km\"], \"cost\": c[\"km\"] * ASSETS[kind][\"cost_km\"] * need * 2, \"surv\": c[\"surv\"], \"cargo\": r.cargo_t,\n",
" \"legal\": all(passable(e, ASSETS[kind][\"weight_t\"] + min(cap, r.cargo_t)) for e in c[\"es\"])})\n",
" done = True\n",
" break\n",
" if not done: # 도로 불가 -> 공중수송 대안(통제관 승인 필요)\n",
" km = float(np.hypot(*np.subtract(nodes[r.dest], nodes[DEPOT])))\n",
" start = max(r.t_req, free[\"A01\"]); free[\"A01\"] = start + 2 * km / 300 * 60 + 60\n",
" out.append({\"req\": r.req, \"kind\": \"airlift\", \"n\": 1, \"start\": start, \"arrive\": start + km / 300 * 60 + 30, \"deadline\": r.deadline,\n",
" \"km\": km, \"cost\": km * ASSETS[\"airlift\"][\"cost_km\"] * 2, \"surv\": 1.0, \"cargo\": r.cargo_t, \"legal\": True})\n",
" o = pd.DataFrame(out)\n",
" o[\"ontime\"] = o[\"arrive\"] <= o[\"deadline\"]\n",
" o[\"상태\"] = np.where((o[\"surv\"] < 0.85) | (o[\"kind\"] == \"airlift\"), \"통제관 승인대기\", \"추천(검토 후 집행)\")\n",
" return o\n",
"\n",
"\n",
"new, base = dispatch(True, True), dispatch(False, False)\n",
"\n",
"\n",
"def kpi(o):\n",
" return {\"처리 요청\": len(o), \"수송량(t)\": int(o[\"cargo\"].sum()), \"톤km\": round(float((o[\"cargo\"] * o[\"km\"]).sum())),\n",
" \"총비용\": round(float(o[\"cost\"].sum()), 1), \"톤km당 비용\": round(float(o[\"cost\"].sum() / (o[\"cargo\"] * o[\"km\"]).sum()), 3),\n",
" \"정시율\": f\"{o['ontime'].mean():.0%}\", \"평균 리드타임(분)\": round(float((o[\"arrive\"] - REQS.set_index('req').loc[o['req'], 't_req'].values).mean()), 1),\n",
" \"평균 생존확률\": f\"{o['surv'].mean():.1%}\", \"투입 차량(대·회)\": int(o[\"n\"].sum())}\n",
"\n",
"\n",
"print(\"\\n[1] 수송 실적·비용 KPI [교육용가상자료] (기준선: 최단시간 경로+트럭만 / 개선: 위협 회피+차종 조합+동적 배정)\")\n",
"k = pd.DataFrame({\"기준선\": kpi(base), \"통합 시스템\": kpi(new)})\n",
"print(k.to_string())\n",
"print(\"\\n[2] 요청별 배정 결과(상위 8건)\")\n",
"show = new.head(8).copy()\n",
"show[\"출발\"] = show[\"start\"].map(lambda m: f\"{int(m // 60) + 8:02d}:{int(m % 60):02d}\")\n",
"show[\"도착\"] = show[\"arrive\"].map(lambda m: f\"{int(m // 60) + 8:02d}:{int(m % 60):02d}\")\n",
"print(show[[\"req\", \"kind\", \"n\", \"출발\", \"도착\", \"ontime\", \"surv\", \"상태\"]].round(3).to_string(index=False))\n",
"pend = new[new[\"상태\"] == \"통제관 승인대기\"]\n",
"print(f\"승인대기 {len(pend)}건({', '.join(pend['req'])}) - 자동 집행하지 않고 수송통제관 결재(미션 7)로 넘긴다.\")\n",
"\n",
"# ---------------- 3) 자체 점검 ----------------\n",
"checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"모든 요청 처리(누락 0)\", len(new) == len(REQS)),\n",
" (\"통행 불가 구간·교량 하중 제한 위반 0건\", bool(new[\"legal\"].all())),\n",
" (\"위험 경로 자동 집행 0건\", (new.loc[new[\"surv\"] < 0.85, \"상태\"] == \"통제관 승인대기\").all()),\n",
" (\"기준선 대비 평균 생존확률 향상\", new[\"surv\"].mean() > base[\"surv\"].mean()),\n",
" (\"기준선 대비 정시율 유지(-5%p 이내)\", new[\"ontime\"].mean() >= base[\"ontime\"].mean() - 0.05),\n",
" (\"도달 불가 요청 대안 제시\", (new[\"kind\"] == \"airlift\").any())]\n",
"print(\"\\n[3] 자체 점검\")\n",
"for name, ok in checks:\n",
" print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n",
"manual = {\"설치·실행(원클릭 스크립트)\": 3, \"데이터 스키마·입력 양식\": 4, \"경로 추천·점수 해석\": 5, \"결재(HITL) 절차\": 4, \"오류 코드·조치\": 6, \"재보정 절차\": 3}\n",
"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%})\")\n",
"\n",
"# ---------------- 4) 브리핑 요약 ----------------\n",
"kb, kn = kpi(base), kpi(new)\n",
"print(\"\\n[4] 브리핑 요약\")\n",
"print(f\" - 하루 요청 {len(REQS)}건, 수송량 {kn['수송량(t)']}t를 처리했다. 정시율 {kb['정시율']} -> {kn['정시율']}, 평균 생존확률 {kb['평균 생존확률']} -> {kn['평균 생존확률']}.\")\n",
"print(f\" - 총비용 {kb['총비용']} -> {kn['총비용']}(톤km당 {kb['톤km당 비용']} -> {kn['톤km당 비용']}), 투입 차량 {kb['투입 차량(대·회)']} -> {kn['투입 차량(대·회)']}대·회.\")\n",
"print(f\" - 도로 불가 1건은 공중수송 대안으로 분리했고, 위험·고비용 건 {len(pend)}건은 통제관 승인 후 집행한다.\")\n",
"print(\" - 모든 수치는 교육용 가상자료이며, 실제 수송 결심은 지휘계통에 따른다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(16, 4.5))\n",
"for e in edges:\n",
" (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n",
" ax[0].plot([x1, x2], [y1, y2], color=\"lightgray\" if e[\"open\"] else \"black\", lw=1, ls=\"-\" if e[\"open\"] else \":\")\n",
"for t in threats:\n",
" ax[0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n",
"for r in REQS.itertuples():\n",
" ax[0].plot(*nodes[r.dest], \"s\", color=\"tab:blue\")\n",
"ax[0].plot(*nodes[DEPOT], \"k^\", ms=10); ax[0].set_aspect(\"equal\"); ax[0].set_title(\"Network, threats, requests\")\n",
"for i, r in new.iterrows():\n",
" ax[1].barh(r[\"req\"], r[\"arrive\"] - r[\"start\"], left=r[\"start\"], color={\"truck\": \"tab:blue\", \"trailer\": \"tab:purple\", \"airlift\": \"tab:red\"}[r[\"kind\"]])\n",
"ax[1].invert_yaxis(); ax[1].set_xlabel(\"minutes from 08:00\"); ax[1].set_title(\"Dynamic dispatch (blue=truck, purple=trailer, red=air)\")\n",
"labels = [\"cost/ton-km\", \"on-time\", \"survival\"]\n",
"bv = [kb[\"톤km당 비용\"], base[\"ontime\"].mean(), base[\"surv\"].mean()]; nv = [kn[\"톤km당 비용\"], new[\"ontime\"].mean(), new[\"surv\"].mean()]\n",
"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\")\n",
"ax[2].set_xticks(x); ax[2].set_xticklabels(labels); ax[2].legend(); ax[2].set_title(\"KPI comparison\")\n",
"plt.suptitle(\"MOV02 integrated transport support system (synthetic)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[0] 환경 점검: Python 3.11, 필수 모듈 5/5 확인\n",
"\n",
"[1] 수송 실적·비용 KPI [교육용가상자료] (기준선: 최단시간 경로+트럭만 / 개선: 위협 회피+차종 조합+동적 배정)\n",
" 기준선 통합 시스템\n",
"처리 요청 14 14\n",
"수송량(t) 156 156\n",
"톤km 5926 6644\n",
"총비용 3810.3 3241.3\n",
"톤km당 비용 0.643 0.488\n",
"정시율 29% 71%\n",
"평균 리드타임(분) 314.5 111.8\n",
"평균 생존확률 79.9% 95.4%\n",
"투입 차량(대·회) 34 14\n",
"\n",
"[2] 요청별 배정 결과(상위 8건)\n",
" req kind n 출발 도착 ontime surv 상태\n",
"REQ-1000 trailer 1 09:05 10:18 True 0.833 통제관 승인대기\n",
"REQ-1001 trailer 1 09:11 10:37 True 1.000 추천(검토 후 집행)\n",
"REQ-1002 truck 1 09:13 09:42 True 1.000 추천(검토 후 집행)\n",
"REQ-1003 trailer 1 11:31 13:22 False 1.000 추천(검토 후 집행)\n",
"REQ-1004 trailer 1 12:03 13:06 False 1.000 추천(검토 후 집행)\n",
"REQ-1005 airlift 1 11:17 11:54 True 1.000 통제관 승인대기\n",
"REQ-1006 trailer 1 14:09 15:13 False 0.833 통제관 승인대기\n",
"REQ-1007 trailer 1 15:12 16:59 False 1.000 추천(검토 후 집행)\n",
"승인대기 5건(REQ-1000, REQ-1005, REQ-1006, REQ-1010, REQ-1012) - 자동 집행하지 않고 수송통제관 결재(미션 7)로 넘긴다.\n",
"\n",
"[3] 자체 점검\n",
" [PASS] 환경 점검 통과\n",
" [PASS] 모든 요청 처리(누락 0)\n",
" [PASS] 통행 불가 구간·교량 하중 제한 위반 0건\n",
" [PASS] 위험 경로 자동 집행 0건\n",
" [PASS] 기준선 대비 평균 생존확률 향상\n",
" [PASS] 기준선 대비 정시율 유지(-5%p 이내)\n",
" [PASS] 도달 불가 요청 대안 제시\n",
" 매뉴얼 목차 6개 장, 작성 완료 6/6 (100%)\n",
"\n",
"[4] 브리핑 요약\n",
" - 하루 요청 14건, 수송량 156t를 처리했다. 정시율 29% -> 71%, 평균 생존확률 79.9% -> 95.4%.\n",
" - 총비용 3810.3 -> 3241.3(톤km당 0.643 -> 0.488), 투입 차량 34 -> 14대·회.\n",
" - 도로 불가 1건은 공중수송 대안으로 분리했고, 위험·고비용 건 5건은 통제관 승인 후 집행한다.\n",
"... (이하 생략)\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}