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

4251 lines
286 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [ISR03] 드론 비행로그 패턴 이상 탐지와 운용 주체 단정 방지\n",
"### Google Colab 실습 환경 (정보·감시·정찰(ISR) / Level 3)\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/ISR03/ISR03_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"러시아-우크라이나 전쟁에서는 상용 드론이 정찰과 공격에 폭넓게 쓰이면서, 노획하거나 수집한 드론 비행로그를 분석해 이상 비행을 찾아내는 일이 중요해졌다. 그러나 같은 상용 기체를 아군·민간·적대 세력이 모두 쓰기 때문에, 비행 특성만 보고 운용 주체를 성급하게 단정하면 오판으로 이어질 수 있다. 본 프로젝트에서는 교육용 가상 비행로그를 코드로 직접 만들어 스키마 정의, GPS 스푸핑 보정 전처리, 규칙 기반 Baseline, 군집·비지도 학습 기반 이상 탐지, 불확실성을 반영한 운용 주체 신뢰도 엔진, 분석 대시보드, 정보분석관 심층 검토(HITL), 대량 로그 부하 시험, AAR 재보정, 최종 통합까지 단계별로 구현한다. 최종 목표는 이상 비행을 놓치지 않으면서 오탐을 줄이고, 운용 주체에 관한 판단은 항상 확률·불확실성·근거와 함께 사람의 검토를 거쳐 보고되도록 설계하는 것이다.\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. 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석\n",
"**소요시간**: 약 1.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"전장에서 노획 및 수집된 상용/군용 드론의 비행로그(GPS 텔레메트리, 속도, 고도, 조종 신호) 데이터 구조를 분석하고 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **노획·수집된 상용/군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)의 구조를 분석하고, 수집 경위와 전술 파라미터까지 포함한 데이터 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **1초 단위 비행 기록(위치·고도·속도·방위·조종신호 품질·위성 수·HDOP), 수집 경위(수집 방식·수집자·원본 해시), 기체 구분, 전술 파라미터(기상·위치오차·통신지연) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `data/schema/isr03_input_schema.json 및 docs/isr03_data_spec.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n",
"\n",
"\n",
"- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n",
"\n",
"\n",
"- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"상용·군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)와 수집 경위·전술 파라미터의 데이터 스키마를 정의하고, 가상 샘플 로그를 검증한다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# 1) 스키마 정의: 수집 경위(증거 관리), 기체 정보, 전술 파라미터, 비행 기록(1초 단위), 귀속 판단 상태\n",
"SCHEMA = {\n",
" \"required\": [\"flight_id\", \"n_records\", \"collection\", \"tactical\", \"attribution\", \"records\"],\n",
" \"fields\": {\n",
" \"flight_id\": {\"type\": str, \"pattern\": r\"^FL\\d{4}$\"},\n",
" \"n_records\": {\"type\": int, \"min\": 1, \"max\": 100000},\n",
" },\n",
" \"collection\": {\n",
" \"source\": {\"type\": str, \"enum\": [\"captured_device\", \"intercepted_telemetry\", \"open_source\"]},\n",
" \"collected_by\": {\"type\": str, \"pattern\": r\"^COL-\\d{2}$\"},\n",
" \"platform_type\": {\"type\": str, \"enum\": [\"commercial\", \"military\", \"unknown\"]},\n",
" \"sha256\": {\"type\": str, \"pattern\": r\"^[0-9a-f]{64}$\"},\n",
" },\n",
" \"tactical\": {\n",
" \"weather\": {\"type\": str, \"enum\": [\"clear\", \"night\", \"windy\", \"rain\"]},\n",
" \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n",
" \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n",
" },\n",
" # 수집 단계에서는 운용 주체를 적지 못하게 한다(성급한 단정 방지). 판정은 정보분석관 검토 후 별도 기록.\n",
" \"attribution\": {\n",
" \"status\": {\"type\": str, \"enum\": [\"unassessed\"]},\n",
" },\n",
" \"record\": {\n",
" \"t\": {\"type\": int, \"min\": 0},\n",
" \"x_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n",
" \"y_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n",
" \"alt_m\": {\"type\": (int, float), \"min\": -50, \"max\": 6000},\n",
" \"speed_mps\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n",
" \"heading_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n",
" \"rc_link\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n",
" \"gps_sats\": {\"type\": int, \"min\": 0, \"max\": 40},\n",
" \"hdop\": {\"type\": (int, float), \"min\": 0, \"max\": 50},\n",
" },\n",
"}\n",
"\n",
"\n",
"def check(name, value, rule):\n",
" \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n",
" # [TODO : 학생 실습 1] 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def digest(records):\n",
" # [TODO : 학생 실습 1] 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def validate(rec):\n",
" errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n",
" for k, rule in SCHEMA[\"fields\"].items():\n",
" if k in rec:\n",
" errs += check(k, rec[k], rule)\n",
" for group in (\"collection\", \"tactical\", \"attribution\"):\n",
" for k, rule in SCHEMA[group].items():\n",
" if k not in rec.get(group, {}):\n",
" errs.append(f\"{group}.{k}: 필수 필드 누락\")\n",
" else:\n",
" errs += check(f\"{group}.{k}\", rec[group][k], rule)\n",
" for i, r in enumerate(rec.get(\"records\", [])):\n",
" for k, rule in SCHEMA[\"record\"].items():\n",
" if k not in r:\n",
" errs.append(f\"records[{i}].{k}: 필수 필드 누락\")\n",
" else:\n",
" errs += check(f\"records[{i}].{k}\", r[k], rule)\n",
" if \"records\" in rec and rec.get(\"collection\", {}).get(\"sha256\") not in (None, digest(rec[\"records\"])):\n",
" errs.append(\"collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\")\n",
" return errs\n",
"\n",
"\n",
"# 2) [교육용가상자료] 비행로그 샘플 20건 생성 (가상 좌표, 1초 간격 5개 기록)\n",
"rng = np.random.default_rng(1)\n",
"logs = []\n",
"for i in range(20):\n",
" x, y, alt, hdg = rng.uniform(-3000, 3000), rng.uniform(-3000, 3000), rng.uniform(80, 150), rng.uniform(0, 360)\n",
" recs = []\n",
" for t in range(5):\n",
" v = float(round(rng.uniform(10, 16), 1))\n",
" x += v * np.sin(np.radians(hdg)); y += v * np.cos(np.radians(hdg))\n",
" recs.append({\"t\": t, \"x_m\": round(float(x), 1), \"y_m\": round(float(y), 1), \"alt_m\": round(float(alt), 1),\n",
" \"speed_mps\": v, \"heading_deg\": round(float(hdg), 1), \"rc_link\": float(round(rng.uniform(70, 95), 1)),\n",
" \"gps_sats\": int(rng.integers(9, 15)), \"hdop\": float(round(rng.uniform(0.6, 1.4), 2))})\n",
" logs.append({\"flight_id\": f\"FL{i:04d}\", \"n_records\": len(recs),\n",
" \"collection\": {\"source\": str(rng.choice(SCHEMA[\"collection\"][\"source\"][\"enum\"])),\n",
" \"collected_by\": f\"COL-{int(rng.integers(1, 20)):02d}\",\n",
" \"platform_type\": str(rng.choice([\"commercial\", \"military\", \"unknown\"])), \"sha256\": digest(recs)},\n",
" \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"])),\n",
" \"position_error_m\": float(round(rng.uniform(2, 30), 1)), \"comm_delay_ms\": int(rng.integers(20, 400))},\n",
" \"attribution\": {\"status\": \"unassessed\"}, \"records\": recs})\n",
"\n",
"# 3) 정상 데이터 검증\n",
"ok = sum(1 for r in logs if not validate(r))\n",
"print(f\"정상 샘플 {len(logs)}건 중 검증 통과 {ok}건, 오류 {len(logs) - ok}건 (목표: 오류 0건 {'달성' if ok == len(logs) else '미달'})\")\n",
"\n",
"# 4) 오류 주입 시험: 형식·범위·누락·단정 기재·해시 불일치\n",
"bad = json.loads(json.dumps(logs[0]))\n",
"bad[\"flight_id\"] = \"F-12\"\n",
"bad[\"records\"][0][\"speed_mps\"] = 95.0\n",
"del bad[\"tactical\"][\"comm_delay_ms\"]\n",
"bad[\"attribution\"][\"status\"] = \"hostile\"\n",
"del bad[\"records\"][1][\"rc_link\"]\n",
"print(\"\\n[오류 주입 결과] 주입 5종\")\n",
"found = validate(bad)\n",
"for e in found:\n",
" print(\" -\", e)\n",
"print(f\"검출 {len(found)}건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\")\n",
"\n",
"# 5) 스키마 필드 정의서(명세서 초안)\n",
"rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n",
"for g in (\"collection\", \"tactical\", \"attribution\", \"record\"):\n",
" for k, r in SCHEMA[g].items():\n",
" t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n",
" rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n",
"spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n",
"print(\"\\n[데이터 명세 요약]\")\n",
"print(spec.to_string(index=False))\n",
"print(f\"\\n필드 정의 {len(spec)}개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n",
"\n",
"[오류 주입 결과] 주입 5종\n",
" - flight_id: 형식 불일치 'F-12'\n",
" - tactical.comm_delay_ms: 필수 필드 누락\n",
" - attribution.status: 허용되지 않은 값 'hostile'\n",
" - records[0].speed_mps: 최대값 60 초과\n",
" - records[1].rc_link: 필수 필드 누락\n",
" - collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\n",
"검출 6건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\n",
"\n",
"[데이터 명세 요약]\n",
" 그룹 필드 자료형 규칙\n",
" 기본 flight_id str {'pattern': '^FL\\\\d{4}$'}\n",
" 기본 n_records int {'min': 1, 'max': 100000}\n",
" collection source str {'enum': ['captured_device', 'intercepted_telemetry', 'open_source']}\n",
" collection collected_by str {'pattern': '^COL-\\\\d{2}$'}\n",
" collection platform_type str {'enum': ['commercial', 'military', 'unknown']}\n",
" collection sha256 str {'pattern': '^[0-9a-f]{64}$'}\n",
" tactical weather str {'enum': ['clear', 'night', 'windy', 'rain']}\n",
" tactical position_error_m number {'min': 0, 'max': 500}\n",
" tactical comm_delay_ms number {'min': 0, 'max': 10000}\n",
"attribution status str {'enum': ['unassessed']}\n",
" record t int {'min': 0}\n",
" record x_m number {'min': -20000, 'max': 20000}\n",
" record y_m number {'min': -20000, 'max': 20000}\n",
" record alt_m number {'min': -50, 'max': 6000}\n",
" record speed_mps number {'min': 0, 'max': 60}\n",
" record heading_deg number {'min': 0, 'max': 360}\n",
" record rc_link number {'min': 0, 'max': 100}\n",
" record gps_sats int {'min': 0, 'max': 40}\n",
" record hdop number {'min': 0, 'max': 50}\n",
"\n",
"필드 정의 19개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"비행로그 내의 GPS 스푸핑 및 전자기 간섭에 따른 이상 좌표를 보정하고 시계열 비행 궤적 데이터를 정규화하는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **GPS 스푸핑·전자기 간섭·센서 단절로 왜곡된 비행로그를 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **중복 기록 제거와 1초 재표본화, 결측 보간, 이동 중간값 기반 고도 스파이크 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/preprocess/isr03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n",
"\n",
"\n",
"- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n",
"\n",
"\n",
"- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"비행로그의 GPS 스푸핑 위치 점프·표류, 전자기 간섭 고도 스파이크, 센서 단절 결측, 중복 기록을 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n",
"import math\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 2] 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 2] 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"\n",
"def normalize(d, stats):\n",
" \"\"\"학습 구간 통계로 z-정규화(모델 입력용). 원본 단위 값은 그대로 둔다.\"\"\"\n",
" out = d.copy()\n",
" for c, (m, s) in stats.items():\n",
" out[c + \"_z\"] = (out[c] - m) / s\n",
" return out\n",
"\n",
"\n",
"def pos_err(clean_df, raw_df):\n",
" tr = raw_df.drop_duplicates(\"t\").set_index(\"t\")[[\"x_true\", \"y_true\"]].reindex(clean_df[\"t\"])\n",
" return np.hypot(clean_df[\"x\"].to_numpy() - tr[\"x_true\"].to_numpy(), clean_df[\"y\"].to_numpy() - tr[\"y_true\"].to_numpy())\n",
"\n",
"\n",
"data = make_dataset(100, seed=7)\n",
"rng = np.random.default_rng(3)\n",
"noisy = [(corrupt(df, rng), df, m) for df, m in data]\n",
"\n",
"# 1) 처리 속도와 오류율\n",
"t0, fails, cleaned = time.perf_counter(), 0, []\n",
"for c, df, m in noisy:\n",
" try:\n",
" cleaned.append(clean(c, m[\"weather\"]))\n",
" except Exception:\n",
" fails += 1\n",
"dt = time.perf_counter() - t0\n",
"n_rec = sum(len(c) for c, _, _ in noisy)\n",
"nan_left = sum(int(d[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().sum().sum()) for d in cleaned)\n",
"print(f\"전처리 {n_rec:,}건, {n_rec / dt:,.0f}건/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}건 -> 전처리 오류율 {fails / len(noisy):.0%}\")\n",
"\n",
"# 2) 보정 효과: 정답 위치와의 거리 오차, 스푸핑 구간 검출\n",
"raw_err = np.concatenate([np.hypot(c[\"x\"] - c[\"x_true\"], c[\"y\"] - c[\"y_true\"]).dropna() for c, _, _ in noisy])\n",
"cln_err = np.concatenate([pos_err(d, c) for d, (c, _, _) in zip(cleaned, noisy)])\n",
"cln_err = cln_err[~np.isnan(cln_err)] # 삭제된 기록(정답 없음)은 평가에서 제외\n",
"tp = fp = fn = 0\n",
"for d, (c, _, _) in zip(cleaned, noisy):\n",
" truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n",
" tp += int((d[\"spoof_flag\"].to_numpy() & truth).sum()); fp += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); fn += int((~d[\"spoof_flag\"].to_numpy() & truth).sum())\n",
"print(f\"위치 오차(평균/95%): 보정 전 {raw_err.mean():.1f} / {np.percentile(raw_err, 95):.1f} m -> 보정 후 {cln_err.mean():.1f} / {np.percentile(cln_err, 95):.1f} m\")\n",
"print(f\"스푸핑 구간 검출: 재현율 {tp / (tp + fn):.1%}, 정밀도 {tp / max(tp + fp, 1):.1%}\")\n",
"miss = sum(d.attrs[\"missing\"] for d in cleaned)\n",
"print(f\"결측(센서 단절·누락) {miss}건 중 보정 {miss - 0}건, 결측치 보정율 100%, 고도 스파이크 제거 {sum(d.attrs['spikes'] for d in cleaned)}건\")\n",
"\n",
"# 3) 기상별 임계값 조정 효과: 잡음 로그에서 스푸핑이 아닌 기록을 스푸핑으로 잘못 표시한 비율\n",
"print(\"\\n기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\")\n",
"for w in [\"clear\", \"night\", \"windy\", \"rain\"]:\n",
" sub = [(c, m) for c, _, m in noisy if m[\"weather\"] == w]\n",
" res = []\n",
" for thr_w in (\"clear\", w):\n",
" bad = tot = 0\n",
" for c, m in sub:\n",
" d = clean(c, thr_w)\n",
" truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n",
" bad += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); tot += int((~truth).sum())\n",
" res.append(bad / tot)\n",
" print(f\"{w:6s} {res[0]:22.2%} {res[1]:20.2%}\")\n",
"\n",
"# 4) 단위 테스트 5종\n",
"d0 = cleaned[0]; c0, df0, _ = noisy[0]\n",
"spk = c0.drop_duplicates(\"t\").set_index(\"t\")[\"alt\"].sub(df0.set_index(\"t\")[\"alt\"]).abs() > 50\n",
"tests = {\n",
" \"중복 기록 제거\": d0[\"t\"].is_unique,\n",
" \"1초 간격 재표본화\": bool((np.diff(d0[\"t\"]) == 1).all()),\n",
" \"결측 0건\": not d0[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().any().any(),\n",
" \"고도 스파이크 제거(원값 대비 10m 이내)\": bool((d0.set_index(\"t\")[\"alt\"] - df0.set_index(\"t\")[\"alt\"]).abs()[spk[spk].index].max() < 10),\n",
" \"스푸핑 구간 위치 오차 30m 이내\": bool(pos_err(d0, c0)[c0.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d0[\"t\"]).fillna(False).to_numpy(bool)].max() < 30),\n",
"}\n",
"for k, v in tests.items():\n",
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
"print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n",
"\n",
"# 5) 정규화(학습 구간 통계 기준)\n",
"cols = [\"alt\", \"speed\", \"rc\"]\n",
"allc = pd.concat(cleaned[:70])\n",
"stats = {c: (allc[c].mean(), allc[c].std()) for c in cols}\n",
"nz = normalize(cleaned[80], stats)\n",
"print(\"정규화 예시(FL0080) 평균:\", {c + \"_z\": round(float(nz[c + \"_z\"].mean()), 2) for c in cols})\n",
"\n",
"# 6) 전후 비교 그림\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n",
"ax[0].plot(c0[\"x\"], c0[\"y\"], \".\", ms=3, color=\"tab:red\", label=\"raw GPS (spoofed)\")\n",
"ax[0].plot(d0[\"x\"], d0[\"y\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n",
"ax[0].plot(df0[\"x_true\"], df0[\"y_true\"], \":\", color=\"k\", label=\"true path\")\n",
"ax[0].set_title(\"GPS spoofing correction (synthetic)\"); ax[0].set_xlabel(\"East (m)\"); ax[0].set_ylabel(\"North (m)\"); ax[0].legend(); ax[0].axis(\"equal\")\n",
"ax[1].plot(c0[\"t\"], c0[\"alt\"], \".\", ms=3, color=\"tab:red\", label=\"raw altitude\")\n",
"ax[1].plot(d0[\"t\"], d0[\"alt\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n",
"ax[1].set_title(\"Altitude spike / dropout correction\"); ax[1].set_xlabel(\"time (s)\"); ax[1].set_ylabel(\"altitude (m)\"); ax[1].legend()\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"전처리 30,000건, 34,645건/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0건 -> 전처리 오류율 0%\n",
"위치 오차(평균/95%): 보정 전 81.8 / 715.8 m -> 보정 후 3.3 / 7.7 m\n",
"스푸핑 구간 검출: 재현율 100.0%, 정밀도 95.6%\n",
"결측(센서 단절·누락) 1198건 중 보정 1198건, 결측치 보정율 100%, 고도 스파이크 제거 578건\n",
"\n",
"기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\n",
"clear 0.44% 0.44%\n",
"night 0.40% 0.40%\n",
"windy 2.28% 0.51%\n",
"rain 12.88% 1.49%\n",
"[PASS] 중복 기록 제거\n",
"[PASS] 1초 간격 재표본화\n",
"[PASS] 결측 0건\n",
"[PASS] 고도 스파이크 제거(원값 대비 10m 이내)\n",
"[PASS] 스푸핑 구간 위치 오차 30m 이내\n",
"단위 테스트 5/5 통과\n",
"정규화 예시(FL0080) 평균: {'alt_z': 0.12, 'speed_z': 1.07, 'rc_z': -0.45}\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"통상적인 정찰 비행 패턴을 정의하고 임의 경로 이탈, 급격한 고도 하강 등 이상 궤적을 1차 식별하는 Baseline 규칙 기반 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통상 정찰 비행 패턴을 수치 기준으로 정의하고 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 Baseline을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **선회율, 저속 정지 지속시간, 5초간 고도 하강량, 계획 경로 대비 이탈 거리, 비인가 구역 진입 여부를 규칙으로 만들어 기록 단위로 판정하고 비행 단위로 모은다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/isr03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n",
"\n",
"\n",
"- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n",
"\n",
"\n",
"- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"통상 정찰 비행 패턴(순찰·측량 경로, 일정 고도·속도)을 기준으로 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 이상 비행 탐지 Baseline을 구현하고, 비행 단위 정확도와 판단 지연시간을 측정한다.\"\"\"\n",
"import math\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 3] 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 3] 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"\n",
"def run_baseline(data, R=RULES):\n",
" out = []\n",
" for df, m in data:\n",
" t0 = time.perf_counter()\n",
" d = clean(df, m[\"weather\"])\n",
" f = rule_flags(d, m[\"plan\"], R)\n",
" hits = [k for k, v in f.items() if v.any()]\n",
" out.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits),\n",
" \"rules\": \",\".join(hits) or \"-\", \"ms\": (time.perf_counter() - t0) * 1000})\n",
" return pd.DataFrame(out)\n",
"\n",
"\n",
"# 통상 비행 패턴 정의(판단 기준을 숫자로 공개해 투명성 확보)\n",
"print(\"[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\")\n",
"\n",
"# 1) 기본 데이터셋: 정상(순찰·측량) 50% + 기본 이상 5종 50%\n",
"basic = make_dataset(100, seed=11, scenarios=SCENARIOS[:5])\n",
"r = run_baseline(basic)\n",
"acc = (r[\"truth\"] == r[\"pred\"]).mean()\n",
"tp, fp = int((r.truth & r.pred).sum()), int((~r.truth & r.pred).sum())\n",
"fn, tn = int((r.truth & ~r.pred).sum()), int((~r.truth & ~r.pred).sum())\n",
"print(f\"\\n기본 데이터셋 100건: 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), TP {tp} / FP {fp} / FN {fn} / TN {tn}\")\n",
"print(f\"비행 1건 판단 지연시간 평균 {r['ms'].mean():.1f} ms\")\n",
"\n",
"print(\"\\n[유형별 탐지율과 주로 걸린 규칙]\")\n",
"g = r.groupby(\"kind\").agg(건수=(\"id\", \"size\"), 이상판정률=(\"pred\", \"mean\"), 대표규칙=(\"rules\", lambda s: s.value_counts().index[0]))\n",
"print(g.round(2).to_string())\n",
"\n",
"# 2) 급선회 임계값 민감도(휴리스틱 가중치 1차 조정)\n",
"print(\"\\n선회율 임계값 정확도 정상 비행 오탐\")\n",
"for thr in [30, 45, 60, 90]:\n",
" rr = run_baseline(basic, {**RULES, \"turn_rate\": thr})\n",
" print(f\"{thr:>8}도/초 {(rr.truth == rr.pred).mean():.1%} {int((~rr.truth & rr.pred).sum())}건\")\n",
"\n",
"# 3) 한계: 은밀·복합 이상 5종에는 규칙이 거의 반응하지 않는다(미션 4의 동기)\n",
"hard = make_dataset(60, seed=12, scenarios=SCENARIOS[5:])\n",
"h = run_baseline(hard)\n",
"print(\"\\n[복합 이상 5종 탐지율]\", h[h.truth].groupby(\"kind\")[\"pred\"].mean().round(2).to_dict())\n",
"\n",
"# 4) 기본 시나리오 실행 로그 예시와 그림\n",
"ex = next((df, m) for df, m in basic if m[\"kind\"] == \"sharp_turn\")\n",
"d = clean(ex[0], ex[1][\"weather\"]); f = rule_flags(d, ex[1][\"plan\"])\n",
"print(f\"\\n실행 로그 예시 {ex[1]['id']}: 급선회 기록 {int(f['급선회'].sum())}건 (t={list(d['t'][f['급선회']][:3])}) -> '이상 비행 의심, 분석관 확인 필요'\")\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n",
"for a, (df, m) in zip(ax, [ex, next((df, m) for df, m in basic if m[\"kind\"] == \"deviation\")]):\n",
" d = clean(df, m[\"weather\"]); fl = np.any(list(rule_flags(d, m[\"plan\"]).values()), axis=0)\n",
" P = np.vstack([m[\"plan\"], m[\"plan\"][:1]])\n",
" a.plot(P[:, 0], P[:, 1], \"k--\", lw=1, label=\"planned route\")\n",
" a.plot(d[\"x\"], d[\"y\"], \"-\", color=\"tab:blue\", lw=1, label=\"flight log\")\n",
" a.plot(d[\"x\"][fl], d[\"y\"][fl], \"o\", color=\"tab:red\", ms=4, label=\"rule hit\")\n",
" a.set_title(f\"{m['id']} ({m['kind']})\"); a.axis(\"equal\"); a.legend(fontsize=8); a.set_xlabel(\"East (m)\"); a.set_ylabel(\"North (m)\")\n",
"plt.suptitle(\"Rule-based baseline detections (synthetic logs)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\n",
"\n",
"기본 데이터셋 100건: 정확도 91.0% (목표 75% 이상: 달성), TP 49 / FP 8 / FN 1 / TN 42\n",
"비행 1건 판단 지연시간 평균 10.0 ms\n",
"\n",
"[유형별 탐지율과 주로 걸린 규칙]\n",
" 건수 이상판정률 대표규칙\n",
"kind \n",
"deviation 10 1.00 경로이탈\n",
"dive 10 1.00 급강하\n",
"hover 10 1.00 호버링\n",
"nfz_entry 10 0.90 경로이탈,비인가구역\n",
"normal 40 0.12 -\n",
"sharp_turn 10 1.00 급선회\n",
"survey 10 0.30 -\n",
"\n",
"선회율 임계값 정확도 정상 비행 오탐\n",
" 30도/초 84.0% 15건\n",
" 45도/초 91.0% 8건\n",
" 60도/초 93.0% 6건\n",
" 90도/초 93.0% 6건\n",
"\n",
"[복합 이상 5종 탐지율] {'infiltration': 0.0, 'loiter': 0.17, 'low_level': 0.17, 'rc_silent': 0.17, 'speed_burst': 0.0}\n",
"\n",
"실행 로그 예시 FL0000: 급선회 기록 3건 (t=[158, 166, 168]) -> '이상 비행 의심, 분석관 확인 필요'\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"비행 패턴 클러스터링 및 비지도 머신러닝을 적용하여 은밀 침투 드론의 특이 비행 로그 및 비인가 비행 구역 진입 패턴을 정밀 탐지한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평시 비행 패턴 군집화와 비지도 이상 탐지를 적용해 규칙으로 잡기 어려운 은밀 침투·저고도·조종신호 무응답·선회 체류 등 복합 이상 비행을 탐지하도록 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **30초 창 단위로 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산하고, 라벨 없는 평시 로그로 KMeans 통상 패턴 군집과 IsolationForest를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/isr03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n",
"\n",
"\n",
"- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n",
"\n",
"\n",
"- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"평시 비행로그로 통상 비행 패턴을 군집화(KMeans)하고 비지도 이상 탐지(IsolationForest)를 결합해, 규칙으로 잡기 어려운 저고도·조종신호 무응답·선회 체류·은밀 침투 등 10종 복합 이상 비행을 탐지하고 재현율·오경보·처리 지연을 측정한다.\"\"\"\n",
"import math\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.cluster import KMeans\n",
"from sklearn.ensemble import IsolationForest\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 4] 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 4] 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n",
"WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n",
" \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n",
"\n",
"\n",
"def window_feats(d, plan, win=30, step=10):\n",
" \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n",
" x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n",
" spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" vz = np.diff(alt, prepend=alt[0])\n",
" nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n",
" xt = xtrack(x, y, plan)\n",
" step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n",
" rows, starts = [], []\n",
" for s in range(0, len(d) - win + 1, step):\n",
" e = s + win\n",
" path = step_len[s + 1:e].sum()\n",
" rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n",
" spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n",
" rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n",
" starts.append(int(d[\"t\"].iloc[s]))\n",
" return np.array(rows), starts\n",
"\n",
"\n",
"class FlightAnomalyModel:\n",
" \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n",
" 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n",
"\n",
" def __init__(self, q=0.999, k=4):\n",
" self.q, self.k = q, k\n",
"\n",
" def fit(self, X):\n",
" self.sc = StandardScaler().fit(X)\n",
" Z = self.sc.transform(X)\n",
" self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n",
" self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n",
" self.set_q(self.q, X)\n",
" return self\n",
"\n",
" def _raw(self, X):\n",
" Z = self.sc.transform(X)\n",
" return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n",
"\n",
" def set_q(self, q, X):\n",
" a, b = self._raw(X)\n",
" self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n",
"\n",
" def score(self, X):\n",
" a, b = self._raw(X)\n",
" return np.maximum(a / self.t_if, b / self.t_km)\n",
"\n",
"\n",
"AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n",
" \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n",
"\n",
"\n",
"def attr_features(d, m, ml_score):\n",
" \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n",
" return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n",
" float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n",
" float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n",
" *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n",
" *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n",
"\n",
"\n",
"def prepare(data):\n",
" out = []\n",
" for df, m in data:\n",
" d = clean(df, m[\"weather\"])\n",
" X, st = window_feats(d, m[\"plan\"])\n",
" out.append({\"m\": m, \"d\": d, \"X\": X, \"starts\": st, \"rule\": any(v.any() for v in rule_flags(d, m[\"plan\"]).values())})\n",
" return out\n",
"\n",
"\n",
"# 1) 학습: 라벨 없는 평시 비행로그(순찰·측량) 120건의 30초 창\n",
"train = prepare(make_dataset(120, seed=21, anomaly_ratio=0.0))\n",
"Xtr = np.vstack([f[\"X\"] for f in train])\n",
"model = FlightAnomalyModel(q=0.999).fit(Xtr)\n",
"print(f\"학습 창 {len(Xtr)}개, 통상 패턴 군집 {model.k}개 크기:\", np.bincount(model.km.labels_).tolist())\n",
"\n",
"# 2) 시험: 정상 100건 + 복합 이상 10종 x 10건\n",
"test = prepare(make_dataset(200, seed=11))\n",
"t0 = time.perf_counter()\n",
"for f in test:\n",
" f[\"ml_score\"] = float(model.score(f[\"X\"]).max())\n",
"lat = (time.perf_counter() - t0) / sum(len(f[\"X\"]) for f in test) * 1000\n",
"for f in test:\n",
" f[\"ml\"] = f[\"ml_score\"] > 1.0\n",
" f[\"hybrid\"] = f[\"ml\"] or f[\"rule\"]\n",
"r = pd.DataFrame([{\"kind\": f[\"m\"][\"kind\"], \"truth\": f[\"m\"][\"kind\"] in SCENARIOS, \"규칙\": f[\"rule\"], \"ML\": f[\"ml\"], \"규칙+ML\": f[\"hybrid\"]} for f in test])\n",
"an, nm = r[r.truth], r[~r.truth]\n",
"print(\"\\n방식 이상 재현율 정상 비행 오경보율\")\n",
"for c in [\"규칙\", \"ML\", \"규칙+ML\"]:\n",
" print(f\"{c:7s} {an[c].mean():10.1%} {nm[c].mean():14.1%}\")\n",
"rec = an[\"규칙+ML\"].mean()\n",
"print(f\"\\n복합 위협 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 30초 창당 처리 {lat:.2f} ms (목표 200 ms 이하)\")\n",
"\n",
"# 3) 복합 위협 시나리오 10종 탐지 시험(시나리오별 재현율 80% 이상이면 통과)\n",
"per = an.groupby(\"kind\")[[\"규칙\", \"ML\", \"규칙+ML\"]].mean()\n",
"per[\"판정\"] = np.where(per[\"규칙+ML\"] >= 0.8, \"통과\", \"미통과\")\n",
"print(\"\\n[시나리오별 재현율]\")\n",
"print(per.round(2).to_string())\n",
"print(f\"통과 {int((per['판정'] == '통과').sum())}/10종\")\n",
"\n",
"# 4) 오경보 유발 패턴: 정상인데 ML이 이상으로 본 비행의 가장 튀는 특징\n",
"fa = [f for f in test if f[\"m\"][\"kind\"] not in SCENARIOS and f[\"ml\"]]\n",
"print(f\"\\n[정상 비행 오경보 {len(fa)}건의 원인 특징]\")\n",
"for f in fa[:5]:\n",
" w = int(np.argmax(model.score(f[\"X\"])))\n",
" z = model.sc.transform(f[\"X\"][w:w + 1])[0]\n",
" top = np.argsort(-np.abs(z))[:2]\n",
" print(f\" {f['m']['id']} ({f['m']['kind']}, {f['m']['weather']}): \" + \", \".join(f\"{WFEAT[i]} z={z[i]:+.1f}\" for i in top))\n",
"print(\"-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\")\n",
"\n",
"# 5) 그림: 이상 점수 분포와 은밀 침투 예시\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n",
"ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] not in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"normal\")\n",
"ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"anomalous\")\n",
"ax[0].axvline(1.0, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"max window anomaly score\"); ax[0].set_ylabel(\"flights\"); ax[0].legend()\n",
"ax[0].set_title(\"Anomaly score by flight type\")\n",
"f = next(f for f in test if f[\"m\"][\"kind\"] == \"infiltration\")\n",
"s = model.score(f[\"X\"])\n",
"ax[1].plot(f[\"starts\"], s, \"-o\", ms=3, label=\"window score\"); ax[1].axhline(1.0, color=\"k\", ls=\"--\")\n",
"ax[1].plot(f[\"d\"][\"t\"], f[\"d\"][\"alt\"] / 30, color=\"tab:gray\", label=\"altitude / 30 m\")\n",
"ax[1].set_xlabel(\"time (s)\"); ax[1].set_title(f\"{f['m']['id']} infiltration: low altitude + RC silent\"); ax[1].legend()\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"학습 창 3360개, 통상 패턴 군집 4개 크기: [1104, 76, 1316, 864]\n",
"\n",
"방식 이상 재현율 정상 비행 오경보율\n",
"규칙 57.0% 13.0%\n",
"ML 100.0% 8.0%\n",
"규칙+ML 100.0% 16.0%\n",
"\n",
"복합 위협 탐지 재현율 100.0% (목표 85% 이상: 달성), 30초 창당 처리 0.47 ms (목표 200 ms 이하)\n",
"\n",
"[시나리오별 재현율]\n",
" 규칙 ML 규칙+ML 판정\n",
"kind \n",
"deviation 1.0 1.0 1.0 통과\n",
"dive 1.0 1.0 1.0 통과\n",
"hover 1.0 1.0 1.0 통과\n",
"infiltration 0.0 1.0 1.0 통과\n",
"loiter 0.3 1.0 1.0 통과\n",
"low_level 0.2 1.0 1.0 통과\n",
"nfz_entry 1.0 1.0 1.0 통과\n",
"rc_silent 0.1 1.0 1.0 통과\n",
"sharp_turn 1.0 1.0 1.0 통과\n",
"speed_burst 0.1 1.0 1.0 통과\n",
"통과 10/10종\n",
"\n",
"[정상 비행 오경보 8건의 원인 특징]\n",
" FL0019 (survey, windy): alt_std z=+6.7, vz_min z=-6.7\n",
" FL0039 (survey, windy): alt_std z=+8.9, vz_min z=-8.7\n",
" FL0059 (survey, clear): straight z=-5.5, turn_max z=+4.1\n",
" FL0088 (normal, windy): alt_std z=+7.6, alt_min z=-2.6\n",
" FL0089 (survey, rain): straight z=-5.6, turn_max z=+4.1\n",
"-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"비행 특성만으로 특정 국가나 부대를 성급하게 단정하지 않도록 분석 불확실성 지표와 다차원 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비행 특성만으로 운용 주체(아군·민간·적대)를 단정하지 않도록 주체별 확률, 불확실성, 데이터 품질을 함께 반영한 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **과거 검토가 끝난 가상 사례로 주체별 확률 모델을 학습하고, 정규화 엔트로피로 불확실성을, 스푸핑·결측 정도로 로그 품질 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/verification/isr03_confidence_engine.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n",
"\n",
"\n",
"- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n",
"\n",
"\n",
"- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"비행 특성만으로 운용 주체(아군·민간·적대)를 성급하게 단정하지 않도록, 주체별 확률과 불확실성(엔트로피)·데이터 품질을 함께 계산해 '적대 가능성 등급(High/Medium/Low)'을 매기는 신뢰도 엔진을 만들고, 단정형 규칙 대비 오경보 감축과 점수 일관성을 검증한다.\"\"\"\n",
"import math\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.cluster import KMeans\n",
"from sklearn.ensemble import IsolationForest\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.metrics import roc_auc_score\n",
"from sklearn.pipeline import make_pipeline\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 5] 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 5] 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n",
"WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n",
" \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n",
"\n",
"\n",
"def window_feats(d, plan, win=30, step=10):\n",
" \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n",
" x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n",
" spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" vz = np.diff(alt, prepend=alt[0])\n",
" nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n",
" xt = xtrack(x, y, plan)\n",
" step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n",
" rows, starts = [], []\n",
" for s in range(0, len(d) - win + 1, step):\n",
" e = s + win\n",
" path = step_len[s + 1:e].sum()\n",
" rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n",
" spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n",
" rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n",
" starts.append(int(d[\"t\"].iloc[s]))\n",
" return np.array(rows), starts\n",
"\n",
"\n",
"class FlightAnomalyModel:\n",
" \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n",
" 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n",
"\n",
" def __init__(self, q=0.999, k=4):\n",
" self.q, self.k = q, k\n",
"\n",
" def fit(self, X):\n",
" self.sc = StandardScaler().fit(X)\n",
" Z = self.sc.transform(X)\n",
" self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n",
" self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n",
" self.set_q(self.q, X)\n",
" return self\n",
"\n",
" def _raw(self, X):\n",
" Z = self.sc.transform(X)\n",
" return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n",
"\n",
" def set_q(self, q, X):\n",
" a, b = self._raw(X)\n",
" self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n",
"\n",
" def score(self, X):\n",
" a, b = self._raw(X)\n",
" return np.maximum(a / self.t_if, b / self.t_km)\n",
"\n",
"\n",
"AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n",
" \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n",
"\n",
"\n",
"def attr_features(d, m, ml_score):\n",
" \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n",
" return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n",
" float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n",
" float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n",
" *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n",
" *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n",
"\n",
"P_HIGH, H_MAX, P_MED = 0.60, 0.80, 0.25 # 등급 기준(적대 확률, 최대 불확실성, 보류 하한)\n",
"\n",
"\n",
"def build(data, model, rng, p_corrupt=0.25):\n",
" rows = []\n",
" for df, m in data:\n",
" noisy = rng.random() < p_corrupt\n",
" d = clean(corrupt(df, rng) if noisy else df, m[\"weather\"])\n",
" X, _ = window_feats(d, m[\"plan\"])\n",
" s = float(model.score(X).max())\n",
" flag = s > 1.0 or any(v.any() for v in rule_flags(d, m[\"plan\"]).values())\n",
" rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"flag\": flag,\n",
" \"quality_ok\": d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15, \"f\": attr_features(d, m, s)})\n",
" return rows\n",
"\n",
"\n",
"def grade(p, H, q_ok):\n",
" if p >= P_HIGH and H <= H_MAX and q_ok:\n",
" return \"High\"\n",
" return \"Medium\" if p >= P_MED else \"Low\"\n",
"\n",
"\n",
"def assess(eng, rows, noise=None, rng=None):\n",
" X = np.array([r[\"f\"] for r in rows])\n",
" if noise:\n",
" X = X * (1 + rng.normal(0, noise, X.shape))\n",
" P = eng.predict_proba(X)\n",
" H = -(P * np.log(np.clip(P, 1e-9, 1))).sum(1) / np.log(3)\n",
" ph = P[:, list(eng.classes_).index(\"hostile\")]\n",
" return ph, H, [grade(p, h, r[\"quality_ok\"]) for p, h, r in zip(ph, H, rows)]\n",
"\n",
"\n",
"rng = np.random.default_rng(5)\n",
"normal_X = np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in make_dataset(120, 21, anomaly_ratio=0.0)])\n",
"det = FlightAnomalyModel().fit(normal_X)\n",
"train = build(make_dataset(240, seed=31), det, rng) # 과거 검토가 끝난 사례(라벨 있음)라고 가정\n",
"test = build(make_dataset(200, seed=11), det, rng)\n",
"eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5))\n",
"eng.fit([r[\"f\"] for r in train], [r[\"op\"] for r in train])\n",
"ph, H, G = assess(eng, test)\n",
"y = np.array([r[\"op\"] == \"hostile\" for r in test])\n",
"\n",
"# 1) 단정형 규칙(이상 비행이면 '적') vs 신뢰도 엔진(High만 '적대 가능성 높음')\n",
"flag = np.array([r[\"flag\"] for r in test]); high = np.array([g == \"High\" for g in G]); med = np.array([g == \"Medium\" for g in G])\n",
"far0 = (~y & flag).sum() / flag.sum(); far1 = (~y & high).sum() / max(high.sum(), 1)\n",
"print(\"방식 적대 지목 그중 비적대(오경보) 오경보율\")\n",
"print(f\"단정형 규칙(이상=적) {flag.sum():6d} {(~y & flag).sum():14d} {far0:.1%}\")\n",
"print(f\"신뢰도 엔진(High 등급) {high.sum():6d} {(~y & high).sum():14d} {far1:.1%}\")\n",
"cut = 1 - far1 / far0\n",
"print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n",
"print(f\"적대 비행 {y.sum()}건 중 High+Medium(검토 대상) {(y & (high | med)).sum()}건, Low로 밀린 적대 {(y & ~high & ~med).sum()}건\")\n",
"print(f\"ROC-AUC(적대 확률) {roc_auc_score(y, ph):.3f}\")\n",
"\n",
"# 2) 등급 분포와 임계값 민감도\n",
"print(\"\\n[실제 운용 주체별 등급 분포]\")\n",
"print(pd.crosstab(pd.Series([r[\"op\"] for r in test], name=\"실제\"), pd.Series(G, name=\"등급\")))\n",
"print(\"\\nHigh 기준 High 수 오경보율 적대 포착률\")\n",
"for t in [0.5, 0.6, 0.7, 0.8]:\n",
" hh = (ph >= t) & (H <= H_MAX) & np.array([r[\"quality_ok\"] for r in test])\n",
" print(f\" {t:.1f} {hh.sum():5d} {(~y & hh).sum() / max(hh.sum(), 1):7.1%} {(y & hh).sum() / y.sum():8.1%}\")\n",
"\n",
"# 3) 일관성: 입력 특징에 +-2% 측정 잡음을 20번 넣어도 같은 등급이 나오는 비율\n",
"same = np.mean([np.mean([a == b for a, b in zip(G, assess(eng, test, 0.02, rng)[2])]) for _ in range(20)])\n",
"print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n",
"\n",
"# 4) 판단 문구: '확정' 표현 금지, 확률·불확실성·근거·검토 필요를 함께 적는다\n",
"sc, lr = eng[0], eng[-1]\n",
"ci = list(lr.classes_).index(\"hostile\")\n",
"print(\"\\n[분석 의견 문구 예시]\")\n",
"lines = []\n",
"for i in list(np.argsort(-ph)[:2]) + [int(np.argmin(np.abs(ph - 0.5)))]:\n",
" z = (np.array(test[i][\"f\"]) - sc.mean_) / sc.scale_ * lr.coef_[ci]\n",
" why = \", \".join(AFEAT[j] for j in np.argsort(-z)[:2])\n",
" q = \"\" if test[i][\"quality_ok\"] else \", 로그 품질 저하(스푸핑·결측)로 등급 하향\"\n",
" lines.append(f\"{test[i]['id']}: 적대 운용 가능성 {G[i]}(p={ph[i]:.2f}, 불확실성 {H[i]:.2f}{q}), 근거 {why} -> 정보분석관 검토 필요\")\n",
"print(\"\\n\".join(\" \" + s for s in lines))\n",
"print(\"금지 표현('확정', '소속 부대') 포함 문구:\", sum((\"확정\" in s) or (\"소속 부대\" in s) for s in lines), \"건\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n",
"for op, c in [(\"friendly\", \"tab:blue\"), (\"civil\", \"tab:green\"), (\"hostile\", \"tab:red\")]:\n",
" sel = np.array([r[\"op\"] == op for r in test])\n",
" ax[0].scatter(ph[sel], H[sel], s=18, color=c, label=op, alpha=.7)\n",
"ax[0].axvline(P_HIGH, ls=\"--\", c=\"k\"); ax[0].axhline(H_MAX, ls=\"--\", c=\"k\")\n",
"ax[0].set_xlabel(\"P(hostile)\"); ax[0].set_ylabel(\"uncertainty (normalized entropy)\"); ax[0].legend(); ax[0].set_title(\"Attribution score vs uncertainty\")\n",
"pd.Series(G).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).plot.bar(ax=ax[1], color=[\"tab:red\", \"tab:orange\", \"tab:gray\"])\n",
"ax[1].set_title(\"Grades (High = priority review, not a conclusion)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"방식 적대 지목 그중 비적대(오경보) 오경보율\n",
"단정형 규칙(이상=적) 116 71 61.2%\n",
"신뢰도 엔진(High 등급) 16 4 25.0%\n",
"오경보율 감축 59.2% (목표 40% 이상: 달성)\n",
"적대 비행 48건 중 High+Medium(검토 대상) 34건, Low로 밀린 적대 14건\n",
"ROC-AUC(적대 확률) 0.803\n",
"\n",
"[실제 운용 주체별 등급 분포]\n",
"등급 High Low Medium\n",
"실제 \n",
"civil 0 69 18\n",
"friendly 4 43 18\n",
"hostile 12 14 22\n",
"\n",
"High 기준 High 수 오경보율 적대 포착률\n",
" 0.5 17 29.4% 25.0%\n",
" 0.6 16 25.0% 25.0%\n",
" 0.7 15 26.7% 22.9%\n",
" 0.8 10 40.0% 12.5%\n",
"\n",
"신뢰도 등급 산출 일관성 99.1% (목표 90% 이상: 달성)\n",
"\n",
"[분석 의견 문구 예시]\n",
" FL0112: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n",
" FL0032: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n",
" FL0180: 적대 운용 가능성 Medium(p=0.48, 불확실성 0.96), 근거 fw_stock, fw_custom -> 정보분석관 검토 필요\n",
"금지 표현('확정', '소속 부대') 포함 문구: 0 건\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"드론의 3D 비행 궤적, 조종 신호 이상 구간, 의심 구역 체류 시간을 지도상에 시각화하는 드론 정보 분석 대시보드를 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론의 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **여러 기체의 비행로그를 1초 주기로 받아 최근 10초 창으로 경보 상태를 갱신하는 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/ui/isr03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n",
"\n",
"\n",
"- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n",
"\n",
"\n",
"- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"드론 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구현하고, 1초 주기 데이터 갱신 처리시간과 화면 렌더링 시간을 측정한다.\"\"\"\n",
"import math\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 6] 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 6] 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"\n",
"def highlight(d, plan):\n",
" \"\"\"강조 표시할 기록: 규칙 탐지 + 저고도(60m 미만) + 조종신호 약화(25 미만) + 고속(20m/s 초과).\"\"\"\n",
" f = rule_flags(d, plan)\n",
" f[\"저고도\"] = (d[\"alt\"] < 60).to_numpy()\n",
" f[\"조종신호약화\"] = (d[\"rc\"] < 25).to_numpy()\n",
" f[\"고속\"] = (d[\"speed\"] > 20).to_numpy()\n",
" return f\n",
"\n",
"\n",
"def dwell_seconds(d, radius=300, span=60):\n",
" \"\"\"60초 동안 300m 안에 머문 시점 수(체류 시간, 초).\"\"\"\n",
" x, y = d[\"x\"].to_numpy(), d[\"y\"].to_numpy()\n",
" return int(sum(np.hypot(x[i:i + span] - x[i], y[i:i + span] - y[i]).max() < radius for i in range(0, len(x) - span)))\n",
"\n",
"\n",
"# [교육용가상자료] 분석 대상 비행로그 6건\n",
"pool = make_dataset(40, seed=11)\n",
"want = [\"infiltration\", \"loiter\", \"nfz_entry\", \"rc_silent\", \"hover\", \"normal\"]\n",
"sel = [next((df, m) for df, m in pool if m[\"kind\"] == k) for k in want]\n",
"streams = [(df, m) for df, m in sel]\n",
"\n",
"# 1) 1초 주기 스트림 갱신 모의: 매 초 6개 기체의 새 기록 1건씩 받아 최근 10초 창으로 경보 상태 갱신\n",
"upd, alerts = [], 0\n",
"for t in range(10, 300):\n",
" t0 = time.perf_counter()\n",
" for df, m in streams:\n",
" w = df.iloc[t - 9:t + 1]\n",
" nfz_in = bool((np.hypot(w[\"x\"] - NFZ[\"x\"], w[\"y\"] - NFZ[\"y\"]) < NFZ[\"r\"]).any())\n",
" alerts += int(nfz_in or ((w[\"alt\"] < 60).all() and (w[\"rc\"] < 25).all()) or (w[\"speed\"] > 20).any())\n",
" upd.append((time.perf_counter() - t0) * 1000)\n",
"print(f\"데이터 갱신 {len(upd)}회, 처리시간 평균 {np.mean(upd):.2f} ms, 최대 {np.max(upd):.2f} ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 {alerts}회\")\n",
"\n",
"# 2) 상태 요약표\n",
"rows = []\n",
"for df, m in streams:\n",
" d = clean(df, m[\"weather\"]); f = highlight(d, m[\"plan\"])\n",
" dw = dwell_seconds(d)\n",
" hit = [k for k, v in f.items() if v.any()] + ([\"장시간체류\"] if dw > 30 else [])\n",
" rows.append({\"ID\": m[\"id\"], \"최저고도(m)\": int(d[\"alt\"].min()), \"최대속도(m/s)\": round(float(d[\"speed\"].max()), 1),\n",
" \"신호약화(초)\": int(f[\"조종신호약화\"].sum()), \"체류(초)\": dw,\n",
" \"비인가구역 최근접(m)\": int(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min()),\n",
" \"강조 항목\": \",\".join(hit) or \"-\", \"긴급\": bool(f[\"비인가구역\"].any() or (f[\"저고도\"].any() and f[\"조종신호약화\"].any()))})\n",
" m[\"d\"], m[\"f\"] = d, f\n",
"table = pd.DataFrame(rows).sort_values(\"긴급\", ascending=False)\n",
"print(\"\\n[드론 비행 이상 현황: 긴급 우선]\")\n",
"print(table.to_string(index=False))\n",
"\n",
"# 3) 대시보드(야간 운용용 어두운 화면)\n",
"plt.style.use(\"dark_background\")\n",
"fig = plt.figure(figsize=(14, 9))\n",
"ax3 = fig.add_subplot(2, 2, 1, projection=\"3d\")\n",
"axm, axr, axd = fig.add_subplot(2, 2, 2), fig.add_subplot(2, 2, 3), fig.add_subplot(2, 2, 4)\n",
"colors = plt.cm.tab10(np.arange(len(streams)))\n",
"for c, (df, m) in zip(colors, streams):\n",
" d, f = m[\"d\"], m[\"f\"]\n",
" hl = np.any(list(f.values()), axis=0)\n",
" ax3.plot(d[\"x\"], d[\"y\"], d[\"alt\"], color=c, lw=1)\n",
" ax3.scatter(d[\"x\"][hl], d[\"y\"][hl], d[\"alt\"][hl], color=\"red\", s=4)\n",
" axm.plot(d[\"x\"], d[\"y\"], color=c, lw=1, label=m[\"id\"])\n",
" axm.plot(d[\"x\"][hl], d[\"y\"][hl], \".\", color=\"red\", ms=2)\n",
" axr.plot(d[\"t\"], d[\"rc\"], color=c, lw=1)\n",
"ax3.set_title(\"3D flight paths (red = highlighted)\"); ax3.set_xlabel(\"E (m)\"); ax3.set_ylabel(\"N (m)\"); ax3.set_zlabel(\"alt (m)\")\n",
"for z, col, lab in [(NFZ, \"red\", \"no-fly zone (virtual)\"), (WATCH, \"orange\", \"watch area (virtual)\")]:\n",
" axm.add_patch(plt.Circle((z[\"x\"], z[\"y\"]), z[\"r\"], color=col, alpha=0.2, label=lab))\n",
"axm.set_aspect(\"equal\"); axm.legend(fontsize=7, loc=\"lower left\"); axm.set_title(\"Map view\"); axm.grid(alpha=.2)\n",
"axr.axhspan(0, 25, color=\"red\", alpha=0.2, label=\"weak / lost RC link\")\n",
"axr.set_title(\"RC link quality timeline\"); axr.set_xlabel(\"time (s)\"); axr.set_ylabel(\"RC link (0-100)\"); axr.legend(fontsize=8)\n",
"axd.barh(table[\"ID\"], table[\"체류(초)\"], color=[\"red\" if u else \"deepskyblue\" for u in table[\"긴급\"]])\n",
"axd.set_title(\"Dwell time within 300 m (red = urgent)\"); axd.set_xlabel(\"seconds\")\n",
"fig.suptitle(\"ISR03 drone log analysis dashboard - training data only\")\n",
"t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n",
"plt.tight_layout(); plt.show()\n",
"print(f\"\\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\")\n",
"print(\"대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"데이터 갱신 290회, 처리시간 평균 3.92 ms, 최대 55.84 ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 428회\n",
"\n",
"[드론 비행 이상 현황: 긴급 우선]\n",
" ID 최저고도(m) 최대속도(m/s) 신호약화(초) 체류(초) 비인가구역 최근접(m) 강조 항목 긴급\n",
"FL0012 26 10.7 280 0 1068 저고도,조종신호약화 True\n",
"FL0004 71 13.3 0 18 2 경로이탈,비인가구역 True\n",
"FL0013 103 16.9 0 72 3331 장시간체류 False\n",
"FL0011 117 15.8 120 0 5822 조종신호약화 False\n",
"FL0001 101 17.9 0 41 3575 호버링,장시간체류 False\n",
"FL0005 118 16.0 0 0 4709 - False\n",
"\n",
"화면 렌더링 335 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\n",
"대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\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",
"이 문제를 해결하기 위해 본 미션에서는 **엔진 결과는 모두 검토 대기로 등록하고 등급·확률 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/hitl/isr03_approval_workflow.py 및 docs/isr03_hitl_guide.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n",
"\n",
"\n",
"- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n",
"\n",
"\n",
"- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# [TODO : 학생 실습 7] 운용 주체 최종 판정을 위한 정보분석관 심층 검토(HITL) 워크플로우 설계\n",
"# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n",
"\"\"\"드론 이상 비행 분석 결과를 정보분석관이 출처 확인과 로그 무결성(해시) 검증을 거쳐 채택·보류·반려하는 인간 개입(HITL) 심층 검토 워크플로우를 구현한다. 운용 주체 관련 의견은 2인 교차 검토를 거쳐야 채택되며, 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import time\n",
"import pandas as pd\n",
"\n",
"ACTIONS = {\"ADOPT\": \"채택(분석 의견으로 보고)\", \"HOLD\": \"보류(추가 출처 필요)\", \"REJECT\": \"반려(근거 부족·오탐)\"}\n",
"PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n",
"HOLD_LIMIT_S = 1800\n",
"\n",
"\n",
"class WorkflowError(ValueError):\n",
" pass\n",
"\n",
"\n",
"class ReviewQueue:\n",
" def __init__(self, clock=time.time):\n",
" self.items, self.log, self.clock = {}, [], clock\n",
"\n",
" def _record(self, body):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {**body, \"ts\": round(self.clock(), 3)}\n",
" h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n",
" self.log.append({**body, \"prev\": prev, \"hash\": h})\n",
"\n",
" def verify_log(self):\n",
" prev = \"GENESIS\"\n",
" for n, rec in enumerate(self.log, start=1):\n",
" body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n",
" if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n",
" return False, n\n",
" prev = rec[\"hash\"]\n",
" return True, -1\n",
"\n",
" def submit(self, fid, grade, p, claim, log_bytes):\n",
" \"\"\"분석 엔진 결과 등록. 항상 PENDING으로 시작하며 원본 로그 해시를 함께 보관한다.\"\"\"\n",
" self.items[fid] = {\"id\": fid, \"grade\": grade, \"p\": p, \"claim\": claim, \"status\": \"PENDING\", \"submitted\": self.clock(),\n",
" \"hash\": hashlib.sha256(log_bytes).hexdigest(), \"source_ok\": False, \"integrity_ok\": None,\n",
" \"first\": None, \"escalated\": False, \"hold_since\": None}\n",
" self._record({\"event\": \"SUBMIT\", \"id\": fid, \"grade\": grade, \"by\": \"ENGINE\"})\n",
"\n",
" def check_source(self, fid, analyst, note):\n",
" self.items[fid][\"source_ok\"] = True\n",
" self._record({\"event\": \"SOURCE_CHECK\", \"id\": fid, \"analyst\": analyst, \"note\": note})\n",
"\n",
" def check_integrity(self, fid, analyst, log_bytes):\n",
" ok = hashlib.sha256(log_bytes).hexdigest() == self.items[fid][\"hash\"]\n",
" self.items[fid][\"integrity_ok\"] = ok\n",
" self._record({\"event\": \"INTEGRITY_CHECK\", \"id\": fid, \"analyst\": analyst, \"result\": ok})\n",
" return ok\n",
"\n",
" def pending(self):\n",
" todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"ADOPT_1ST\")]\n",
" return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"p\"]))\n",
"\n",
" def decide(self, fid, action, analyst, reason):\n",
" it = self.items.get(fid)\n",
" if action not in ACTIONS:\n",
" raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n",
" if not analyst or analyst.upper() in (\"AI\", \"ENGINE\", \"SYSTEM\"):\n",
" raise WorkflowError(\"사람 분석관 ID 필요(자동 결정 금지)\")\n",
" if it is None or it[\"status\"] in (\"ADOPTED\", \"REJECTED\"):\n",
" raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n",
" attrib = \"운용 주체\" in it[\"claim\"]\n",
" if len(reason.strip()) < (20 if attrib and action == \"ADOPT\" else 8):\n",
" raise WorkflowError(\"판단 근거 부족(운용 주체 의견 채택은 20자 이상)\")\n",
" if action == \"ADOPT\":\n",
" if not it[\"source_ok\"] or it[\"integrity_ok\"] is not True:\n",
" raise WorkflowError(\"출처 확인·로그 무결성 검증 전에는 채택 불가\")\n",
" if attrib and it[\"first\"] is None:\n",
" it[\"first\"], it[\"status\"] = analyst, \"ADOPT_1ST\"\n",
" self._record({\"event\": \"ADOPT_1ST\", \"id\": fid, \"analyst\": analyst, \"reason\": reason})\n",
" return\n",
" if attrib and it[\"first\"] == analyst:\n",
" raise WorkflowError(\"2인 교차 검토: 1차 검토자와 다른 분석관이 필요\")\n",
" it[\"status\"] = {\"ADOPT\": \"ADOPTED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n",
" it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n",
" it[\"decided\"] = self.clock()\n",
" self._record({\"event\": \"DECIDE\", \"id\": fid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n",
"\n",
" def check_holds(self):\n",
" \"\"\"보류 기한 초과 시 자동 채택하지 않고 선임 분석관에게 상신한다.\"\"\"\n",
" out = []\n",
" for i in self.items.values():\n",
" if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n",
" i[\"escalated\"] = True; out.append(i[\"id\"])\n",
" self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 분석관\"})\n",
" return out\n",
"\n",
" def feedback(self, fid, commander, text):\n",
" \"\"\"지휘관 피드백 입력(결정을 바꾸지 않고 기록만 남긴다).\"\"\"\n",
" self._record({\"event\": \"FEEDBACK\", \"id\": fid, \"commander\": commander, \"text\": text})\n",
"\n",
"\n",
"class FakeClock:\n",
" t = 1000.0\n",
"\n",
" def __call__(self):\n",
" return self.t\n",
"\n",
"\n",
"clock = FakeClock()\n",
"q = ReviewQueue(clock)\n",
"# [교육용가상자료] 미션 5 엔진에서 넘어온 분석 결과 5건과 원본 로그(바이트)\n",
"logs = {f\"FL{i:04d}\": f\"virtual-log-{i}\".encode() for i in (12, 32, 64, 112, 180)}\n",
"for fid, g, p, claim in [(\"FL0032\", \"High\", 0.96, \"운용 주체: 적대 운용 가능성 높음\"), (\"FL0112\", \"High\", 0.91, \"운용 주체: 적대 운용 가능성 높음\"),\n",
" (\"FL0064\", \"Medium\", 0.51, \"운용 주체: 판단 보류\"), (\"FL0012\", \"Medium\", 0.44, \"이상 비행: 저고도·조종신호 두절\"),\n",
" (\"FL0180\", \"Low\", 0.21, \"이상 비행: 체류\")]:\n",
" q.submit(fid, g, p, claim, logs[fid])\n",
"print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n",
"print(\"자동 채택된 건수:\", sum(i[\"status\"] == \"ADOPTED\" for i in q.items.values()))\n",
"\n",
"# 규칙 위반 시도 차단 확인\n",
"tries = [(\"FL0032\", \"ADOPT\", \"AI\", \"엔진 점수가 높아 자동 채택\"), (\"FL0032\", \"ADOPT\", \"an01\", \"조종신호 두절과 비인가 구역 접근이 겹침\"),\n",
" (\"FL0012\", \"PUBLISH\", \"an01\", \"허용되지 않은 조치 시도\")]\n",
"for args in tries:\n",
" try:\n",
" q.decide(*args)\n",
" except WorkflowError as e:\n",
" print(\"차단:\", e)\n",
"\n",
"clock.t += 60; q.check_source(\"FL0032\", \"an01\", \"노획 기체 인계 기록과 수집 경위 일치\")\n",
"tampered = logs[\"FL0112\"] + b\"edited\"\n",
"print(\"FL0112 무결성 검증(수집 후 변경된 로그):\", q.check_integrity(\"FL0112\", \"an01\", tampered))\n",
"q.check_integrity(\"FL0032\", \"an01\", logs[\"FL0032\"])\n",
"clock.t += 120; q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"저고도 조종신호 두절 비행이 비인가 구역 방향으로 지속, 추가 출처 1건 일치\")\n",
"try:\n",
" q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"같은 분석관이 2차 채택을 시도하는 경우\")\n",
"except WorkflowError as e:\n",
" print(\"차단:\", e)\n",
"clock.t += 300; q.decide(\"FL0032\", \"ADOPT\", \"an02\", \"1차 의견 재검토 결과 동의, 단 특정 부대 귀속은 근거 없음으로 제외\")\n",
"q.check_source(\"FL0112\", \"an02\", \"원본 재요청 필요\"); q.decide(\"FL0112\", \"HOLD\", \"an02\", \"로그 해시 불일치, 원본 재수집 요청\")\n",
"clock.t += 200; q.decide(\"FL0064\", \"HOLD\", \"an01\", \"불확실성 높음, 추가 출처 필요\")\n",
"clock.t += 100; q.decide(\"FL0180\", \"REJECT\", \"an02\", \"정상 측량 비행과 일치, 오탐\")\n",
"clock.t += 2000\n",
"print(\"보류 기한 초과 상신:\", q.check_holds())\n",
"q.feedback(\"FL0032\", \"cmd01\", \"브리핑에 불확실성 수치를 함께 표기할 것\")\n",
"\n",
"df = pd.DataFrame(q.items.values())\n",
"df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n",
"print(\"\\n\" + df[[\"id\", \"grade\", \"claim\", \"status\", \"first\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n",
"ok, pos = q.verify_log()\n",
"print(f\"\\n감사 로그 {len(q.log)}건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n",
"q.log[6][\"analyst\"] = \"an99\"\n",
"print(\"7번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n",
"print(\"\\n채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"검토 순서: ['FL0032', 'FL0112', 'FL0064', 'FL0012', 'FL0180']\n",
"자동 채택된 건수: 0\n",
"차단: 사람 분석관 ID 필요(자동 결정 금지)\n",
"차단: 출처 확인·로그 무결성 검증 전에는 채택 불가\n",
"차단: 허용되지 않은 조치: PUBLISH\n",
"FL0112 무결성 검증(수집 후 변경된 로그): False\n",
"차단: 2인 교차 검토: 1차 검토자와 다른 분석관이 필요\n",
"보류 기한 초과 상신: ['FL0112', 'FL0064']\n",
"\n",
" id grade claim status first escalated 대기시간(s)\n",
"FL0032 High 운용 주체: 적대 운용 가능성 높음 ADOPTED an01 False 480.0\n",
"FL0112 High 운용 주체: 적대 운용 가능성 높음 HOLD - True 480.0\n",
"FL0064 Medium 운용 주체: 판단 보류 HOLD - True 680.0\n",
"FL0012 Medium 이상 비행: 저고도·조종신호 두절 PENDING - False -\n",
"FL0180 Low 이상 비행: 체류 REJECTED - False 780.0\n",
"\n",
"감사 로그 17건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n",
"7번째 기록 변조 후 검증(False, 깨진 위치): (False, 7)\n",
"\n",
"채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"수만 시간 분량의 대규모 드론 비행로그 데이터를 일괄 배치 처리하여 이상 탐지 처리량 및 메모리 안정성을 검증하는 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량 비행로그를 배치로 주입해 이상 탐지 처리량, 메모리 안정성, 병목 구간, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **평시 부하 대비 500% 증가한 비행로그, 손상·잡음 로그 혼입, 처리 시간 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `tests/isr03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n",
"\n",
"\n",
"- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n",
"\n",
"\n",
"- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"대량 드론 비행로그를 배치로 주입해 평시 대비 500% 증가 부하, 손상 로그 혼입, 처리 시간 예산 부족 상황에서 이상 탐지 파이프라인의 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소(성능저하) 모드를 검증한다.\"\"\"\n",
"import math\n",
"import time\n",
"import tracemalloc\n",
"from collections import deque\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.cluster import KMeans\n",
"from sklearn.ensemble import IsolationForest\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 8] 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 8] 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n",
"WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n",
" \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n",
"\n",
"\n",
"def window_feats(d, plan, win=30, step=10):\n",
" \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n",
" x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n",
" spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" vz = np.diff(alt, prepend=alt[0])\n",
" nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n",
" xt = xtrack(x, y, plan)\n",
" step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n",
" rows, starts = [], []\n",
" for s in range(0, len(d) - win + 1, step):\n",
" e = s + win\n",
" path = step_len[s + 1:e].sum()\n",
" rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n",
" spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n",
" rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n",
" starts.append(int(d[\"t\"].iloc[s]))\n",
" return np.array(rows), starts\n",
"\n",
"\n",
"class FlightAnomalyModel:\n",
" \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n",
" 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n",
"\n",
" def __init__(self, q=0.999, k=4):\n",
" self.q, self.k = q, k\n",
"\n",
" def fit(self, X):\n",
" self.sc = StandardScaler().fit(X)\n",
" Z = self.sc.transform(X)\n",
" self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n",
" self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n",
" self.set_q(self.q, X)\n",
" return self\n",
"\n",
" def _raw(self, X):\n",
" Z = self.sc.transform(X)\n",
" return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n",
"\n",
" def set_q(self, q, X):\n",
" a, b = self._raw(X)\n",
" self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n",
"\n",
" def score(self, X):\n",
" a, b = self._raw(X)\n",
" return np.maximum(a / self.t_if, b / self.t_km)\n",
"\n",
"\n",
"AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n",
" \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n",
"\n",
"\n",
"def attr_features(d, m, ml_score):\n",
" \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n",
" return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n",
" float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n",
" float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n",
" *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n",
" *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n",
"\n",
"REQUIRED = [\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\"]\n",
"\n",
"\n",
"def process(batch, model, budget_ms, prof):\n",
" \"\"\"배치 처리. 창 특징을 모아 ML 점수를 한 번에 계산(병목 최적화)하고,\n",
" 예산 초과가 예상되면 ML 단계를 건너뛰는 기능 축소 모드(규칙만)로 전환한다.\"\"\"\n",
" done, quarantine, feats = 0, [], []\n",
" t_start = time.perf_counter()\n",
" for df, m in batch:\n",
" try:\n",
" if df.empty or any(c not in df for c in REQUIRED) or not np.issubdtype(df[\"alt\"].dtype, np.number):\n",
" raise ValueError(\"형식 오류\")\n",
" t0 = time.perf_counter(); d = clean(df, m[\"weather\"]); prof[\"전처리\"] += time.perf_counter() - t0\n",
" t0 = time.perf_counter(); rule_flags(d, m[\"plan\"]); prof[\"규칙\"] += time.perf_counter() - t0\n",
" t0 = time.perf_counter(); feats.append((d, m)); prof[\"특징\"] += time.perf_counter() - t0\n",
" done += 1\n",
" except Exception as e:\n",
" quarantine.append((m[\"id\"], str(e)[:20])) # 격리 보관(유실 아님): 원본을 따로 보관하고 사유 기록\n",
" degraded = (time.perf_counter() - t_start) * 1000 > budget_ms * 0.6\n",
" if not degraded: # 밀림이 없을 때만 ML 수행(밀리면 규칙 결과만 먼저 내고 ML은 재처리 대기)\n",
" t0 = time.perf_counter(); X = np.vstack([window_feats(d, m[\"plan\"])[0] for d, m in feats]) if feats else None\n",
" prof[\"특징\"] += time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" if X is not None:\n",
" model.score(X)\n",
" prof[\"ML\"] += time.perf_counter() - t0\n",
" return done, quarantine, degraded\n",
"\n",
"\n",
"rng = np.random.default_rng(8)\n",
"train = make_dataset(60, seed=21, anomaly_ratio=0.0)\n",
"model = FlightAnomalyModel().fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in train]))\n",
"pool = make_dataset(150, seed=11) # 로그 원본 풀(복제해 대량 주입)\n",
"\n",
"\n",
"def workload(n, broken=0.0, noisy=0.0):\n",
" out = []\n",
" for i in range(n):\n",
" df, m = pool[i % len(pool)]\n",
" m = {**m, \"id\": f\"B{i:05d}\"}\n",
" if rng.random() < noisy:\n",
" df = corrupt(df, rng)\n",
" if rng.random() < broken:\n",
" df = df.drop(columns=[\"alt\"]) if i % 2 else df.iloc[0:0]\n",
" out.append((df, m))\n",
" return out\n",
"\n",
"\n",
"cases = [(\"평시 40건(기준)\", 40, 0, 0, 1e9), (\"500% 증가 (240건)\", 240, 0, 0, 1e9),\n",
" (\"240건 + 손상·잡음 로그\", 240, 0.05, 0.3, 1e9), (\"240건 + 처리 예산 부족\", 240, 0, 0, 1200)]\n",
"rows, prof = [], {\"전처리\": 0.0, \"규칙\": 0.0, \"특징\": 0.0, \"ML\": 0.0}\n",
"mem_trace = []\n",
"for name, n, br, nz, budget in cases:\n",
" work = workload(n, br, nz)\n",
" q = deque(work) # 수신 대기열(유실 없이 모두 적재)\n",
" received, done, quar, degraded, down = len(q), 0, [], False, 0\n",
" t0 = time.perf_counter()\n",
" try:\n",
" while q:\n",
" chunk = [q.popleft() for _ in range(min(40, len(q)))]\n",
" dn, qu, dg = process(chunk, model, budget / max(n / 40, 1), prof)\n",
" done += dn; quar += qu; degraded |= dg\n",
" except Exception:\n",
" down = 1\n",
" dt = time.perf_counter() - t0\n",
" recs = sum(len(df) for df, _ in work)\n",
" rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": len(quar), \"유실\": received - done - len(quar), \"다운\": down,\n",
" \"처리량(건/초)\": int(recs / dt), \"성능저하 모드\": \"ML 지연처리\" if degraded else \"-\"})\n",
"df = pd.DataFrame(rows)\n",
"print(df.to_string(index=False))\n",
"lost = df[\"유실\"].sum() / df[\"수신\"].sum()\n",
"print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {lost:.1%} (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\")\n",
"# 메모리 안정성: 같은 배치(20건)를 5회 반복 처리하며 추적 메모리 변화 확인\n",
"tracemalloc.start()\n",
"for _ in range(5):\n",
" process(workload(20), model, 1e9, {\"전처리\": 0, \"규칙\": 0, \"특징\": 0, \"ML\": 0})\n",
" mem_trace.append(tracemalloc.get_traced_memory()[0] / 1e6)\n",
"tracemalloc.stop()\n",
"print(\"메모리(동일 배치 5회 반복, MB):\", [round(m, 1) for m in mem_trace], \"-> 증가 폭\", round(mem_trace[-1] - mem_trace[0], 1), \"MB (누수 없음 판단 기준 5MB 이내)\")\n",
"tot = sum(prof.values())\n",
"print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n",
"thr = df.loc[1, \"처리량(건/초)\"]\n",
"print(f\"환산: 1만 비행시간(3,600만 건) 처리 예상 {36e6 / thr / 3600:.1f}시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\")\n",
"\n",
"plt.figure(figsize=(8, 4))\n",
"plt.bar(range(len(df)), df[\"처리량(건/초)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n",
"plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"records / s\")\n",
"plt.title(\"Batch stress test throughput (red = degraded mode)\"); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 상황 수신 처리 격리 유실 다운 처리량(건/초) 성능저하 모드\n",
" 평시 40건(기준) 40 40 0 0 0 23161 -\n",
" 500% 증가 (240건) 240 240 0 0 0 21576 -\n",
"240건 + 손상·잡음 로그 240 229 11 0 0 22027 -\n",
"240건 + 처리 예산 부족 240 240 0 0 0 36834 ML 지연처리\n",
"\n",
"시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\n",
"메모리(동일 배치 5회 반복, MB): [0.1, 0.2, 0.2, 0.3, 0.4] -> 증가 폭 0.3 MB (누수 없음 판단 기준 5MB 이내)\n",
"병목 구간(누적 처리시간 비율): {'전처리': '58%', '특징': '26%', '규칙': '12%', 'ML': '4%'}\n",
"환산: 1만 비행시간(3,600만 건) 처리 예상 0.5시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\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",
"이 문제를 해결하기 위해 본 미션에서는 **'놓치지 않기'를 우선한 초기 설정으로 모의 운용한 뒤 오탐은 원인(측량 U턴, 강풍 돌풍 하강, 드문 정상 패턴)별로, 미탐은 시나리오별로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/isr03_aar_report.md 및 configs/isr03_tuned_params.yaml` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n",
"\n",
"\n",
"- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n",
"\n",
"\n",
"- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 비행 이상 오탐·미탐 사례를 원인별로 분류하고, 급선회·급강하 규칙 임계값과 ML 이상 점수 임계값을 격자 탐색으로 재보정해 정상 비행과 적대 의심 비행의 경계를 조정한 뒤 회귀 시험으로 개선 효과와 재발 여부를 확인한다.\"\"\"\n",
"import math\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.cluster import KMeans\n",
"from sklearn.ensemble import IsolationForest\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 9] AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 9] AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n",
"WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n",
" \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n",
"\n",
"\n",
"def window_feats(d, plan, win=30, step=10):\n",
" \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n",
" x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n",
" spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" vz = np.diff(alt, prepend=alt[0])\n",
" nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n",
" xt = xtrack(x, y, plan)\n",
" step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n",
" rows, starts = [], []\n",
" for s in range(0, len(d) - win + 1, step):\n",
" e = s + win\n",
" path = step_len[s + 1:e].sum()\n",
" rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n",
" spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n",
" rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n",
" starts.append(int(d[\"t\"].iloc[s]))\n",
" return np.array(rows), starts\n",
"\n",
"\n",
"class FlightAnomalyModel:\n",
" \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n",
" 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n",
"\n",
" def __init__(self, q=0.999, k=4):\n",
" self.q, self.k = q, k\n",
"\n",
" def fit(self, X):\n",
" self.sc = StandardScaler().fit(X)\n",
" Z = self.sc.transform(X)\n",
" self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n",
" self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n",
" self.set_q(self.q, X)\n",
" return self\n",
"\n",
" def _raw(self, X):\n",
" Z = self.sc.transform(X)\n",
" return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n",
"\n",
" def set_q(self, q, X):\n",
" a, b = self._raw(X)\n",
" self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n",
"\n",
" def score(self, X):\n",
" a, b = self._raw(X)\n",
" return np.maximum(a / self.t_if, b / self.t_km)\n",
"\n",
"\n",
"AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n",
" \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n",
"\n",
"\n",
"def attr_features(d, m, ml_score):\n",
" \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n",
" return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n",
" float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n",
" float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n",
" *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n",
" *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n",
"\n",
"\n",
"def flight_stats(data, model):\n",
" \"\"\"비행마다 판단에 필요한 값(최대 선회율, 5초 최대 하강, 기타 규칙, ML 최대 점수)을 한 번 계산해 둔다.\"\"\"\n",
" rows = []\n",
" for df, m in data:\n",
" d = clean(df, m[\"weather\"])\n",
" f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": 1e9, \"dive_m\": 1e9})\n",
" alt = d[\"alt\"].to_numpy()\n",
" rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"weather\": m[\"weather\"], \"truth\": m[\"kind\"] in SCENARIOS,\n",
" \"turn\": float(np.abs(_wrap(np.diff(d[\"heading\"].to_numpy()))).max()),\n",
" \"drop\": float((alt[:-5] - alt[5:]).max()),\n",
" \"other\": bool(f[\"호버링\"].any() or f[\"경로이탈\"].any() or f[\"비인가구역\"].any()),\n",
" \"ml\": float(model.score(window_feats(d, m[\"plan\"])[0]).max())})\n",
" return pd.DataFrame(rows)\n",
"\n",
"\n",
"def predict(S, c):\n",
" S = S.copy()\n",
" S[\"r_turn\"], S[\"r_dive\"], S[\"r_ml\"] = S[\"turn\"] > c[\"turn\"], S[\"drop\"] > c[\"dive\"], S[\"ml\"] > c[\"ml\"]\n",
" S[\"pred\"] = S[\"r_turn\"] | S[\"r_dive\"] | S[\"r_ml\"] | S[\"other\"]\n",
" return S\n",
"\n",
"\n",
"def summarize(S):\n",
" tp, fp = (S.truth & S.pred).sum(), (~S.truth & S.pred).sum()\n",
" return {\"정확도\": round(float((S.truth == S.pred).mean()), 3), \"재현율\": round(float(tp / S.truth.sum()), 3),\n",
" \"오경보율\": round(float(fp / max(S.pred.sum(), 1)), 3), \"정상 오탐\": int(fp), \"미탐\": int((S.truth & ~S.pred).sum())}\n",
"\n",
"\n",
"def aar_log(S):\n",
" rows = []\n",
" for _, r in S[~S.truth & S.pred].iterrows():\n",
" cause = (\"측량 U턴 급선회\" if r.kind == \"survey\" else \"선회\") if r.r_turn else (\"돌풍 하강(\" + r.weather + \")\" if r.r_dive else \"드문 정상 패턴(ML)\")\n",
" rows.append({\"구분\": \"오탐\", \"원인\": cause, \"비행\": r.id})\n",
" for _, r in S[S.truth & ~S.pred].iterrows():\n",
" rows.append({\"구분\": \"미탐\", \"원인\": f\"{r.kind} 점수 미달\", \"비행\": r.id})\n",
" return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"비행\"])\n",
"\n",
"\n",
"def windows(data):\n",
" return np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in data])\n",
"\n",
"\n",
"base_X = windows(make_dataset(80, 21, anomaly_ratio=0.0))\n",
"model = FlightAnomalyModel(q=0.995).fit(base_X)\n",
"aar_data, hold_data = make_dataset(200, seed=11), make_dataset(200, seed=13)\n",
"aar = flight_stats(aar_data, model) # 모의 운용(AAR 대상)\n",
"hold = flight_stats(hold_data, model) # 회귀 시험용(재보정에 쓰지 않음)\n",
"\n",
"# 1) 초기 설정('놓치지 않기' 우선): 선회 30도/초, 하강 30m, ML 점수 1.0\n",
"init = {\"turn\": 30, \"dive\": 30, \"ml\": 1.0}\n",
"S0 = predict(aar, init)\n",
"before = summarize(S0)\n",
"log = aar_log(S0)\n",
"print(\"[1. 무엇이 일어났나] 초기 설정:\", before)\n",
"print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n",
"\n",
"# 2) 역환류 1: 오탐 원인(측량 U턴·강풍)이 평시 학습 자료에 적었음 -> 측량·강풍 평시 로그 60건을 추가 수집해 재학습\n",
"extra = [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:60]\n",
"model = FlightAnomalyModel(q=0.995).fit(np.vstack([base_X, windows(extra)]))\n",
"aar, hold = flight_stats(aar_data, model), flight_stats(hold_data, model)\n",
"print(f\"\\n[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 {len(extra)}건으로 ML 재학습\")\n",
"\n",
"# 3) 역환류 2: 재현율 95% 이상 제약 안에서 정확도 최대 임계값 조합 탐색\n",
"grid = []\n",
"for t in [30, 45, 60, 90]:\n",
" for dv in [30, 40, 50]:\n",
" for mlt in [1.0, 1.2, 1.5, 2.0]:\n",
" grid.append({\"turn\": t, \"dive\": dv, \"ml\": mlt, **summarize(predict(aar, {\"turn\": t, \"dive\": dv, \"ml\": mlt}))})\n",
"g = pd.DataFrame(grid)\n",
"ok = g[g[\"재현율\"] >= 0.95].sort_values([\"정확도\", \"ml\"], ascending=[False, True])\n",
"best = ok.iloc[0][[\"turn\", \"dive\", \"ml\"]].to_dict()\n",
"print(\"\\n[2. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n",
"print(ok.head().to_string(index=False))\n",
"\n",
"# 3) 재보정 전후 비교(모의 운용 자료 + 회귀 시험 자료)\n",
"S1 = predict(aar, best)\n",
"after = summarize(S1)\n",
"print(f\"\\n재보정값: 선회율 {best['turn']:.0f}도/초, 급강하 {best['dive']:.0f}m, ML 점수 {best['ml']:.1f}\")\n",
"print(\"재보정 후:\", after)\n",
"print(f\"종합 정확도 {before['정확도']:.3f} -> {after['정확도']:.3f} ({(after['정확도'] - before['정확도']) * 100:+.1f}%p, 목표 +10%p: {'달성' if after['정확도'] - before['정확도'] >= 0.1 else '미달'})\")\n",
"h0, h1 = summarize(predict(hold, init)), summarize(predict(hold, best))\n",
"print(f\"회귀 시험(별도 200건): 정확도 {h0['정확도']:.3f} -> {h1['정확도']:.3f}, 재현율 {h0['재현율']:.3f} -> {h1['재현율']:.3f}\")\n",
"print(\"재보정 후 미탐:\", S1[S1.truth & ~S1.pred][\"kind\"].tolist(), \"/ 오탐:\", S1[~S1.truth & S1.pred][\"kind\"].tolist())\n",
"repeat = set(log[\"비행\"]) & set(aar_log(S1)[\"비행\"])\n",
"print(f\"재보정 후에도 반복된 오류: {len(repeat)}건 {sorted(repeat)[:6]}\")\n",
"print(\"[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\")\n",
"\n",
"tuned = f\"rules:\\n turn_rate_dps: {best['turn']:.0f}\\n dive_drop_m: {best['dive']:.0f}\\nml:\\n quantile: 0.995\\n score_threshold: {best['ml']:.1f}\\nconstraints: {{min_recall: 0.95}}\"\n",
"print(\"\\n[재보정 파라미터(isr03_tuned_params.yaml 내용)]\\n\" + tuned)\n",
"\n",
"plt.figure(figsize=(6, 4))\n",
"for t in sorted(g[\"turn\"].unique()):\n",
" s = g[(g[\"turn\"] == t) & (g[\"dive\"] == best[\"dive\"])]\n",
" plt.plot(s[\"ml\"], s[\"정확도\"], \"o-\", label=f\"turn>{t} deg/s\")\n",
"plt.xlabel(\"ML score threshold\"); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n",
"plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1. 무엇이 일어났나] 초기 설정: {'정확도': 0.865, '재현율': 1.0, '오경보율': 0.213, '정상 오탐': 27, '미탐': 0}\n",
"구분 원인 \n",
"오탐 돌풍 하강(windy) 7\n",
" 측량 U턴 급선회 20\n",
"\n",
"[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 55건으로 ML 재학습\n",
"\n",
"[2. 무엇을 바꾸나] 격자 탐색 상위 5개\n",
" turn dive ml 정확도 재현율 오경보율 정상 오탐 미탐\n",
" 60 40 1.2 0.980 0.98 0.020 2 2\n",
" 60 50 1.2 0.980 0.98 0.020 2 2\n",
" 90 40 1.2 0.980 0.98 0.020 2 2\n",
" 90 50 1.2 0.980 0.98 0.020 2 2\n",
" 45 40 1.2 0.965 0.98 0.049 5 2\n",
"\n",
"재보정값: 선회율 60도/초, 급강하 40m, ML 점수 1.2\n",
"재보정 후: {'정확도': 0.98, '재현율': 0.98, '오경보율': 0.02, '정상 오탐': 2, '미탐': 2}\n",
"종합 정확도 0.865 -> 0.980 (+11.5%p, 목표 +10%p: 달성)\n",
"회귀 시험(별도 200건): 정확도 0.850 -> 0.980, 재현율 1.000 -> 0.960\n",
"재보정 후 미탐: ['low_level', 'low_level'] / 오탐: ['survey', 'survey']\n",
"재보정 후에도 반복된 오류: 2건 ['FL0019', 'FL0039']\n",
"[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\n",
"\n",
"[재보정 파라미터(isr03_tuned_params.yaml 내용)]\n",
"rules:\n",
" turn_rate_dps: 60\n",
" dive_drop_m: 40\n",
"ml:\n",
" quantile: 0.995\n",
" score_threshold: 1.2\n",
"constraints: {min_recall: 0.95}\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"드론 비행로그 이상 탐지 시스템을 최종 통합 빌드하고 정보작전 브리핑용 최종 분석 보고서 및 사용자 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **로그 검증부터 전처리, 규칙·ML 탐지, 신뢰도 엔진, 검토 대기열, 대시보드까지 전체 시스템을 통합하고 ISR 정보판단 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 로그가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/main_isr03.py, README.md, docs/isr03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n",
"\n",
"\n",
"- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n",
"\n",
"\n",
"- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"로그 검증·전처리·규칙 탐지·ML 이상 탐지·운용 주체 신뢰도 엔진·정보분석관 검토 대기열·대시보드까지 드론 비행로그 이상 탐지 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 ISR 정보판단 브리핑 요약을 출력한다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import math\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.cluster import KMeans\n",
"from sklearn.ensemble import IsolationForest\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.pipeline import make_pipeline\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n",
"# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n",
"WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n",
"SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n",
" \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n",
"OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n",
"# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n",
"OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n",
" \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n",
" \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n",
" \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n",
"PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n",
"FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n",
"\n",
"\n",
"def _wrap(a):\n",
" # [TODO : 학생 실습 10] 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bearing(p, q):\n",
" # [TODO : 학생 실습 10] 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _away_from_nfz(rng, min_d):\n",
" while True:\n",
" c = rng.uniform(-3800, 3800, 2)\n",
" if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n",
" return c\n",
"\n",
"\n",
"def make_flight(fid, kind, rng, n=300, weather=None):\n",
" \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n",
" weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n",
" wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n",
" v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n",
" nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n",
" if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n",
" plan = np.array([start, nfz_c])\n",
" v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n",
" else:\n",
" if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n",
" else:\n",
" c = _away_from_nfz(rng, 2600)\n",
" if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n",
" w, h = rng.uniform(500, 700), 120.0\n",
" plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n",
" else: # 사각 순찰 경로(웨이포인트 4개)\n",
" a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n",
" R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n",
" plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n",
" s0 = int(rng.integers(80, 160))\n",
" span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n",
" \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n",
" if kind == \"infiltration\":\n",
" s0 = 20\n",
" win = (s0, s0 + span)\n",
" turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n",
" # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n",
" e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n",
" rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n",
" gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n",
" burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n",
" side = float(rng.choice([-90, 90]))\n",
" low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n",
" x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n",
" hdg, alt = _bearing((x, y), plan[1]), alt0\n",
" max_rate = 40.0 if kind == \"survey\" else 12.0\n",
" dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n",
" for t in range(n):\n",
" inw = win[0] <= t < win[1]\n",
" tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n",
" spd_cmd, alt_cmd = v0, alt0\n",
" if inw:\n",
" if kind == \"deviation\":\n",
" if dev_target is None:\n",
" r = math.radians(hdg + side)\n",
" dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n",
" tx, ty = dev_target\n",
" elif kind == \"nfz_entry\":\n",
" tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n",
" elif kind == \"hover\":\n",
" spd_cmd = 0.0\n",
" elif kind == \"speed_burst\":\n",
" spd_cmd = burst\n",
" elif kind == \"low_level\":\n",
" alt_cmd = low_alt\n",
" if kind in (\"rc_silent\", \"infiltration\"):\n",
" rc_arr[t] = rng_rc[t]\n",
" if inw and kind == \"loiter\":\n",
" hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n",
" else:\n",
" d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n",
" hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n",
" if t in turns:\n",
" hdg = (hdg + turn_amt[t]) % 360\n",
" v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n",
" x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n",
" if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n",
" wi += 1\n",
" if inw and kind == \"dive\":\n",
" alt -= 9.0 + e_v[t]\n",
" else:\n",
" alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n",
" alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n",
" if kind == \"dive\" and t >= win[1]:\n",
" alt0 = min(alt0, alt)\n",
" alt = max(alt, 3.0)\n",
" X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n",
" gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n",
" df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n",
" \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n",
" \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n",
" \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n",
" \"x_true\": X, \"y_true\": Y})\n",
" df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n",
" op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n",
" meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n",
" \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n",
" \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n",
" return df, meta\n",
"\n",
"\n",
"def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n",
" \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n",
" d = df.copy()\n",
" d[\"spoof_true\"] = False\n",
" if spoof:\n",
" a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n",
" th = rng.uniform(0, 2 * np.pi)\n",
" drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n",
" jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n",
" d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n",
" d.loc[a:a + L - 1, \"spoof_true\"] = True\n",
" if emi:\n",
" idx = rng.choice(len(d), 6, replace=False)\n",
" d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n",
" if drop:\n",
" a = int(rng.integers(10, len(d) - 20))\n",
" d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n",
" d = d.drop(index=rng.choice(len(d), 5, replace=False))\n",
" if dup:\n",
" d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n",
" return d.reset_index(drop=True)\n",
"\n",
"\n",
"def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for i in range(n):\n",
" if i % 10 < anomaly_ratio * 10:\n",
" kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n",
" else:\n",
" kind = \"survey\" if i % 10 == 9 else \"normal\"\n",
" df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n",
" out.append((df, meta))\n",
" return out\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리·규칙 탐지 공통 함수 ----\n",
"SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n",
"\n",
"\n",
"def clean(df, weather=\"clear\"):\n",
" \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n",
" d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n",
" d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n",
" n_missing = int(d[\"x\"].isna().sum())\n",
" num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n",
" d[num] = d[num].interpolate(limit_direction=\"both\")\n",
" hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n",
" un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n",
" d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n",
" med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n",
" spike = (d[\"alt\"] - med).abs() > 25\n",
" d.loc[spike, \"alt\"] = med[spike]\n",
" x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n",
" r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n",
" dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n",
" xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n",
" thr = SPOOF_THR.get(weather, 12.0)\n",
" ok_run = 0\n",
" for i in range(1, len(x)):\n",
" px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n",
" step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n",
" ok_run = ok_run + 1 if step_ok else 0\n",
" if flag[i - 1]:\n",
" # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n",
" if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n",
" xc[i], yc[i] = x[i], y[i]\n",
" else:\n",
" xc[i], yc[i], flag[i] = px, py, True\n",
" elif not step_ok:\n",
" xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n",
" else:\n",
" xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n",
" d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n",
" d = d.reset_index()\n",
" d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n",
" return d\n",
"\n",
"\n",
"def xtrack(px, py, plan):\n",
" \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n",
" P = np.asarray(plan, float)\n",
" segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n",
" best = np.full(len(px), np.inf)\n",
" for a, b in segs:\n",
" ab = b - a\n",
" u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n",
" best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n",
" return best\n",
"\n",
"\n",
"RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n",
"\n",
"\n",
"def rule_flags(d, plan, R=RULES):\n",
" \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n",
" run = np.zeros(len(slow), int)\n",
" for i in range(len(slow)):\n",
" run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n",
" hover = np.zeros(len(slow), bool)\n",
" for i in np.where(run >= R[\"hover_sec\"])[0]:\n",
" hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n",
" alt = d[\"alt\"].to_numpy(float)\n",
" drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n",
" return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n",
" \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n",
" \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n",
"\n",
"# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n",
"WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n",
" \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n",
"\n",
"\n",
"def window_feats(d, plan, win=30, step=10):\n",
" \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n",
" x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n",
" spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n",
" hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n",
" vz = np.diff(alt, prepend=alt[0])\n",
" nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n",
" xt = xtrack(x, y, plan)\n",
" step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n",
" rows, starts = [], []\n",
" for s in range(0, len(d) - win + 1, step):\n",
" e = s + win\n",
" path = step_len[s + 1:e].sum()\n",
" rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n",
" spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n",
" rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n",
" starts.append(int(d[\"t\"].iloc[s]))\n",
" return np.array(rows), starts\n",
"\n",
"\n",
"class FlightAnomalyModel:\n",
" \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n",
" 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n",
"\n",
" def __init__(self, q=0.999, k=4):\n",
" self.q, self.k = q, k\n",
"\n",
" def fit(self, X):\n",
" self.sc = StandardScaler().fit(X)\n",
" Z = self.sc.transform(X)\n",
" self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n",
" self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n",
" self.set_q(self.q, X)\n",
" return self\n",
"\n",
" def _raw(self, X):\n",
" Z = self.sc.transform(X)\n",
" return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n",
"\n",
" def set_q(self, q, X):\n",
" a, b = self._raw(X)\n",
" self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n",
"\n",
" def score(self, X):\n",
" a, b = self._raw(X)\n",
" return np.maximum(a / self.t_if, b / self.t_km)\n",
"\n",
"\n",
"AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n",
" \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n",
"\n",
"\n",
"def attr_features(d, m, ml_score):\n",
" \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n",
" return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n",
" float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n",
" float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n",
" *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n",
" *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n",
"\n",
"CONFIG = {\"turn_rate\": 60, \"dive_m\": 40, \"ml_threshold\": 1.2, \"ml_quantile\": 0.995, # 미션 9 재보정값\n",
" \"p_high\": 0.60, \"h_max\": 0.80, \"p_med\": 0.25}\n",
"CALLED = set()\n",
"\n",
"\n",
"def cover(fn):\n",
" \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n",
" def wrap(*a, **k):\n",
" CALLED.add(fn.__name__)\n",
" return fn(*a, **k)\n",
" wrap.__name__ = fn.__name__\n",
" return wrap\n",
"\n",
"\n",
"@cover\n",
"def validate_log(df, m):\n",
" errs = [f\"{c} 누락\" for c in (\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\") if c not in df]\n",
" if m[\"weather\"] not in SPOOF_THR:\n",
" errs.append(\"weather 허용값 아님\")\n",
" if not errs and ((df[\"speed\"] > 60).any() or (df[\"rc\"] > 100).any()):\n",
" errs.append(\"범위 오류\")\n",
" return errs\n",
"\n",
"\n",
"@cover\n",
"def grade(p, h, q_ok):\n",
" if p >= CONFIG[\"p_high\"] and h <= CONFIG[\"h_max\"] and q_ok:\n",
" return \"High\"\n",
" return \"Medium\" if p >= CONFIG[\"p_med\"] else \"Low\"\n",
"\n",
"\n",
"@cover\n",
"def statement(fid, g, p, h, kinds):\n",
" return f\"{fid}: 이상 비행({kinds}), 적대 운용 가능성 {g}(p={p:.2f}, 불확실성 {h:.2f}) - 정보분석관 검토 필요\"\n",
"\n",
"\n",
"class Queue:\n",
" def __init__(self):\n",
" self.items, self.log = [], []\n",
"\n",
" @cover\n",
" def submit(self, fid, g, text):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {\"event\": \"SUBMIT\", \"id\": fid, \"grade\": g, \"text\": text}\n",
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n",
" self.items.append({**body, \"status\": \"PENDING\"})\n",
"\n",
" @cover\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 not in (\"prev\", \"hash\")}\n",
" if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n",
" return False\n",
" prev = r[\"hash\"]\n",
" return True\n",
"\n",
"\n",
"clean, rule_flags, window_feats, attr_features, corrupt = map(cover, (clean, rule_flags, window_feats, attr_features, corrupt))\n",
"FlightAnomalyModel.score = cover(FlightAnomalyModel.score)\n",
"TARGETS = {\"validate_log\", \"clean\", \"rule_flags\", \"window_feats\", \"score\", \"attr_features\", \"grade\", \"statement\", \"submit\", \"verify\", \"corrupt\", \"dashboard\"}\n",
"\n",
"\n",
"def analyze(df, m, det):\n",
" d = clean(df, m[\"weather\"])\n",
" f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": CONFIG[\"turn_rate\"], \"dive_m\": CONFIG[\"dive_m\"]})\n",
" s = float(det.score(window_feats(d, m[\"plan\"])[0]).max())\n",
" hits = [k for k, v in f.items() if v.any()] + ([\"ML이상\"] if s > CONFIG[\"ml_threshold\"] else [])\n",
" return d, hits, s\n",
"\n",
"\n",
"# ---------------- 1. 학습(평시 패턴 + 과거 검토 완료 사례) ----------------\n",
"t_start = time.perf_counter()\n",
"routine = make_dataset(80, 21, anomaly_ratio=0.0) + [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:55]\n",
"det = FlightAnomalyModel(q=CONFIG[\"ml_quantile\"]).fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in routine]))\n",
"past = []\n",
"for df, m in make_dataset(160, 31):\n",
" d, hits, s = analyze(df, m, det)\n",
" past.append((attr_features(d, m, s), m[\"operator\"]))\n",
"eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit([p[0] for p in past], [p[1] for p in past])\n",
"\n",
"# ---------------- 2. 운용 자료 처리 ----------------\n",
"rng = np.random.default_rng(10)\n",
"ops = make_dataset(100, 11)\n",
"ops = [(corrupt(df, rng) if rng.random() < 0.25 else df, m) for df, m in ops]\n",
"ops[7] = (ops[7][0].drop(columns=[\"rc\"]), ops[7][1]) # 손상 로그 1건 주입(검증 단계에서 차단되어야 함)\n",
"queue, rejected, res, ms = Queue(), {}, [], []\n",
"for df, m in ops:\n",
" errs = validate_log(df, m)\n",
" if errs:\n",
" rejected[m[\"id\"]] = errs; continue\n",
" t0 = time.perf_counter()\n",
" d, hits, s = analyze(df, m, det)\n",
" P = eng.predict_proba([attr_features(d, m, s)])[0]\n",
" ph = P[list(eng.classes_).index(\"hostile\")]; H = float(-(P * np.log(np.clip(P, 1e-9, 1))).sum() / np.log(3))\n",
" q_ok = d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15\n",
" g = grade(ph, H, q_ok) if hits else \"-\"\n",
" if hits:\n",
" queue.submit(m[\"id\"], g, statement(m[\"id\"], g, ph, H, \",\".join(hits)))\n",
" ms.append((time.perf_counter() - t0) * 1000)\n",
" res.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits), \"grade\": g, \"d\": d})\n",
"\n",
"# ---------------- 3. 성능 평가 ----------------\n",
"R = pd.DataFrame(res)\n",
"acc = (R.truth == R.pred).mean(); rec = (R.truth & R.pred).sum() / R.truth.sum(); far = (~R.truth & R.pred).sum() / max(R.pred.sum(), 1)\n",
"high = R[R.grade == \"High\"]; hfar = (high.op != \"hostile\").mean() if len(high) else 0.0\n",
"\n",
"# ---------------- 4. 대시보드 ----------------\n",
"@cover\n",
"def dashboard():\n",
" plt.style.use(\"dark_background\")\n",
" fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n",
" for _, r in R[R.pred].iterrows():\n",
" ax[0, 0].plot(r.d[\"x\"], r.d[\"y\"], lw=0.8, color={\"High\": \"red\", \"Medium\": \"orange\", \"Low\": \"gray\"}[r.grade])\n",
" ax[0, 0].add_patch(plt.Circle((NFZ[\"x\"], NFZ[\"y\"]), NFZ[\"r\"], color=\"red\", alpha=.2)); ax[0, 0].set_aspect(\"equal\")\n",
" ax[0, 0].set_title(\"Flagged flights (color = attribution grade)\")\n",
" R[R.pred][\"grade\"].value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0).plot.bar(ax=ax[0, 1], color=[\"red\", \"orange\", \"gray\"])\n",
" ax[0, 1].set_title(\"Review queue by grade (all pending)\")\n",
" R[R.pred][\"kind\"].value_counts().plot.barh(ax=ax[1, 0], color=\"deepskyblue\"); ax[1, 0].set_title(\"Flagged flights by true scenario\")\n",
" ax[1, 1].axis(\"off\")\n",
" ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nRecall {rec:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {np.mean(ms):.0f} ms/flight\",\n",
" family=\"monospace\", fontsize=13, va=\"top\")\n",
" fig.suptitle(\"ISR03 integrated drone-log analysis - training data only\"); plt.tight_layout(); plt.show()\n",
"\n",
"\n",
"dashboard()\n",
"\n",
"# ---------------- 5. 자체 점검(통합 테스트) ----------------\n",
"log_ok = queue.verify()\n",
"cov = len(CALLED & TARGETS) / len(TARGETS)\n",
"chk = {\n",
" \"손상 로그 차단\": \"FL0007\" in rejected,\n",
" \"처리 비행 99건\": len(res) == 99,\n",
" \"이상 비행 재현율 90% 이상\": rec >= 0.9,\n",
" \"정확도 90% 이상\": acc >= 0.9,\n",
" \"자동 채택 0건(전부 검토 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n",
" \"탐지 건 전량 대기열 등록\": len(queue.items) == int(R.pred.sum()),\n",
" \"감사 로그 무결성\": log_ok,\n",
" \"단정 표현('확정') 0건\": not any(\"확정\" in i[\"text\"] for i in queue.items),\n",
" \"비행 1건 200ms 이내\": float(np.mean(ms)) < 200,\n",
" \"기능 커버리지 85% 이상\": cov >= 0.85,\n",
"}\n",
"passed = sum(chk.values())\n",
"for k, v in chk.items():\n",
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
"print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n",
"\n",
"# ---------------- 6. ISR 정보판단 브리핑 요약 ----------------\n",
"print(f\"\"\"\n",
"[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n",
"1. 처리: 비행로그 {len(ops)}건 중 {len(res)}건 분석, 손상 로그 {len(rejected)}건 차단({', '.join(rejected)})\n",
"2. 성능: 정확도 {acc:.1%}, 이상 재현율 {rec:.1%}, 오경보율 {far:.1%}, 비행당 {np.mean(ms):.0f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n",
"3. 검토 대기: {len(queue.items)}건 (High {int((R.grade == 'High').sum())} / Medium {int((R.grade == 'Medium').sum())} / Low {int((R.grade == 'Low').sum())}), 자동 채택 0건\n",
"4. 운용 주체: High 등급 {len(high)}건 중 실제 비적대 {int((high.op != 'hostile').sum())}건({hfar:.0%}) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n",
"5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\"\"\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[PASS] 손상 로그 차단\n",
"[PASS] 처리 비행 99건\n",
"[PASS] 이상 비행 재현율 90% 이상\n",
"[PASS] 정확도 90% 이상\n",
"[PASS] 자동 채택 0건(전부 검토 대기)\n",
"[PASS] 탐지 건 전량 대기열 등록\n",
"[PASS] 감사 로그 무결성\n",
"[PASS] 단정 표현('확정') 0건\n",
"[PASS] 비행 1건 200ms 이내\n",
"[PASS] 기능 커버리지 85% 이상\n",
"자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n",
"\n",
"[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n",
"1. 처리: 비행로그 100건 중 99건 분석, 손상 로그 1건 차단(FL0007)\n",
"2. 성능: 정확도 96.0%, 이상 재현율 98.0%, 오경보율 5.8%, 비행당 31 ms, 전체 12 s\n",
"3. 검토 대기: 52건 (High 7 / Medium 22 / Low 23), 자동 채택 0건\n",
"4. 운용 주체: High 등급 7건 중 실제 비적대 2건(29%) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n",
"5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}