3713 lines
259 KiB
Plaintext
3713 lines
259 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# 🎯 [WEL03] 가상 수질검사 데이터 위험 신호 대시보드\n",
|
|
"### Google Colab 실습 환경 (장병 복지·지원 / Level 2)\n",
|
|
"\n",
|
|
"[](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/WEL03/WEL03_solution.ipynb)\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n",
|
|
"## 📌 1. 프로젝트 개요 및 배경\n",
|
|
"\n",
|
|
"| 항목 | 내용 |\n",
|
|
"| :--- | :--- |\n",
|
|
"| **과정 분류** | 장병 복지·지원 (Level 2) |\n",
|
|
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
|
|
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
|
|
"\n",
|
|
"### 📖 프로젝트 목표\n",
|
|
"전장에서는 급수차, 정수 장비, 우물, 하천 취수원 등 급수원의 수질 이상을 빨리 알아채지 못하면 장병의 건강과 부대 활동에 바로 영향을 주지만, 센서 값은 잡음과 고장이 많아 오경보로 불필요하게 급수를 끊는 일도 생긴다. 러시아-우크라이나 전쟁 사례에서 학습 초점으로 삼은 전장 위생·건강 위험 관리를 반영해, 본 프로젝트는 가상 수질검사 데이터를 분석해 위험 신호를 데이터로 판단하는 과제를 다룬다. 교육용 가상 급수원 8곳의 pH, 탁도, 잔류염소, 중금속, 대장균 측정 자료를 코드로 만들어 스키마 정의, 센서 결측·이상치 정제, 기준치 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. 야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균) 데이터셋 정의\n",
|
|
"**소요시간**: 약 1.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 센서·전술 환경 정보의 스키마와 데이터 명세를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **측정 레코드를 급수원 정보, 수질 5항목, 센서 상태, 전술 파라미터(기상, 위치 오차, 통신 지연) 네 묶음 15개 필드로 정의하고 자료형·단위·허용 범위·형식 규칙을 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `data/schema/wel03_input_schema.json 및 docs/wel03_data_spec.md` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 장병 복지·지원 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\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": [
|
|
"\"\"\"야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 급수원·센서·전술 파라미터를 담은 입력 데이터 스키마를 정의하고, 가상 샘플 20건과 오류 주입 데이터로 검증기를 시험한다.\"\"\"\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# 1) 스키마 정의: 급수원 정보, 수질 측정값(5항목), 센서 상태, 전술 파라미터(기상·위치오차·통신지연)\n",
|
|
"SCHEMA = {\n",
|
|
" \"required\": [\"record_id\", \"source\", \"measured_at\", \"quality\", \"sensor\", \"tactical\"],\n",
|
|
" \"fields\": {\n",
|
|
" \"record_id\": {\"type\": str, \"pattern\": r\"^WQ-W\\d{2}-\\d{4}$\"},\n",
|
|
" \"measured_at\": {\"type\": str, \"pattern\": r\"^D\\+\\d{2} \\d{2}:00$\"},\n",
|
|
" },\n",
|
|
" \"source\": {\n",
|
|
" \"source_id\": {\"type\": str, \"pattern\": r\"^W\\d{2}$\"},\n",
|
|
" \"source_type\": {\"type\": str, \"enum\": [\"water_truck\", \"purification_unit\", \"well\", \"river_intake\"]},\n",
|
|
" \"grid_km\": {\"type\": list, \"length\": 2},\n",
|
|
" },\n",
|
|
" \"quality\": { # 단위와 물리적으로 가능한 범위(기준치가 아니라 측정 가능 범위)\n",
|
|
" \"ph\": {\"type\": (int, float), \"min\": 0, \"max\": 14, \"unit\": \"-\"},\n",
|
|
" \"turbidity_ntu\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"NTU\"},\n",
|
|
" \"chlorine_mgL\": {\"type\": (int, float), \"min\": 0, \"max\": 10, \"unit\": \"mg/L\"},\n",
|
|
" \"metal_ugL\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"ug/L\"},\n",
|
|
" \"ecoli_cfu\": {\"type\": int, \"min\": 0, \"max\": 100000, \"unit\": \"CFU/100mL\"},\n",
|
|
" },\n",
|
|
" \"sensor\": {\n",
|
|
" \"status\": {\"type\": str, \"enum\": [\"ok\", \"calibration_due\", \"fault\", \"offline\"]},\n",
|
|
" \"last_calibrated_h\": {\"type\": int, \"min\": 0, \"max\": 720},\n",
|
|
" },\n",
|
|
" \"tactical\": {\n",
|
|
" \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"night\", \"snow\", \"dust\"]},\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",
|
|
"\n",
|
|
"\n",
|
|
"def check(name, value, rule):\n",
|
|
" \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n",
|
|
" if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n",
|
|
" return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n",
|
|
" errs = []\n",
|
|
" if \"enum\" in rule and value not in rule[\"enum\"]:\n",
|
|
" errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n",
|
|
" if \"min\" in rule and value < rule[\"min\"]:\n",
|
|
" errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n",
|
|
" if \"max\" in rule and value > rule[\"max\"]:\n",
|
|
" errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n",
|
|
" if \"length\" in rule and len(value) != rule[\"length\"]:\n",
|
|
" errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n",
|
|
" if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n",
|
|
" errs.append(f\"{name}: 형식 불일치 '{value}'\")\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"def validate(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 (\"source\", \"quality\", \"sensor\", \"tactical\"):\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",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"# 2) [교육용가상자료] 가상 센서 데이터에서 샘플 20건을 뽑아 스키마 형식(JSON)으로 변환\n",
|
|
"data = make_dataset(days=3, seed=1, n_events=1, n_faults=0)\n",
|
|
"rng = np.random.default_rng(1)\n",
|
|
"rows = data.sample(20, random_state=1).sort_values([\"source_id\", \"t\"])\n",
|
|
"records = []\n",
|
|
"for _, r in rows.iterrows():\n",
|
|
" records.append({\n",
|
|
" \"record_id\": f\"WQ-{r['source_id']}-{int(r['t']):04d}\", \"measured_at\": f\"D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}:00\",\n",
|
|
" \"source\": {\"source_id\": r[\"source_id\"], \"source_type\": r[\"source_type\"], \"grid_km\": [r[\"x_km\"], r[\"y_km\"]]},\n",
|
|
" \"quality\": {\"ph\": float(r[\"ph\"]), \"turbidity_ntu\": float(r[\"turbidity\"]), \"chlorine_mgL\": float(r[\"chlorine\"]),\n",
|
|
" \"metal_ugL\": float(r[\"metal\"]), \"ecoli_cfu\": int(r[\"ecoli\"])},\n",
|
|
" \"sensor\": {\"status\": \"ok\", \"last_calibrated_h\": int(rng.integers(1, 200))},\n",
|
|
" \"tactical\": {\"weather\": {\"storm\": \"rain\", \"night\": \"night\", \"day\": \"clear\"}[r[\"cond\"]],\n",
|
|
" \"position_error_m\": float(round(rng.uniform(3, 40), 1)), \"comm_delay_ms\": int(rng.integers(30, 900))}})\n",
|
|
"\n",
|
|
"ok = sum(1 for r in records if not validate(r))\n",
|
|
"print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n",
|
|
"parsed = json.loads(json.dumps(records, ensure_ascii=False)) # 직렬화 -> 파싱 왕복 시험\n",
|
|
"print(\"JSON 직렬화·파싱 왕복 후 값 일치:\", parsed == records)\n",
|
|
"\n",
|
|
"# 3) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n",
|
|
"bad = json.loads(json.dumps(records[0]))\n",
|
|
"bad[\"record_id\"] = \"WQ-1\" # 형식 오류\n",
|
|
"bad[\"quality\"][\"ph\"] = 15.2 # 물리 범위 초과\n",
|
|
"bad[\"quality\"][\"ecoli_cfu\"] = \"검출\" # 자료형 오류(숫자여야 함)\n",
|
|
"bad[\"source\"][\"source_type\"] = \"bottle\" # 허용되지 않은 급수원 유형\n",
|
|
"del bad[\"tactical\"][\"comm_delay_ms\"] # 전술 파라미터 누락\n",
|
|
"print(\"\\n[오류 주입 결과]\")\n",
|
|
"errs = validate(bad)\n",
|
|
"for e in errs:\n",
|
|
" print(\" -\", e)\n",
|
|
"print(f\"주입 오류 5종 중 검출 {len(errs)}종 -> {'모두 검출' if len(errs) >= 5 else '누락 있음'}\")\n",
|
|
"\n",
|
|
"# 4) 데이터 명세 요약(명세서 초안)\n",
|
|
"spec = []\n",
|
|
"for g in (\"fields\", \"source\", \"quality\", \"sensor\", \"tactical\"):\n",
|
|
" for k, r in SCHEMA[g].items():\n",
|
|
" t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n",
|
|
" spec.append((g, k, t, r.get(\"unit\", \"\"), str({x: y for x, y in r.items() if x not in (\"type\", \"unit\")})))\n",
|
|
"print(\"\\n[데이터 명세 요약]\")\n",
|
|
"print(pd.DataFrame(spec, columns=[\"그룹\", \"필드\", \"자료형\", \"단위\", \"규칙\"]).to_string(index=False))\n",
|
|
"done = sum(1 for s in spec if s[2] and s[4] != \"{}\")\n",
|
|
"print(f\"스키마 필드 정의 완성도 {done}/{len(spec)} ({done / len(spec):.0%}): 모든 필드에 자료형·검증 규칙 정의\")\n",
|
|
"print(\"\\n[가정 판정 기준(교육용)] (주의, 위험):\", {k: v for k, v in LIMITS.items()})\n",
|
|
"print(\"급수원 유형별 샘플 수:\", rows[\"source_type\"].value_counts().to_dict())\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"정상 샘플 20건 중 검증 통과 20건, 오류 0건\n",
|
|
"JSON 직렬화·파싱 왕복 후 값 일치: True\n",
|
|
"\n",
|
|
"[오류 주입 결과]\n",
|
|
" - record_id: 형식 불일치 'WQ-1'\n",
|
|
" - source.source_type: 허용되지 않은 값 'bottle'\n",
|
|
" - quality.ph: 최대값 14 초과\n",
|
|
" - quality.ecoli_cfu: 자료형 오류(str)\n",
|
|
" - tactical.comm_delay_ms: 필수 필드 누락\n",
|
|
"주입 오류 5종 중 검출 5종 -> 모두 검출\n",
|
|
"\n",
|
|
"[데이터 명세 요약]\n",
|
|
" 그룹 필드 자료형 단위 규칙\n",
|
|
" fields record_id str {'pattern': '^WQ-W\\\\d{2}-\\\\d{4}$'}\n",
|
|
" fields measured_at str {'pattern': '^D\\\\+\\\\d{2} \\\\d{2}:00$'}\n",
|
|
" source source_id str {'pattern': '^W\\\\d{2}$'}\n",
|
|
" source source_type str {'enum': ['water_truck', 'purification_unit', 'well', 'river_intake']}\n",
|
|
" source grid_km list {'length': 2}\n",
|
|
" quality ph number - {'min': 0, 'max': 14}\n",
|
|
" quality turbidity_ntu number NTU {'min': 0, 'max': 1000}\n",
|
|
" quality chlorine_mgL number mg/L {'min': 0, 'max': 10}\n",
|
|
" quality metal_ugL number ug/L {'min': 0, 'max': 1000}\n",
|
|
" quality ecoli_cfu int CFU/100mL {'min': 0, 'max': 100000}\n",
|
|
" sensor status str {'enum': ['ok', 'calibration_due', 'fault', 'offline']}\n",
|
|
" sensor last_calibrated_h int {'min': 0, 'max': 720}\n",
|
|
"tactical weather str {'enum': ['clear', 'rain', 'night', 'snow', 'dust']}\n",
|
|
"tactical position_error_m number {'min': 0, 'max': 500}\n",
|
|
"tactical comm_delay_ms number {'min': 0, 'max': 10000}\n",
|
|
"스키마 필드 정의 완성도 15/15 (100%): 모든 필드에 자료형·검증 규칙 정의\n",
|
|
"\n",
|
|
"[가정 판정 기준(교육용)] (주의, 위험): {'ph': (5.8, 8.5), 'turbidity': (1.0, 2.0), 'chlorine': (0.2, 0.1), 'metal': (5.0, 10.0), 'ecoli': (1, 1)}\n",
|
|
"급수원 유형별 샘플 수: {'purification_unit': 7, 'river_intake': 7, 'water_truck': 3, 'well': 3}\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 02. 수질 센서 결측치 대체 및 시계열 센서 이상치 정제 전처리 모듈 구현\n",
|
|
"**소요시간**: 약 2.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"수질 센서 결측치 대체 및 시계열 이상치 정제 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수질 센서의 결측치를 보정하고 시계열 이상치를 정제하는 전처리 모듈을 구현해 속도와 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **급수원별 시계열에서 물리 범위 밖 값, 같은 값이 반복되는 고정값(stuck), 튀었다가 바로 돌아오는 단발 스파이크를 표시·제거하고 짧은 결측은 선형 보간, 긴 결측은 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/preprocess/wel03_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": [
|
|
"\"\"\"수질 센서 시계열의 물리 범위 밖 값·고정값(stuck)·단발 스파이크를 정제하고 결측을 보정하는 전처리 모듈을 구현한 뒤, 처리 속도·오류율·보정 정확도와 단위 테스트 5종을 확인한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"data = make_dataset(days=14, seed=7, n_events=8, n_faults=16)\n",
|
|
"\n",
|
|
"# 1) 처리 속도와 오류율\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"c = clean(data)\n",
|
|
"dt = time.perf_counter() - t0\n",
|
|
"remain = int(c[PARAMS].isna().sum().sum())\n",
|
|
"print(f\"전처리 {len(data):,}건, {len(data) / dt:,.0f}건/초, 처리 후 남은 결측 {remain}건, 오류율 {remain / len(data):.1%}\")\n",
|
|
"\n",
|
|
"# 2) 전처리 전후 비교: 센서 고장 유형별로 무엇이 처리되었는가\n",
|
|
"flag_cols = [f\"{p}_flag\" for p in PARAMS]\n",
|
|
"c[\"처리내역\"] = c[flag_cols].apply(lambda r: \",\".join(sorted({x for x in r if x})) or \"-\", axis=1)\n",
|
|
"print(\"\\n[실제 고장 유형 x 전처리 처리 내역] (행 수)\")\n",
|
|
"print(pd.crosstab(c[\"fault\"], c[\"처리내역\"]).to_string())\n",
|
|
"print(f\"결측 셀 수: 전처리 전 {int(data[PARAMS].isna().sum().sum())} -> 후 {remain}\")\n",
|
|
"\n",
|
|
"# 3) 결측 보정 정확도: 정상 구간에서 값을 일부 지워 보고, 실제 값과 비교\n",
|
|
"rng = np.random.default_rng(3)\n",
|
|
"test = data.copy()\n",
|
|
"normal_idx = test.index[(test[\"fault\"] == \"none\") & (test[\"event\"] == \"none\")]\n",
|
|
"holes = rng.choice(normal_idx, 120, replace=False)\n",
|
|
"test.loc[holes, \"chlorine\"] = np.nan\n",
|
|
"filled = clean(test).set_index([\"source_id\", \"t\"])\n",
|
|
"truth = data.loc[holes].set_index([\"source_id\", \"t\"])[\"true_chlorine\"]\n",
|
|
"mae = (filled.loc[truth.index, \"chlorine\"] - truth).abs().mean()\n",
|
|
"mean_fill = (data[\"chlorine\"].mean() - truth).abs().mean()\n",
|
|
"print(f\"\\n잔류염소 결측 120건 보정 오차(MAE): 선형 보간 {mae:.3f} mg/L vs 전체 평균 대체 {mean_fill:.3f} mg/L\")\n",
|
|
"\n",
|
|
"# 4) 단위 테스트 5종\n",
|
|
"def unit_tests():\n",
|
|
" base = make_dataset(days=3, seed=5, n_events=0, n_faults=0, events=[], faults=[])\n",
|
|
" res = {}\n",
|
|
" t = base.copy(); t.loc[(t.source_id == \"W01\") & (t.t == 30), \"ph\"] = 15.0\n",
|
|
" r = clean(t).query(\"source_id == 'W01' and t == 30\").iloc[0]\n",
|
|
" res[\"T1 물리 범위 밖 pH 15.0 제거 후 보정\"] = r[\"ph_flag\"] == \"range\" and 6.0 < r[\"ph\"] < 8.0\n",
|
|
" t = base.copy(); t.loc[(t.source_id == \"W03\") & (t.t == 40), \"turbidity\"] = 38.0\n",
|
|
" r = clean(t).query(\"source_id == 'W03' and t == 40\").iloc[0]\n",
|
|
" res[\"T2 단발 스파이크(탁도 38) 제거\"] = r[\"turbidity_flag\"] == \"spike\" and r[\"turbidity\"] < 1.0\n",
|
|
" ev = make_dataset(days=3, seed=5, events=[(2, \"sewage\", 30, 12, 1.0)], faults=[])\n",
|
|
" r = clean(ev).query(\"source_id == 'W03' and 30 <= t <= 45\")\n",
|
|
" res[\"T3 지속 상승(하수 유입)은 보존\"] = (r[\"turbidity_flag\"] != \"spike\").all() and r[\"turbidity\"].max() > 2.0\n",
|
|
" fz = make_dataset(days=3, seed=5, events=[], faults=[(4, \"stuck\", \"chlorine\", 20, 10)])\n",
|
|
" r = clean(fz).query(\"source_id == 'W05'\")\n",
|
|
" res[\"T4 10시간 고정값 -> stuck 표시\"] = (r[\"chlorine_flag\"] == \"stuck\").sum() == 9\n",
|
|
" t = base.copy(); t.loc[(t.source_id == \"W07\") & t.t.isin([10, 11]), PARAMS] = np.nan\n",
|
|
" t.loc[(t.source_id == \"W07\") & t.t.between(40, 47), PARAMS] = np.nan\n",
|
|
" r = clean(t).query(\"source_id == 'W07'\").set_index(\"t\")\n",
|
|
" res[\"T5 2시간 결측 보간 / 8시간 결측 표시\"] = (r.loc[[10, 11], \"ph_flag\"] == \"interp\").all() and (r.loc[40:47, \"ph_flag\"] == \"long_gap\").all()\n",
|
|
" return res\n",
|
|
"\n",
|
|
"\n",
|
|
"res = unit_tests()\n",
|
|
"print(\"\\n[단위 테스트]\")\n",
|
|
"for k, v in res.items():\n",
|
|
" print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n",
|
|
"print(f\"단위 테스트 {sum(res.values())}/5 통과\")\n",
|
|
"\n",
|
|
"# 5) 상황별(주간/야간/악천후) 임계값 동적 조정 효과: 최소 변화폭 하한을 끄고 배수만 비교\n",
|
|
"# (고장이 없는 행이 스파이크로 잘못 제거된 건수 / 실제 스파이크 고장 검출 건수)\n",
|
|
"no_floor = {p: 0.0 for p in MIN_JUMP}\n",
|
|
"for name, k in [(\"고정 임계(모든 상황 3배)\", {\"day\": 3.0, \"night\": 3.0, \"storm\": 3.0}), (\"상황별 동적 임계\", SPIKE_K)]:\n",
|
|
" cc = clean(data, spike_k=k, min_jump=no_floor)\n",
|
|
" sp = (cc[flag_cols] == \"spike\").any(axis=1)\n",
|
|
" wrong, caught = int((sp & (cc[\"fault\"] != \"spike\")).sum()), int((sp & (cc[\"fault\"] == \"spike\")).sum())\n",
|
|
" print(f\"{name}: 정상 변동 오제거 {wrong}건(야간·악천후 {int((sp & (cc['fault'] != 'spike') & (cc['cond'] != 'day')).sum())}건), 스파이크 검출 {caught}건\")\n",
|
|
"print(\"운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\")\n",
|
|
"\n",
|
|
"# 6) 전후 비교 그림\n",
|
|
"sid = data.loc[data[\"fault\"].isin([\"spike\", \"dropout\"]), \"source_id\"].iloc[0]\n",
|
|
"raw, cl = data[data.source_id == sid], c[c.source_id == sid]\n",
|
|
"fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True)\n",
|
|
"for a, p in zip(ax, [\"turbidity\", \"chlorine\"]):\n",
|
|
" a.plot(raw[\"t\"], raw[p], color=\"lightgray\", lw=3, label=\"raw sensor\")\n",
|
|
" a.plot(cl[\"t\"], cl[p], color=\"tab:blue\", lw=1, label=\"cleaned\")\n",
|
|
" a.set_ylabel(p)\n",
|
|
" a.legend(loc=\"upper right\")\n",
|
|
"ax[1].set_xlabel(\"hour\")\n",
|
|
"plt.suptitle(f\"{sid} before / after preprocessing (synthetic)\")\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"전처리 2,688건, 22,643건/초, 처리 후 남은 결측 0건, 오류율 0.0%\n",
|
|
"\n",
|
|
"[실제 고장 유형 x 전처리 처리 내역] (행 수)\n",
|
|
"처리내역 - long_gap spike stuck\n",
|
|
"fault \n",
|
|
"drift 78 8 0 0\n",
|
|
"dropout 0 17 0 0\n",
|
|
"none 2485 0 0 0\n",
|
|
"spike 1 0 2 0\n",
|
|
"stuck 6 0 0 91\n",
|
|
"결측 셀 수: 전처리 전 125 -> 후 0\n",
|
|
"\n",
|
|
"잔류염소 결측 120건 보정 오차(MAE): 선형 보간 0.042 mg/L vs 전체 평균 대체 0.122 mg/L\n",
|
|
"\n",
|
|
"[단위 테스트]\n",
|
|
" [PASS] T1 물리 범위 밖 pH 15.0 제거 후 보정\n",
|
|
" [PASS] T2 단발 스파이크(탁도 38) 제거\n",
|
|
" [PASS] T3 지속 상승(하수 유입)은 보존\n",
|
|
" [PASS] T4 10시간 고정값 -> stuck 표시\n",
|
|
" [PASS] T5 2시간 결측 보간 / 8시간 결측 표시\n",
|
|
"단위 테스트 5/5 통과\n",
|
|
"고정 임계(모든 상황 3배): 정상 변동 오제거 7건(야간·악천후 6건), 스파이크 검출 3건\n",
|
|
"상황별 동적 임계: 정상 변동 오제거 1건(야간·악천후 0건), 스파이크 검출 2건\n",
|
|
"운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 03. 음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음용 적합·부적합 판정의 1차 기준치 필터링 Baseline 알고리즘을 구현하고 정확도를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **전처리한 자료에 pH, 탁도, 잔류염소, 중금속, 대장균의 주의·위험 가정 기준을 적용해 안전·주의·위험 3등급 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/wel03_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 time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"\n",
|
|
"# 기준 벤치마크 데이터셋: 가상 급수원 8곳 x 14일, 오염 사건 10건, 센서 고장 14건 (정답 등급 grade 포함)\n",
|
|
"bench = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n",
|
|
"truth_raw = bench.sort_values([\"source_id\", \"t\"])[\"grade\"].to_numpy()\n",
|
|
"\n",
|
|
"# 1) 전처리 없이(결측은 0으로) vs 전처리 후 Baseline 판정\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"c = clean(bench)\n",
|
|
"c[\"pred\"] = rule_grade(c)\n",
|
|
"dt = time.perf_counter() - t0\n",
|
|
"raw = bench.sort_values([\"source_id\", \"t\"]).fillna({p: 0 for p in PARAMS})\n",
|
|
"acc_raw = (rule_grade(raw) == truth_raw).mean()\n",
|
|
"acc = (c[\"pred\"] == c[\"grade\"]).mean()\n",
|
|
"print(f\"[교육용가상자료] 벤치마크 {len(c):,}건(급수원 8곳 x 336시간)\")\n",
|
|
"print(f\"정확도: 전처리 없음 {acc_raw:.1%} -> 전처리 + 기준치 필터 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})\")\n",
|
|
"print(f\"처리 지연: 전체 {dt * 1000:.0f}ms, 1건당 {dt / len(c) * 1000:.3f}ms\")\n",
|
|
"\n",
|
|
"# 2) 혼동행렬과 오차 유형\n",
|
|
"cm = pd.crosstab(c[\"grade\"].map(GRADE_NAME), c[\"pred\"].map(GRADE_NAME), rownames=[\"실제\"], colnames=[\"판정\"])\n",
|
|
"cm = cm.reindex(index=[\"안전\", \"주의\", \"위험\"], columns=[\"안전\", \"주의\", \"위험\"], fill_value=0)\n",
|
|
"print(\"\\n[혼동행렬]\")\n",
|
|
"print(cm.to_string())\n",
|
|
"danger = c[\"grade\"] == 2\n",
|
|
"rec = ((c[\"pred\"] == 2) & danger).sum() / danger.sum()\n",
|
|
"far = ((c[\"pred\"] == 2) & ~danger).sum() / max((c[\"pred\"] == 2).sum(), 1)\n",
|
|
"print(f\"위험 재현율 {rec:.1%}, 위험 경보 중 오경보 비율 {far:.1%}\")\n",
|
|
"miss = c[danger & (c[\"pred\"] != 2)]\n",
|
|
"fa = c[~danger & (c[\"pred\"] == 2)]\n",
|
|
"print(\"놓친 위험(사건 유형별):\", miss[\"event\"].value_counts().to_dict())\n",
|
|
"print(\"오경보(센서 고장 유형별):\", fa[\"fault\"].value_counts().to_dict())\n",
|
|
"\n",
|
|
"# 3) 판정 근거(투명성): 위험 판정 사례 일부의 초과 항목\n",
|
|
"print(\"\\n[위험 판정 근거 예시]\")\n",
|
|
"show = c[c[\"pred\"] == 2].groupby(\"source_id\").head(1).head(5)\n",
|
|
"for _, r in show.iterrows():\n",
|
|
" print(f\" {r['source_id']} D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}시: {reasons(r)} -> 실제 {GRADE_NAME[r['grade']]}({r['event']})\")\n",
|
|
"\n",
|
|
"# 4) 휴리스틱 1차 튜닝: 위험 판정을 '2시간 연속 초과'로 바꾸면 오경보는 줄지만 놓침이 늘어난다\n",
|
|
"c[\"pred2\"] = c[\"pred\"]\n",
|
|
"prev = c.groupby(\"source_id\")[\"pred\"].shift(1).fillna(0)\n",
|
|
"c.loc[(c[\"pred\"] == 2) & (prev < 2), \"pred2\"] = 1\n",
|
|
"for name, col in [(\"1시간 즉시 판정\", \"pred\"), (\"2시간 연속 확인\", \"pred2\")]:\n",
|
|
" p = c[col]\n",
|
|
" print(f\"{name}: 정확도 {(p == c['grade']).mean():.1%}, 위험 재현율 {((p == 2) & danger).sum() / danger.sum():.1%}, \"\n",
|
|
" f\"오경보 {int(((p == 2) & ~danger).sum())}건\")\n",
|
|
"print(\"-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\")\n",
|
|
"print(\"※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\")\n",
|
|
"\n",
|
|
"sid = miss[\"source_id\"].mode().iloc[0] if len(miss) else \"W01\"\n",
|
|
"s = c[c[\"source_id\"] == sid]\n",
|
|
"fig, ax = plt.subplots(figsize=(10, 3.5))\n",
|
|
"ax.plot(s[\"t\"], s[\"grade\"], color=\"black\", lw=2, label=\"true grade\")\n",
|
|
"ax.plot(s[\"t\"], s[\"pred\"] + 0.05, color=\"tab:red\", lw=1, label=\"baseline grade\")\n",
|
|
"ax.set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax.set_xlabel(\"hour\"); ax.legend()\n",
|
|
"ax.set_title(f\"{sid} baseline vs true grade (synthetic)\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 벤치마크 2,688건(급수원 8곳 x 336시간)\n",
|
|
"정확도: 전처리 없음 96.8% -> 전처리 + 기준치 필터 97.9% (목표 75% 이상: 달성)\n",
|
|
"처리 지연: 전체 158ms, 1건당 0.059ms\n",
|
|
"\n",
|
|
"[혼동행렬]\n",
|
|
"판정 안전 주의 위험\n",
|
|
"실제 \n",
|
|
"안전 2369 8 17\n",
|
|
"주의 3 45 0\n",
|
|
"위험 9 19 218\n",
|
|
"위험 재현율 88.6%, 위험 경보 중 오경보 비율 7.2%\n",
|
|
"놓친 위험(사건 유형별): {'creeping_metal': 22, 'chemical': 4, 'disinfect_fail': 1, 'metal': 1}\n",
|
|
"오경보(센서 고장 유형별): {'none': 11, 'drift': 6}\n",
|
|
"\n",
|
|
"[위험 판정 근거 예시]\n",
|
|
" W01 D+13 20시: 대장균 1 -> 실제 안전(none)\n",
|
|
" W02 D+06 03시: pH 9.04, 탁도 1.24NTU -> 실제 위험(chemical)\n",
|
|
" W03 D+02 03시: 대장균 1 -> 실제 안전(none)\n",
|
|
" W04 D+12 00시: pH 8.65, 탁도 1.16NTU -> 실제 위험(chemical)\n",
|
|
" W05 D+00 09시: 대장균 1 -> 실제 안전(none)\n",
|
|
"1시간 즉시 판정: 정확도 97.9%, 위험 재현율 88.6%, 오경보 17건\n",
|
|
"2시간 연속 확인: 정확도 97.6%, 위험 재현율 85.0%, 오경보 5건\n",
|
|
"-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\n",
|
|
"※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 04. 복합 오염원 유입 징후 및 급수 안전도 위험 등급 분류 머신러닝 모델 고도화\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"복합 오염원 유입 징후 및 급수 위험 등급 분류 머신러닝 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복합 오염원 유입 징후와 급수 안전도 위험 등급을 분류하는 머신러닝 모델을 고도화하고 방어 로직을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적, 센서 처리 표시 등 29개 특징으로 랜덤 포레스트를 학습하고 Baseline과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/wel03_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": [
|
|
"\"\"\"시계열 특징(변화량·기준선 편차·변동성·대장균 누적·센서 처리 표시)으로 급수 안전도 위험 등급(안전·주의·위험)을 분류하는 랜덤 포레스트 모델을 학습하고, 복합 위협 시나리오 10종(오염 사건 7종, 센서 교란 3종)으로 재현율·오경보·처리 지연을 검증한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"\n",
|
|
"def prepare(df):\n",
|
|
" c = add_features(clean(df))\n",
|
|
" c[\"rule\"] = rule_grade(c)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 학습(가상 2주 x 2세트)과 평가(벤치마크 seed 11)\n",
|
|
"train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n",
|
|
"test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n",
|
|
"model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n",
|
|
"model.fit(train[FEATS], train[\"grade\"])\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"test[\"ml\"] = model.predict(test[FEATS])\n",
|
|
"batch_ms = (time.perf_counter() - t0) * 1000\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"for i in range(20):\n",
|
|
" model.predict(test[FEATS].iloc[[i]])\n",
|
|
"single_ms = (time.perf_counter() - t0) / 20 * 1000\n",
|
|
"\n",
|
|
"d = test[\"grade\"] == 2\n",
|
|
"print(f\"[교육용가상자료] 학습 {len(train):,}건 / 평가 {len(test):,}건, 특징 {len(FEATS)}개\")\n",
|
|
"for name, col in [(\"Baseline 기준치\", \"rule\"), (\"랜덤 포레스트\", \"ml\")]:\n",
|
|
" p = test[col]\n",
|
|
" print(f\"{name}: 정확도 {(p == test['grade']).mean():.1%}, 위험 재현율 {((p == 2) & d).sum() / d.sum():.1%}, \"\n",
|
|
" f\"위험 오경보 {int(((p == 2) & ~d).sum())}건\")\n",
|
|
"rec = ((test[\"ml\"] == 2) & d).sum() / d.sum()\n",
|
|
"print(f\"복합 위협 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), \"\n",
|
|
" f\"처리 지연 1건 {single_ms:.1f}ms / 전체 {batch_ms:.0f}ms (목표 200ms 이하: {'달성' if single_ms <= 200 else '미달'})\")\n",
|
|
"print(\"Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출:\",\n",
|
|
" f\"{int(((test['ml'] == 2) & d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}\"\n",
|
|
" f\"/{int((d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}시간\")\n",
|
|
"\n",
|
|
"# 2) 오경보 유발 패턴: 위험 오경보가 어떤 센서 고장에서 나왔는가 -> 특징 공간 재설계 단서\n",
|
|
"fa = test[(test[\"ml\"] == 2) & ~d]\n",
|
|
"print(\"모델 위험 오경보의 센서 고장 유형:\", fa[\"fault\"].value_counts().to_dict(), \"-> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\")\n",
|
|
"imp = pd.Series(model.feature_importances_, FEATS).sort_values(ascending=False).head(6)\n",
|
|
"print(\"중요 특징 Top6:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.items()))\n",
|
|
"\n",
|
|
"# 3) 복합 위협 시나리오 10종: (급수원, 사건) / 센서 교란은 오염이 없으므로 위험 경보가 나오면 안 된다\n",
|
|
"SCEN = [(\"하수 유입\", [(6, \"sewage\", 30, 14, 1.0)], []), (\"중금속 급증\", [(4, \"metal\", 30, 14, 1.0)], []),\n",
|
|
" (\"소독 실패\", [(0, \"disinfect_fail\", 30, 14, 1.0)], []), (\"화학 오염(pH 급변)\", [(2, \"chemical\", 30, 14, 1.0)], []),\n",
|
|
" (\"중금속 서서히 증가\", [(5, \"creeping_metal\", 20, 24, 1.0)], []),\n",
|
|
" (\"하수+중금속 동시\", [(7, \"sewage\", 30, 14, 1.0), (7, \"metal\", 34, 10, 1.0)], []),\n",
|
|
" (\"약한 하수 유입(강도 60%)\", [(3, \"sewage\", 30, 14, 0.6)], []),\n",
|
|
" (\"교란: 탁도 센서 드리프트\", [], [(1, \"drift\", \"turbidity\", 24, 18)]),\n",
|
|
" (\"교란: 잔류염소 단발 스파이크\", [], [(0, \"spike\", \"chlorine\", 40, 1)]),\n",
|
|
" (\"교란: 통신 단절(전 항목 결측)\", [], [(6, \"dropout\", \"ph\", 30, 10)])]\n",
|
|
"rows = []\n",
|
|
"for name, ev, ft in SCEN:\n",
|
|
" c = prepare(make_dataset(days=3, seed=21, events=ev, faults=ft))\n",
|
|
" c[\"ml\"] = model.predict(c[FEATS])\n",
|
|
" if ev:\n",
|
|
" sid = SOURCES[ev[0][0]][0]; on = ev[0][2]\n",
|
|
" hit = c[(c[\"source_id\"] == sid) & (c[\"ml\"] == 2) & (c[\"t\"] >= on)]\n",
|
|
" lag = int(hit[\"t\"].min() - on) if len(hit) else None\n",
|
|
" ok = lag is not None and lag <= 8\n",
|
|
" rows.append((name, \"오염\", f\"{lag}h\" if lag is not None else \"미탐\", \"PASS\" if ok else \"FAIL\"))\n",
|
|
" else:\n",
|
|
" sid = SOURCES[ft[0][0]][0]\n",
|
|
" n = int(((c[\"source_id\"] == sid) & (c[\"ml\"] == 2)).sum())\n",
|
|
" rows.append((name, \"교란\", f\"위험 오경보 {n}h\", \"PASS\" if n <= 2 else \"FAIL\"))\n",
|
|
"res = pd.DataFrame(rows, columns=[\"시나리오\", \"구분\", \"탐지 지연/오경보\", \"판정\"])\n",
|
|
"print(\"\\n[복합 위협 시나리오 10종]\")\n",
|
|
"print(res.to_string(index=False))\n",
|
|
"n_pass = int((res[\"판정\"] == \"PASS\").sum())\n",
|
|
"print(f\"통과 {n_pass}/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\")\n",
|
|
"\n",
|
|
"plt.figure(figsize=(8, 4))\n",
|
|
"imp.iloc[::-1].plot.barh(color=\"tab:blue\")\n",
|
|
"plt.title(\"Random forest feature importance (top 6, synthetic)\"); plt.xlabel(\"importance\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 학습 5,376건 / 평가 2,688건, 특징 29개\n",
|
|
"Baseline 기준치: 정확도 97.9%, 위험 재현율 88.6%, 위험 오경보 17건\n",
|
|
"랜덤 포레스트: 정확도 99.3%, 위험 재현율 97.2%, 위험 오경보 10건\n",
|
|
"복합 위협 재현율 97.2% (목표 85% 이상: 달성), 처리 지연 1건 20.7ms / 전체 49ms (목표 200ms 이하: 달성)\n",
|
|
"Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출: 17/22시간\n",
|
|
"모델 위험 오경보의 센서 고장 유형: {'drift': 10} -> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\n",
|
|
"중요 특징 Top6: storm 0.17, turbidity 0.15, metal 0.10, turbidity_dev 0.09, ph 0.07, river 0.06\n",
|
|
"\n",
|
|
"[복합 위협 시나리오 10종]\n",
|
|
" 시나리오 구분 탐지 지연/오경보 판정\n",
|
|
" 하수 유입 오염 0h PASS\n",
|
|
" 중금속 급증 오염 0h PASS\n",
|
|
" 소독 실패 오염 3h PASS\n",
|
|
" 화학 오염(pH 급변) 오염 0h PASS\n",
|
|
" 중금속 서서히 증가 오염 2h PASS\n",
|
|
" 하수+중금속 동시 오염 0h PASS\n",
|
|
" 약한 하수 유입(강도 60%) 오염 0h PASS\n",
|
|
" 교란: 탁도 센서 드리프트 교란 위험 오경보 13h FAIL\n",
|
|
" 교란: 잔류염소 단발 스파이크 교란 위험 오경보 0h PASS\n",
|
|
"교란: 통신 단절(전 항목 결측) 교란 위험 오경보 0h PASS\n",
|
|
"통과 9/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 05. 센서 오류에 의한 단수 오경보 방지 및 수질 안전 신뢰도 지수 산출 모듈 개발\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"센서 오류에 의한 단수 오경보 방지 신뢰도 지수 산출 모듈을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 오류로 인한 급수 중단 오경보를 줄이는 수질 안전 신뢰도 지수와 High·Med·Low 등급 모듈을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **위험 경보마다 항목별 편차의 일치도, 경보 지속 시간, 센서 이상 표시, 드리프트 의심 여부를 점수화해 0~100점 신뢰도를 만들고 High·Med·Low로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/verification/wel03_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": [
|
|
"\"\"\"위험 경보마다 다항목 동시 변화·연속 지속·센서 상태·단일 항목 드리프트 여부로 수질 안전 신뢰도 지수(0~100, High/Med/Low)를 산출해, 센서 오류에 의한 급수 중단(단수) 오경보를 줄이고 ROC-AUC·임계값 민감도·산출 일관성을 확인한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"from sklearn.metrics import roc_auc_score, roc_curve\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"SCALE = {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0} # 항목별 '의미 있는 편차' 크기(가정)\n",
|
|
"W = {\"corrob\": 40, \"persist\": 25, \"health\": 20, \"nondrift\": 15} # 신뢰도 가중치 매트릭스(합 100)\n",
|
|
"CUT = {\"High\": 60, \"Med\": 35} # High만 급수 중단 검토 요청으로 올린다\n",
|
|
"\n",
|
|
"\n",
|
|
"def prepare(df):\n",
|
|
" c = add_features(clean(df))\n",
|
|
" c[\"rule\"] = rule_grade(c)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"\n",
|
|
"def confidence(c, alert):\n",
|
|
" \"\"\"경보(alert=True) 행마다 신뢰도 점수와 구성 요소를 계산한다.\"\"\"\n",
|
|
" dev = pd.DataFrame({p: (c[f\"{p}_dev\"].abs() > SCALE[p]) | (c[f\"{p}_d3\"].abs() > SCALE[p]) for p in SCALE})\n",
|
|
" n_dev = dev.sum(axis=1) + (c[\"ecoli_3h\"] >= 2)\n",
|
|
" corrob = np.minimum(n_dev, 3) / 3\n",
|
|
" a = pd.Series(alert.astype(int), index=c.index)\n",
|
|
" run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n",
|
|
" persist = np.minimum(run, 3) / 3\n",
|
|
" flagged = c[\"n_flag\"].groupby(c[\"source_id\"]).transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n",
|
|
" health = 1 - flagged.astype(float)\n",
|
|
" # 드리프트 의심: 한 항목만 24시간 기준선에서 벗어나고, 6시간 변동성이 작으며(매끄러운 변화) 대장균이 없다\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" jitter = {p: g[f\"{p}_d1\"].transform(lambda s: s.rolling(6, min_periods=3).std()).fillna(0) for p in SCALE}\n",
|
|
" smooth = pd.concat([jitter[p] < SCALE[p] * 0.5 for p in SCALE], axis=1).all(axis=1) # 변화량이 일정 = 기울기만 있는 변화\n",
|
|
" nondrift = 1 - ((n_dev <= 1) & smooth & (c[\"ecoli_3h\"] < 2)).astype(float)\n",
|
|
" score = W[\"corrob\"] * corrob + W[\"persist\"] * persist + W[\"health\"] * health + W[\"nondrift\"] * nondrift\n",
|
|
" level = np.where(score >= CUT[\"High\"], \"High\", np.where(score >= CUT[\"Med\"], \"Med\", \"Low\"))\n",
|
|
" return pd.DataFrame({\"score\": score.round(1), \"level\": level, \"n_dev\": n_dev}, index=c.index)\n",
|
|
"\n",
|
|
"\n",
|
|
"train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n",
|
|
"model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n",
|
|
"model.fit(train[FEATS], train[\"grade\"])\n",
|
|
"\n",
|
|
"test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n",
|
|
"alert = ((test[\"rule\"] == 2) | (model.predict(test[FEATS]) == 2)).to_numpy() # Baseline 또는 모델이 위험 판정\n",
|
|
"conf = confidence(test, alert)\n",
|
|
"A = test[alert].join(conf)\n",
|
|
"A[\"real\"] = A[\"grade\"] == 2\n",
|
|
"far_before = (~A[\"real\"]).mean()\n",
|
|
"stop = A[A[\"level\"] == \"High\"]\n",
|
|
"far_after = (~stop[\"real\"]).mean()\n",
|
|
"cut = 1 - far_after / far_before\n",
|
|
"print(f\"[교육용가상자료] 위험 경보 {len(A)}건(실제 위험 {int(A['real'].sum())}건, 오경보 {int((~A['real']).sum())}건)\")\n",
|
|
"print(f\"급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 {far_before:.1%} -> High만 상신 {far_after:.1%} \"\n",
|
|
" f\"({cut:.0%} 감축, 목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n",
|
|
"print(\"\\n[신뢰도 등급별 경보 수와 조치]\")\n",
|
|
"tab = A.groupby(\"level\").agg(경보=(\"real\", \"size\"), 실제위험=(\"real\", \"sum\"), 평균점수=(\"score\", \"mean\"))\n",
|
|
"tab[\"조치\"] = tab.index.map({\"High\": \"급수 중단 검토 요청(HITL)\", \"Med\": \"즉시 재채수·현장 키트 검사\", \"Low\": \"센서 점검·재측정\"})\n",
|
|
"print(tab.reindex([\"High\", \"Med\", \"Low\"]).round(1).to_string())\n",
|
|
"print(\"오경보의 원인(센서 고장 유형) x 등급:\")\n",
|
|
"print(pd.crosstab(A.loc[~A[\"real\"], \"fault\"], A.loc[~A[\"real\"], \"level\"]).to_string())\n",
|
|
"miss_real = int((A[\"real\"] & (A[\"level\"] == \"Low\")).sum())\n",
|
|
"print(f\"실제 위험인데 Low로 낮춰진 경보 {miss_real}건 -> Low도 재측정 대상이므로 무시되지 않는다\")\n",
|
|
"\n",
|
|
"auc = roc_auc_score(A[\"real\"], A[\"score\"])\n",
|
|
"print(f\"\\nROC-AUC(신뢰도 점수로 실제 위험 구분) {auc:.3f}\")\n",
|
|
"print(\"[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\")\n",
|
|
"for th in [45, 55, 60, 70, 80]:\n",
|
|
" s = A[A[\"score\"] >= th]\n",
|
|
" print(f\" {th:>3} / {len(s):>4} / {(~s['real']).mean():6.1%} / {s['real'].sum() / A['real'].sum():6.1%}\")\n",
|
|
"\n",
|
|
"# 산출 일관성: 같은 사건·고장 조건에서 측정 잡음만 바꿔 5회 재산출했을 때 실제 위험 경보의 등급 일치율\n",
|
|
"EV = [(6, \"sewage\", 30, 14, 1.0), (4, \"metal\", 60, 12, 1.0), (2, \"chemical\", 40, 10, 1.0), (5, \"creeping_metal\", 20, 24, 1.0)]\n",
|
|
"FT = [(1, \"drift\", \"turbidity\", 24, 18), (0, \"stuck\", \"chlorine\", 30, 10)]\n",
|
|
"levels = []\n",
|
|
"for s in range(5):\n",
|
|
" c = prepare(make_dataset(days=4, seed=100 + s, events=EV, faults=FT))\n",
|
|
" al = ((c[\"rule\"] == 2) | (model.predict(c[FEATS]) == 2)).to_numpy()\n",
|
|
" levels.append(confidence(c, al)[\"level\"].where(al, \"-\").to_numpy())\n",
|
|
"L = np.array(levels)\n",
|
|
"ref = pd.Series(L[0]).mode().iloc[0]\n",
|
|
"mask = (L != \"-\").sum(axis=0) >= 3\n",
|
|
"agree = np.mean([pd.Series(col[col != \"-\"]).value_counts(normalize=True).iloc[0] for col in L[:, mask].T])\n",
|
|
"print(f\"\\n잡음 재현 5회 등급 일치율 {agree:.1%} (경보 3회 이상 시점 {int(mask.sum())}개, 목표 90% 이상: {'달성' if agree >= 0.9 else '미달'})\")\n",
|
|
"print(\"※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\")\n",
|
|
"\n",
|
|
"fpr, tpr, _ = roc_curve(A[\"real\"], A[\"score\"])\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n",
|
|
"ax[0].plot(fpr, tpr, color=\"tab:blue\"); ax[0].plot([0, 1], [0, 1], \"k--\", lw=0.8)\n",
|
|
"ax[0].set_title(f\"ROC of confidence score (AUC {auc:.2f})\"); ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n",
|
|
"ax[1].hist([A.loc[A[\"real\"], \"score\"], A.loc[~A[\"real\"], \"score\"]], bins=12, label=[\"real danger\", \"false alarm\"], color=[\"tab:red\", \"tab:gray\"])\n",
|
|
"ax[1].axvline(CUT[\"High\"], color=\"k\", ls=\"--\"); ax[1].set_xlabel(\"confidence score\"); ax[1].legend(); ax[1].set_title(\"Score distribution (synthetic)\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 위험 경보 262건(실제 위험 240건, 오경보 22건)\n",
|
|
"급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 8.4% -> High만 상신 0.7% (91% 감축, 목표 40% 이상: 달성)\n",
|
|
"\n",
|
|
"[신뢰도 등급별 경보 수와 조치]\n",
|
|
" 경보 실제위험 평균점수 조치\n",
|
|
"level \n",
|
|
"High 137 136 84.1 급수 중단 검토 요청(HITL)\n",
|
|
"Med 112 100 53.5 즉시 재채수·현장 키트 검사\n",
|
|
"Low 13 4 27.5 센서 점검·재측정\n",
|
|
"오경보의 원인(센서 고장 유형) x 등급:\n",
|
|
"level High Low Med\n",
|
|
"fault \n",
|
|
"drift 1 0 10\n",
|
|
"none 0 9 2\n",
|
|
"실제 위험인데 Low로 낮춰진 경보 4건 -> Low도 재측정 대상이므로 무시되지 않는다\n",
|
|
"\n",
|
|
"ROC-AUC(신뢰도 점수로 실제 위험 구분) 0.821\n",
|
|
"[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\n",
|
|
" 45 / 243 / 4.9% / 96.2%\n",
|
|
" 55 / 210 / 5.2% / 82.9%\n",
|
|
" 60 / 137 / 0.7% / 56.7%\n",
|
|
" 70 / 134 / 0.7% / 55.4%\n",
|
|
" 80 / 88 / 1.1% / 36.2%\n",
|
|
"\n",
|
|
"잡음 재현 5회 등급 일치율 93.6% (경보 3회 이상 시점 99개, 목표 90% 이상: 달성)\n",
|
|
"※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 06. 야전 급수차 및 정수 시설별 수질 상태 실시간 시각화 대시보드 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"야전 급수 시설별 수질 상태 실시간 시각화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수차와 정수 시설별 수질 상태를 실시간처럼 갱신해 보여 주는 상황판 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **가상 급수원 8곳의 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종 컴포넌트를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/ui/wel03_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": [
|
|
"\"\"\"가상 급수원 8곳의 수질 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종을 그리는 상황판을 구현하고, 갱신 주기·응답 시간을 측정한다(야간 모드 지원).\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"DARK = True # 야간 작전 모드(어두운 배경)\n",
|
|
"NOW = 290 # 상황판 기준 시각(모의 시작 후 경과 시간 h)\n",
|
|
"COLOR = {0: \"tab:green\", 1: \"gold\", 2: \"red\"}\n",
|
|
"\n",
|
|
"data = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n",
|
|
"data = data[data[\"t\"] <= NOW]\n",
|
|
"\n",
|
|
"\n",
|
|
"def ratio(row):\n",
|
|
" \"\"\"항목별 '위험 기준 대비 비율'(1 이상이면 위험). 잔류염소·pH는 방향을 맞춰 환산한다.\"\"\"\n",
|
|
" lo, hi = LIMITS[\"ph\"]\n",
|
|
" return {\"pH\": max((row[\"ph\"] - 7.0) / (hi - 7.0), (7.0 - row[\"ph\"]) / (7.0 - lo)),\n",
|
|
" \"turb\": row[\"turbidity\"] / LIMITS[\"turbidity\"][1], \"Cl\": LIMITS[\"chlorine\"][1] / max(row[\"chlorine\"], 0.01),\n",
|
|
" \"metal\": row[\"metal\"] / LIMITS[\"metal\"][1], \"E.coli\": row[\"ecoli\"] / LIMITS[\"ecoli\"][1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 실시간 갱신 모의: 최근 24시간 창을 유지하며 1시간씩 새 측정값이 들어올 때 상태 계산 시간\n",
|
|
"window = data[data[\"t\"] <= NOW - 24].copy()\n",
|
|
"upd = []\n",
|
|
"for h in range(NOW - 23, NOW + 1):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" window = pd.concat([window[window[\"t\"] > h - 48], data[data[\"t\"] == h]])\n",
|
|
" c = clean(window)\n",
|
|
" c[\"g\"] = rule_grade(c)\n",
|
|
" latest = c[c[\"t\"] == h]\n",
|
|
" upd.append((time.perf_counter() - t0) * 1000)\n",
|
|
"print(f\"[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 {np.mean(upd):.1f}ms, 최대 {np.max(upd):.1f}ms \"\n",
|
|
" f\"(갱신 주기 1초 이내: {'달성' if max(upd) < 1000 else '미달'})\")\n",
|
|
"\n",
|
|
"# 2) 현재 상태표\n",
|
|
"state = latest.set_index(\"source_id\")\n",
|
|
"hist = c.groupby(\"source_id\")[\"g\"].apply(lambda s: int((s.tail(6) == 2).sum()))\n",
|
|
"board = pd.DataFrame({\"유형\": state[\"source_type\"], \"상태\": state[\"g\"].map(GRADE_NAME), \"최근6h 위험\": hist,\n",
|
|
" \"근거\": [reasons(r) for _, r in state.iterrows()]})\n",
|
|
"print(f\"\\n[D+{NOW // 24:02d} {NOW % 24:02d}시 급수원 상태판]\")\n",
|
|
"print(board.to_string())\n",
|
|
"alerts = board[(board[\"상태\"] != \"안전\") | (board[\"최근6h 위험\"] > 0)]\n",
|
|
"print(f\"긴급 경보 강조 대상 {len(alerts)}곳: {', '.join(alerts.index)}\")\n",
|
|
"\n",
|
|
"# 3) 상황판 그리기(컴포넌트 5종) + 렌더링 시간\n",
|
|
"plt.style.use(\"dark_background\" if DARK else \"default\")\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"fig = plt.figure(figsize=(13, 7.5))\n",
|
|
"gs = fig.add_gridspec(2, 3)\n",
|
|
"ax1 = fig.add_subplot(gs[0, 0])\n",
|
|
"for sid, r in state.iterrows():\n",
|
|
" ax1.scatter(r[\"x_km\"], r[\"y_km\"], s=260, color=COLOR[r[\"g\"]], edgecolor=\"white\" if r[\"g\"] == 2 else \"none\", lw=2)\n",
|
|
" ax1.annotate(sid, (r[\"x_km\"], r[\"y_km\"]), xytext=(6, 6), textcoords=\"offset points\", fontsize=8)\n",
|
|
"ax1.set_title(\"Water source status map (virtual grid km)\"); ax1.set_xlim(0, 7.5); ax1.set_ylim(0, 7)\n",
|
|
"ax2 = fig.add_subplot(gs[0, 1])\n",
|
|
"R = pd.DataFrame([ratio(r) for _, r in state.iterrows()], index=state.index)\n",
|
|
"im = ax2.imshow(R.clip(0, 2).to_numpy(), cmap=\"RdYlGn_r\", vmin=0, vmax=2, aspect=\"auto\")\n",
|
|
"ax2.set_xticks(range(R.shape[1]), R.columns); ax2.set_yticks(range(len(R)), R.index)\n",
|
|
"ax2.set_title(\"Ratio to danger limit (>=1 danger)\"); fig.colorbar(im, ax=ax2, fraction=0.046)\n",
|
|
"ax3 = fig.add_subplot(gs[0, 2])\n",
|
|
"cnt = state[\"g\"].value_counts().reindex([0, 1, 2], fill_value=0)\n",
|
|
"ax3.bar([\"safe\", \"caution\", \"danger\"], cnt.values, color=[COLOR[i] for i in range(3)])\n",
|
|
"ax3.set_title(f\"Status count at hour {NOW}\")\n",
|
|
"ax4 = fig.add_subplot(gs[1, :2])\n",
|
|
"focus = alerts.index[0] if len(alerts) else state.index[0]\n",
|
|
"s = c[c[\"source_id\"] == focus]\n",
|
|
"for p, col in [(\"turbidity\", \"tab:cyan\"), (\"chlorine\", \"tab:orange\"), (\"metal\", \"violet\")]:\n",
|
|
" ax4.plot(s[\"t\"], s[p] / LIMITS[p][1] if p != \"chlorine\" else LIMITS[p][1] / s[p].clip(lower=0.01), label=p, color=col)\n",
|
|
"ax4.axhline(1, color=\"red\", ls=\"--\", lw=1); ax4.set_ylim(0, 3); ax4.legend(loc=\"upper left\")\n",
|
|
"ax4.set_title(f\"{focus} last 48h ratio to danger limit\"); ax4.set_xlabel(\"hour\")\n",
|
|
"ax5 = fig.add_subplot(gs[1, 2]); ax5.axis(\"off\")\n",
|
|
"lines = [f\"{sid}: {('DANGER' if r['상태'] == '위험' else 'CHECK')} 6h danger {r['최근6h 위험']}\" for sid, r in alerts.iterrows()]\n",
|
|
"ax5.text(0, 1, \"ALERT LIST\\n\" + \"\\n\".join(lines or [\"no alert\"]), va=\"top\", family=\"monospace\", fontsize=10,\n",
|
|
" color=\"red\" if len(alerts) else \"white\" if DARK else \"black\")\n",
|
|
"fig.suptitle(\"Field water quality risk board (synthetic, decision support only)\")\n",
|
|
"fig.tight_layout()\n",
|
|
"fig.canvas.draw()\n",
|
|
"draw_ms = (time.perf_counter() - t0) * 1000\n",
|
|
"\n",
|
|
"# 4) UI 응답성: 급수원 클릭(선택) 시 상세 조회 응답 시간\n",
|
|
"resp = []\n",
|
|
"for sid in state.index:\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" d = c[c[\"source_id\"] == sid].tail(6)\n",
|
|
" _ = (d[[\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]].agg([\"min\", \"max\"]), reasons(d.iloc[-1]))\n",
|
|
" resp.append((time.perf_counter() - t0) * 1000)\n",
|
|
"print(f\"\\n시각화 컴포넌트 5종 렌더링 {draw_ms:.0f}ms, 급수원 상세 조회 응답 평균 {np.mean(resp):.1f}ms \"\n",
|
|
" f\"(UI 응답성 100ms 이내: {'달성' if max(resp) < 100 else '미달'})\")\n",
|
|
"print(\"※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 158.2ms, 최대 205.7ms (갱신 주기 1초 이내: 달성)\n",
|
|
"\n",
|
|
"[D+12 02시 급수원 상태판]\n",
|
|
" 유형 상태 최근6h 위험 근거\n",
|
|
"source_id \n",
|
|
"W01 water_truck 안전 0 -\n",
|
|
"W02 water_truck 안전 0 -\n",
|
|
"W03 purification_unit 안전 0 -\n",
|
|
"W04 purification_unit 위험 3 pH 8.72, 탁도 1.31NTU\n",
|
|
"W05 well 안전 0 -\n",
|
|
"W06 well 안전 0 -\n",
|
|
"W07 river_intake 주의 0 중금속 8.7ug/L\n",
|
|
"W08 river_intake 위험 6 중금속 24.1ug/L\n",
|
|
"긴급 경보 강조 대상 3곳: W04, W07, W08\n",
|
|
"\n",
|
|
"시각화 컴포넌트 5종 렌더링 581ms, 급수원 상세 조회 응답 평균 5.7ms (UI 응답성 100ms 이내: 달성)\n",
|
|
"※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\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",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/hitl/wel03_approval_workflow.py 및 docs/wel03_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": [
|
|
"\"\"\"수질 위험 경보가 나면 시스템은 급수 중단을 자동 확정하지 않고 우선순위 검토 큐에 올리며, 측정 담당 긴급 채수 → 예방의학 담당 검토 → 지휘관 급수 중단 승인·반려(반려 사유 필수) 결재선과 권한 통제, 해시 체인 감사 로그, 큐 대기시간을 시뮬레이션한다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"ROLE_ACTIONS = {\"측정병\": {\"sample\"}, \"예방의학담당\": {\"review\"}, \"지휘관\": {\"approve_stop\", \"reject\"}, \"시스템\": {\"enqueue\"}}\n",
|
|
"USERS = {\"op01\": \"측정병\", \"med01\": \"예방의학담당\", \"cdr01\": \"지휘관\", \"sys\": \"시스템\"}\n",
|
|
"FLOW = {\"enqueue\": (\"-\", \"채수대기\"), \"sample\": (\"채수대기\", \"검토대기\"), \"review\": (\"검토대기\", \"승인대기\"),\n",
|
|
" \"approve_stop\": (\"승인대기\", \"급수중단\"), \"reject\": (\"승인대기\", \"급수유지\")}\n",
|
|
"\n",
|
|
"\n",
|
|
"class AuditLog:\n",
|
|
" def __init__(self):\n",
|
|
" self.rows = []\n",
|
|
"\n",
|
|
" def add(self, **kw):\n",
|
|
" prev = self.rows[-1][\"hash\"] if self.rows else \"0\" * 16\n",
|
|
" body = json.dumps(kw, ensure_ascii=False, sort_keys=True)\n",
|
|
" self.rows.append({**kw, \"prev\": prev, \"hash\": hashlib.sha256((prev + body).encode()).hexdigest()[:16]})\n",
|
|
"\n",
|
|
" def verify(self):\n",
|
|
" prev = \"0\" * 16\n",
|
|
" for r in self.rows:\n",
|
|
" body = json.dumps({k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}, ensure_ascii=False, sort_keys=True)\n",
|
|
" if r[\"prev\"] != prev or hashlib.sha256((prev + body).encode()).hexdigest()[:16] != r[\"hash\"]:\n",
|
|
" return False\n",
|
|
" prev = r[\"hash\"]\n",
|
|
" return True\n",
|
|
"\n",
|
|
"\n",
|
|
"class Workflow:\n",
|
|
" def __init__(self):\n",
|
|
" self.log, self.state, self.n_try = AuditLog(), {}, 0\n",
|
|
"\n",
|
|
" def act(self, user, action, case, t, note=\"\"):\n",
|
|
" \"\"\"권한·순서·사유를 검사한 뒤 상태를 바꾼다. 거부도 감사 로그에 남긴다.\"\"\"\n",
|
|
" self.n_try += 1\n",
|
|
" role = USERS.get(user, \"미등록\")\n",
|
|
" cur = self.state.get(case, \"-\")\n",
|
|
" err = None\n",
|
|
" if action not in ROLE_ACTIONS.get(role, set()):\n",
|
|
" err = f\"권한 없음({role} 역할에 {action} 권한 없음)\"\n",
|
|
" elif FLOW[action][0] != cur:\n",
|
|
" err = f\"순서 위반(현재 {cur})\"\n",
|
|
" elif action in (\"reject\", \"approve_stop\", \"review\") and len(note.strip()) < 5:\n",
|
|
" err = \"사유·근거 입력 필수\"\n",
|
|
" if err is None:\n",
|
|
" self.state[case] = FLOW[action][1]\n",
|
|
" self.log.add(t=t, user=user, role=role, case=case, action=action, result=\"거부\" if err else \"처리\",\n",
|
|
" detail=err or note, state=self.state.get(case, \"-\"))\n",
|
|
" return err is None\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 경보 생성: 가상 벤치마크에서 위험 판정이 2시간 이상 이어진 구간을 '경보 사건'으로 묶는다\n",
|
|
"c = clean(make_dataset(days=14, seed=11, n_events=10, n_faults=14))\n",
|
|
"c[\"g\"] = rule_grade(c)\n",
|
|
"cases = []\n",
|
|
"for sid, s in c.groupby(\"source_id\"):\n",
|
|
" d = (s[\"g\"] == 2).astype(int).to_numpy()\n",
|
|
" seg = np.split(np.arange(len(d)), np.where(np.diff(d) != 0)[0] + 1)\n",
|
|
" for idx in seg:\n",
|
|
" if d[idx[0]] == 1 and len(idx) >= 2:\n",
|
|
" r = s.iloc[idx[0]]\n",
|
|
" cases.append({\"case\": f\"AL-{len(cases) + 1:02d}\", \"source\": sid, \"type\": r[\"source_type\"], \"t\": int(r[\"t\"]),\n",
|
|
" \"hours\": len(idx), \"real\": bool((s.iloc[idx][\"grade\"] == 2).mean() >= 0.5), \"basis\": reasons(s.iloc[idx[-1]])})\n",
|
|
"Q = pd.DataFrame(cases)\n",
|
|
"# 우선순위: 지속시간 + 급수차·정수장(공급 인원 많음) 가중 + 대장균·중금속 포함 가중\n",
|
|
"Q[\"priority\"] = Q[\"hours\"].clip(upper=12) + Q[\"type\"].isin([\"water_truck\", \"purification_unit\"]) * 4 \\\n",
|
|
" + Q[\"basis\"].str.contains(\"대장균|중금속\") * 3\n",
|
|
"Q = Q.sort_values([\"priority\", \"t\"], ascending=[False, True]).reset_index(drop=True)\n",
|
|
"print(f\"[교육용가상자료] 경보 사건 {len(Q)}건이 검토 큐에 등록됨(자동 급수 중단 0건)\")\n",
|
|
"\n",
|
|
"# 2) 결재 시뮬레이션: 채수 담당 30분 내 채수, 예방의학 담당 재검사 결과로 의견, 지휘관이 승인·반려\n",
|
|
"wf = Workflow()\n",
|
|
"rng = np.random.default_rng(5)\n",
|
|
"wait = []\n",
|
|
"for _, q in Q.iterrows():\n",
|
|
" t = q[\"t\"] * 60\n",
|
|
" wf.act(\"sys\", \"enqueue\", q[\"case\"], t, \"기준치 초과 경보 자동 등록\")\n",
|
|
" t += int(rng.integers(10, 30)); wf.act(\"op01\", \"sample\", q[\"case\"], t, \"현장 키트 재채수\")\n",
|
|
" t += int(rng.integers(20, 60))\n",
|
|
" opinion = \"재검사 위험 확인, 급수 중단 권고\" if q[\"real\"] else \"재검사 정상, 센서 점검 권고\"\n",
|
|
" wf.act(\"med01\", \"review\", q[\"case\"], t, opinion)\n",
|
|
" t += int(rng.integers(5, 25))\n",
|
|
" if q[\"real\"]:\n",
|
|
" wf.act(\"cdr01\", \"approve_stop\", q[\"case\"], t, f\"{q['basis']} 확인, 대체 급수원 전환\")\n",
|
|
" else:\n",
|
|
" wf.act(\"cdr01\", \"reject\", q[\"case\"], t, \"재검사 정상, 급수 유지하고 센서 교정\")\n",
|
|
" wait.append(t - q[\"t\"] * 60)\n",
|
|
"st = pd.Series(wf.state).value_counts()\n",
|
|
"print(\"결재 결과:\", st.to_dict(), f\"/ 경보부터 결정까지 평균 {np.mean(wait):.0f}분, 최대 {max(wait)}분\")\n",
|
|
"print(\"\\n[검토 큐 상위 5건]\")\n",
|
|
"print(Q.head(5)[[\"case\", \"source\", \"type\", \"t\", \"hours\", \"priority\", \"basis\"]].to_string(index=False))\n",
|
|
"\n",
|
|
"# 3) 권한 우회·절차 위반 시도 6종 (사이사이 정상 절차를 진행)\n",
|
|
"wf.act(\"sys\", \"enqueue\", \"AL-X\", 99999, \"시험 경보\")\n",
|
|
"STEPS = [(\"위반\", \"op01\", \"approve_stop\", \"측정병이 직접 급수 중단\", \"중단\"), (\"위반\", \"sys\", \"approve_stop\", \"시스템 자동 확정\", \"자동\"),\n",
|
|
" (\"위반\", \"cdr01\", \"approve_stop\", \"채수·검토 없이 지휘관 승인\", \"즉시 중단\"), (\"위반\", \"hacker\", \"review\", \"미등록 사용자 검토\", \"위험 확인됨\"),\n",
|
|
" (\"정상\", \"op01\", \"sample\", \"정상 채수\", \"재채수\"), (\"위반\", \"med01\", \"review\", \"근거 없는 검토 의견\", \"\"),\n",
|
|
" (\"정상\", \"med01\", \"review\", \"정상 검토\", \"재검사 정상 확인\"), (\"위반\", \"cdr01\", \"reject\", \"사유 없는 반려\", \"\")]\n",
|
|
"blocked = n_v = 0\n",
|
|
"print(\"\\n[권한 우회·절차 위반 시도]\")\n",
|
|
"for kind, u, a, desc, note in STEPS:\n",
|
|
" ok = wf.act(u, a, \"AL-X\", 99999, note)\n",
|
|
" if kind == \"위반\":\n",
|
|
" n_v += 1; blocked += not ok\n",
|
|
" print(f\" {desc:16s} -> {'차단' if not ok else '통과(문제)'}: {wf.log.rows[-1]['detail']}\")\n",
|
|
"print(f\"우회·위반 시도 차단 {blocked}/{n_v} ({blocked / n_v:.0%})\")\n",
|
|
"\n",
|
|
"# 4) 감사 로그 무결성과 로깅 성공률\n",
|
|
"n_log = len(wf.log.rows)\n",
|
|
"print(f\"\\n감사 로그 {n_log}건 / 요청 {wf.n_try}건 -> 로깅 성공률 {n_log / wf.n_try:.0%}, 해시 체인 검증: {'정상' if wf.log.verify() else '위변조'}\")\n",
|
|
"wf.log.rows[5][\"detail\"] = \"기록 임의 수정\"\n",
|
|
"print(f\"5번째 기록 임의 수정 후 검증: {'정상' if wf.log.verify() else '위변조 탐지'}\")\n",
|
|
"print(\"※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 경보 사건 10건이 검토 큐에 등록됨(자동 급수 중단 0건)\n",
|
|
"결재 결과: {'급수중단': 9, '급수유지': 1} / 경보부터 결정까지 평균 69분, 최대 82분\n",
|
|
"\n",
|
|
"[검토 큐 상위 5건]\n",
|
|
" case source type t hours priority basis\n",
|
|
"AL-02 W03 purification_unit 243 23 19 잔류염소 0.00mg/L, 대장균 9\n",
|
|
"AL-01 W02 water_truck 147 21 16 pH 8.87, 탁도 1.21NTU\n",
|
|
"AL-03 W04 purification_unit 288 16 16 pH 8.72, 탁도 1.12NTU\n",
|
|
"AL-09 W08 river_intake 69 22 15 중금속 15.0ug/L\n",
|
|
"AL-07 W07 river_intake 115 29 15 탁도 3.11NTU, 잔류염소 0.08mg/L, 대장균 39\n",
|
|
"\n",
|
|
"[권한 우회·절차 위반 시도]\n",
|
|
" 측정병이 직접 급수 중단 -> 차단: 권한 없음(측정병 역할에 approve_stop 권한 없음)\n",
|
|
" 시스템 자동 확정 -> 차단: 권한 없음(시스템 역할에 approve_stop 권한 없음)\n",
|
|
" 채수·검토 없이 지휘관 승인 -> 차단: 순서 위반(현재 채수대기)\n",
|
|
" 미등록 사용자 검토 -> 차단: 권한 없음(미등록 역할에 review 권한 없음)\n",
|
|
" 근거 없는 검토 의견 -> 차단: 사유·근거 입력 필수\n",
|
|
" 사유 없는 반려 -> 차단: 사유·근거 입력 필수\n",
|
|
"우회·위반 시도 차단 6/6 (100%)\n",
|
|
"\n",
|
|
"감사 로그 49건 / 요청 49건 -> 로깅 성공률 100%, 해시 체인 검증: 정상\n",
|
|
"5번째 기록 임의 수정 후 검증: 위변조 탐지\n",
|
|
"※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 08. 오염물질 대량 유입 가상 시나리오 부하 테스트 및 경보 반응 시간 검증\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"오염물질 대량 유입 가상 시나리오 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물질이 대량 유입되는 가상 시나리오에서 파이프라인 부하 시험을 하고 경보 반응 시간과 Fail-safe 동작을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **급수원을 8곳에서 48곳으로 늘려 트래픽을 500% 키우고 30곳에 오염이 동시에 유입되는 상황에서 수집·정제·판정 주기의 처리 시간, 시스템 중단, 데이터 유실, 메모리 증가, 경보 탐지와 반응 시간을 평시와 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `tests/wel03_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": [
|
|
"\"\"\"가상 급수원을 8곳에서 48곳(트래픽 500% 증가)으로 늘리고 30곳에 오염물질이 동시에 유입되는 시나리오로 수집·판정 파이프라인 부하 시험을 수행해, 시스템 중단·데이터 유실·메모리 증가·경보 반응 시간과 과부하 시 기능 축소(Fail-safe) 동작을 검증한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"BUDGET_MS = 300 # 1시간 주기 처리 예산(모의): 직전 주기가 예산을 넘으면 기능 축소 모드로 전환\n",
|
|
"FULL_EVERY = 12 # 기능 축소 중에도 12시간마다 전체 정제(지연 처리)를 수행\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sources(n, seed=0):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" types = [s[1] for s in SOURCES]\n",
|
|
" return [(f\"W{i + 1:02d}\", types[i % len(types)], float(round(rng.uniform(0, 30), 1)), float(round(rng.uniform(0, 30), 1))) for i in range(n)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def lite_check(batch, last):\n",
|
|
" \"\"\"기능 축소 모드: 레코드 단위 범위 점검 -> 직전 정상값 유지 -> 기준치 판정(정제·특징 계산 생략).\"\"\"\n",
|
|
" b = batch.copy()\n",
|
|
" for p in PARAMS:\n",
|
|
" v = pd.to_numeric(b[p], errors=\"coerce\")\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" v = v.where(v.between(lo, hi))\n",
|
|
" b[p] = v.fillna(b[\"source_id\"].map(last.get(p, {}))).fillna(0 if p == \"ecoli\" else np.nan)\n",
|
|
" last.setdefault(p, {}).update(b.dropna(subset=[p]).set_index(\"source_id\")[p].to_dict())\n",
|
|
" b = b.dropna(subset=[\"ph\", \"turbidity\", \"chlorine\", \"metal\"])\n",
|
|
" b[\"g\"] = rule_grade(b)\n",
|
|
" return b\n",
|
|
"\n",
|
|
"\n",
|
|
"def run(n_src, days, events, seed, mode_auto=True, hours=None):\n",
|
|
" src = make_sources(n_src)\n",
|
|
" data = make_dataset(days=days, seed=seed, events=events, faults=[], sources=src)\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" bad_idx = rng.choice(len(data), int(len(data) * 0.01), replace=False) # 1% 손상 레코드 주입(문자·None)\n",
|
|
" data[PARAMS] = data[PARAMS].astype(object)\n",
|
|
" data.loc[bad_idx[: len(bad_idx) // 2], \"turbidity\"] = \"ERR\"\n",
|
|
" data.loc[bad_idx[len(bad_idx) // 2:], \"ph\"] = None\n",
|
|
" stored, quarantine, alerts, ticks, down, last = 0, 0, {}, [], 0, {}\n",
|
|
" buf, mode, switched = [], \"full\", None\n",
|
|
" mem = []\n",
|
|
" for h in range(hours or days * 24):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" try:\n",
|
|
" batch = data[data[\"t\"] == h]\n",
|
|
" if mode_auto and mode == \"full\" and ticks and ticks[-1] > BUDGET_MS:\n",
|
|
" mode, switched = \"lite\", h # 한 번 전환하면 부하 해소 확인 전까지 유지\n",
|
|
" ok_mask = batch[[\"ph\", \"turbidity\", \"chlorine\", \"metal\"]].apply(pd.to_numeric, errors=\"coerce\").notna().all(axis=1)\n",
|
|
" quarantine += int((~ok_mask).sum())\n",
|
|
" if mode == \"full\":\n",
|
|
" buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n",
|
|
" win = pd.concat(buf[-24:])\n",
|
|
" c = clean(win)\n",
|
|
" res = c[c[\"t\"] == h].assign(g=lambda d: rule_grade(d))\n",
|
|
" else:\n",
|
|
" res = lite_check(batch, last)\n",
|
|
" buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n",
|
|
" if h % FULL_EVERY == FULL_EVERY - 1: # 지연 전체 정제(기록 보정용)\n",
|
|
" clean(pd.concat(buf[-FULL_EVERY:]))\n",
|
|
" buf = buf[-24:]\n",
|
|
" stored += len(batch)\n",
|
|
" for sid in res.loc[res[\"g\"] == 2, \"source_id\"]:\n",
|
|
" alerts.setdefault(sid, []).append(h)\n",
|
|
" except Exception:\n",
|
|
" down += 1\n",
|
|
" ticks.append((time.perf_counter() - t0) * 1000)\n",
|
|
" mem.append(sum(b.memory_usage(deep=True).sum() for b in buf) / 1e6) # 보관 버퍼 메모리(MB)\n",
|
|
" last = {p: last.get(p, {}) for p in PARAMS}\n",
|
|
" react = []\n",
|
|
" for e in events:\n",
|
|
" hs = [h for h in alerts.get(src[e[0]][0], []) if h >= e[2]]\n",
|
|
" if hs:\n",
|
|
" react.append(hs[0] - e[2])\n",
|
|
" return {\"src\": n_src, \"recs\": len(data), \"stored\": stored, \"quarantine\": quarantine, \"down\": down, \"mode\": mode if switched is None else f\"lite({switched}h~)\",\n",
|
|
" \"tick_avg\": np.mean(ticks), \"tick_max\": np.max(ticks), \"mem_first\": np.mean(mem[24:48]) if len(mem) >= 48 else mem[-1], \"mem_last\": np.mean(mem[-24:]),\n",
|
|
" \"react\": react, \"detected\": len(react), \"n_events\": len(events), \"ticks\": ticks}\n",
|
|
"\n",
|
|
"\n",
|
|
"days = 2\n",
|
|
"ev8 = [(i, et, 20 + i, 30, 1.0) for i, et in zip(range(5), [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\", \"sewage\"])]\n",
|
|
"ev48 = [(i, [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\"][i % 4], 20 + i % 6, 30, 1.0) for i in range(0, 48, 48 // 30 + 0)][:30]\n",
|
|
"t_all = time.perf_counter()\n",
|
|
"R = {\"평시(8곳)\": run(8, days, ev8, 1), \"부하 500%(48곳, 30곳 동시 오염)\": run(48, days, ev48, 2)}\n",
|
|
"print(f\"[교육용가상자료] 부하 시험 {days}일 모의, 전체 실행 {time.perf_counter() - t_all:.1f}초\")\n",
|
|
"rows = []\n",
|
|
"for k, r in R.items():\n",
|
|
" loss = 1 - r[\"stored\"] / r[\"recs\"]\n",
|
|
" rows.append({\"구분\": k, \"레코드\": r[\"recs\"], \"격리(손상)\": r[\"quarantine\"], \"유실률\": f\"{loss:.1%}\", \"중단\": r[\"down\"],\n",
|
|
" \"모드\": r[\"mode\"], \"주기 평균ms\": round(r[\"tick_avg\"], 1), \"주기 최대ms\": round(r[\"tick_max\"], 1),\n",
|
|
" \"메모리MB(초기->말기)\": f\"{r['mem_first']:.1f}->{r['mem_last']:.1f}\",\n",
|
|
" \"경보 탐지\": f\"{r['detected']}/{r['n_events']}\", \"반응 평균h\": round(float(np.mean(r[\"react\"])), 1)})\n",
|
|
"print(pd.DataFrame(rows).set_index(\"구분\").T.to_string())\n",
|
|
"S = R[\"부하 500%(48곳, 30곳 동시 오염)\"]\n",
|
|
"print(f\"\\n부하 500%: 시스템 중단 {S['down']}건(목표 0건: {'달성' if S['down'] == 0 else '미달'}), \"\n",
|
|
" f\"데이터 유실률 {1 - S['stored'] / S['recs']:.1%}(목표 0%: {'달성' if S['stored'] == S['recs'] else '미달'})\")\n",
|
|
"print(f\"메모리 말기/초기 비율 {S['mem_last'] / max(S['mem_first'], 1e-6):.2f} (버퍼 24시간 고정, 1.2 이하면 누수 없음), \"\n",
|
|
" f\"경보 반응 최대 {max(S['react'])}시간\")\n",
|
|
"\n",
|
|
"# 기능 축소를 끄고 전체 정제를 강행하면 주기 시간이 얼마나 늘어나는지 (6시간만 측정)\n",
|
|
"F = run(48, 1, [], 3, mode_auto=False, hours=6)\n",
|
|
"print(f\"기능 축소 미적용(전체 정제 강행) 주기 평균 {np.mean(F['ticks']):.0f}ms vs 기능 축소 {S['tick_avg']:.0f}ms \"\n",
|
|
" f\"(예산 {BUDGET_MS}ms) -> 병목: 급수원별 정제 반복(clean 함수)\")\n",
|
|
"print(\"Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\")\n",
|
|
"\n",
|
|
"plt.figure(figsize=(9, 3.5))\n",
|
|
"plt.plot(R[\"평시(8곳)\"][\"ticks\"], label=\"normal (8 sources)\")\n",
|
|
"plt.plot(S[\"ticks\"], label=\"500% load (48 sources, degraded mode)\")\n",
|
|
"plt.plot(F[\"ticks\"], label=\"500% load, full mode forced (6 h)\")\n",
|
|
"plt.axhline(BUDGET_MS, color=\"red\", ls=\"--\", lw=1, label=\"budget\")\n",
|
|
"plt.xlabel(\"simulated hour\"); plt.ylabel(\"processing ms per tick\"); plt.legend(); plt.title(\"Stress test tick time (synthetic)\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 부하 시험 2일 모의, 전체 실행 11.9초\n",
|
|
"구분 평시(8곳) 부하 500%(48곳, 30곳 동시 오염)\n",
|
|
"레코드 384 2304\n",
|
|
"격리(손상) 3 23\n",
|
|
"유실률 0.0% 0.0%\n",
|
|
"중단 0 0\n",
|
|
"모드 full lite(1h~)\n",
|
|
"주기 평균ms 127.1 87.7\n",
|
|
"주기 최대ms 183.6 742.7\n",
|
|
"메모리MB(초기->말기) 0.1->0.1 0.6->0.6\n",
|
|
"경보 탐지 5/5 30/30\n",
|
|
"반응 평균h 0.8 0.9\n",
|
|
"\n",
|
|
"부하 500%: 시스템 중단 0건(목표 0건: 달성), 데이터 유실률 0.0%(목표 0%: 달성)\n",
|
|
"메모리 말기/초기 비율 1.00 (버퍼 24시간 고정, 1.2 이하면 누수 없음), 경보 반응 최대 2시간\n",
|
|
"기능 축소 미적용(전체 정제 강행) 주기 평균 808ms vs 기능 축소 88ms (예산 300ms) -> 병목: 급수원별 정제 반복(clean 함수)\n",
|
|
"Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\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",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **튜닝용 가상 자료에서 Baseline 오판을 오경보(센서 드리프트, 단발 대장균), 경계 혼동, 놓침(화학, 중금속 완만 증가) 등 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `docs/wel03_aar_report.md 및 configs/wel03_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(사후 검토)로 기준치 Baseline의 수질 이상 오판 사례를 원인별로 분류하고, 단발 대장균 검출·완만한 중금속 증가에 대한 판단 임계치를 튜닝 세트에서 재조정한 뒤 검증 세트·벤치마크 회귀 테스트로 전후를 비교하고 조정 파라미터를 YAML로 출력한다.\"\"\"\n",
|
|
"import itertools\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"BEFORE = {\"ecoli_3h_min\": 1, \"metal_dev\": 99.0, \"metal_min\": 99.0} # 현행: 대장균 1회 검출 즉시 위험, 추세 규칙 없음\n",
|
|
"\n",
|
|
"\n",
|
|
"def tuned_grade(c, P):\n",
|
|
" \"\"\"재조정 규칙: 대장균은 3시간 누적 기준(또는 탁도·염소 이상 동반 시 즉시), 중금속은 기준선 대비 상승 추세 규칙 추가.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n",
|
|
" if P[\"ecoli_3h_min\"] <= 1: # 현행 규칙: 그 시각 1회 검출이면 위험\n",
|
|
" eco = ec >= 1\n",
|
|
" else:\n",
|
|
" eco = (c[\"ecoli_3h\"].to_numpy() >= P[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n",
|
|
" trend = (c[\"metal_dev\"].to_numpy() > P[\"metal_dev\"]) & (mt > P[\"metal_min\"])\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | eco | trend\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def cause(r, pred):\n",
|
|
" if r[\"grade\"] == 2 and pred != 2:\n",
|
|
" return {\"creeping_metal\": \"놓침-중금속 완만 증가\", \"chemical\": \"놓침-화학(초기 pH 변화)\"}.get(r[\"event\"], f\"놓침-{r['event']}\")\n",
|
|
" if r[\"grade\"] != 2 and pred == 2:\n",
|
|
" if r[\"fault\"] != \"none\":\n",
|
|
" return f\"오경보-센서 {r['fault']}\"\n",
|
|
" return \"오경보-단발 대장균\" if r[\"ecoli\"] >= 1 else \"오경보-기타\"\n",
|
|
" return \"주의/안전 경계 혼동\"\n",
|
|
"\n",
|
|
"\n",
|
|
"D = {name: add_features(clean(make_dataset(seed=s, n_events=10, n_faults=14))) for name, s in\n",
|
|
" [(\"튜닝(seed31)\", 31), (\"검증(seed41)\", 41), (\"벤치마크(seed11)\", 11)]}\n",
|
|
"\n",
|
|
"# 1) AAR: 튜닝 세트의 오판 원인 분류\n",
|
|
"tune = D[\"튜닝(seed31)\"]\n",
|
|
"p0 = tuned_grade(tune, BEFORE)\n",
|
|
"err = tune[p0 != tune[\"grade\"]]\n",
|
|
"causes = pd.Series([cause(r, p) for (_, r), p in zip(err.iterrows(), p0[p0 != tune[\"grade\"]])]).value_counts()\n",
|
|
"print(f\"[교육용가상자료] AAR 대상 오판 {len(err)}건(튜닝 세트 {len(tune):,}건) 원인 분류\")\n",
|
|
"print(causes.to_string())\n",
|
|
"\n",
|
|
"# 2) 재조정: 고칠 수 있는 원인(단발 대장균, 중금속 완만 증가)에 대한 임계치 격자 탐색\n",
|
|
"# AAR 결정: 대장균 단발 1회 검출은 '주의 + 재채수'로 낮추고(3시간 누적 2회 이상부터 위험), 그 기준값만 탐색한다\n",
|
|
"best = None\n",
|
|
"for e3, md, mm in itertools.product([2, 3], [1.0, 1.5, 2.0, 3.0], [4.0, 5.0, 6.0]):\n",
|
|
" P = {\"ecoli_3h_min\": e3, \"metal_dev\": md, \"metal_min\": mm}\n",
|
|
" acc = (tuned_grade(tune, P) == tune[\"grade\"]).mean()\n",
|
|
" if best is None or acc > best[0]:\n",
|
|
" best = (acc, P)\n",
|
|
"AFTER = best[1]\n",
|
|
"print(\"\\n재조정 파라미터:\", AFTER)\n",
|
|
"\n",
|
|
"# 3) 회귀 테스트: 세 데이터셋에서 전후 비교\n",
|
|
"rows = []\n",
|
|
"for name, c in D.items():\n",
|
|
" b, a = tuned_grade(c, BEFORE), tuned_grade(c, AFTER)\n",
|
|
" d = c[\"grade\"] == 2\n",
|
|
" rows.append({\"데이터\": name, \"정확도 전\": f\"{(b == c['grade']).mean():.1%}\", \"정확도 후\": f\"{(a == c['grade']).mean():.1%}\",\n",
|
|
" \"향상(%p)\": round(((a == c[\"grade\"]).mean() - (b == c[\"grade\"]).mean()) * 100, 1),\n",
|
|
" \"오판 전→후\": f\"{int((b != c['grade']).sum())}→{int((a != c['grade']).sum())}\",\n",
|
|
" \"새로 틀림\": int(((b == c[\"grade\"]) & (a != c[\"grade\"])).sum()),\n",
|
|
" \"위험 재현율 후\": f\"{((a == 2) & d).sum() / d.sum():.1%}\"})\n",
|
|
"reg = pd.DataFrame(rows)\n",
|
|
"print(\"\\n[회귀 테스트]\")\n",
|
|
"print(reg.to_string(index=False))\n",
|
|
"gain = reg.loc[1, \"향상(%p)\"]\n",
|
|
"print(f\"검증 세트 종합 정확도 향상 {gain}%p (목표 10%p 이상: {'달성' if gain >= 10 else '미달'} - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\")\n",
|
|
"\n",
|
|
"# 4) 재발 오류: 고친 원인 유형이 검증 세트에서 다시 나오는가\n",
|
|
"val = D[\"검증(seed41)\"]\n",
|
|
"pa = tuned_grade(val, AFTER)\n",
|
|
"ev = val[pa != val[\"grade\"]]\n",
|
|
"vc = pd.Series([cause(r, p) for (_, r), p in zip(ev.iterrows(), pa[pa != val[\"grade\"]])]).value_counts()\n",
|
|
"fixed_types = [\"오경보-단발 대장균\", \"놓침-중금속 완만 증가\"]\n",
|
|
"rec = {k: int(vc.get(k, 0)) for k in fixed_types}\n",
|
|
"print(f\"\\n재발 오류(고친 유형의 검증 세트 재발): {rec} -> 합계 {sum(rec.values())}건(목표 0건: {'달성' if sum(rec.values()) == 0 else '미달'})\")\n",
|
|
"print(\"남은 원인 상위:\", vc.head(3).to_dict(), \"-> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\")\n",
|
|
"\n",
|
|
"print(\"\\n# configs/wel03_tuned_params.yaml\")\n",
|
|
"print(f\"ecoli:\\n danger_3h_count: {AFTER['ecoli_3h_min']} # 단발 1회는 '주의 + 재채수'\\nmetal_trend:\\n\"\n",
|
|
" f\" dev_from_24h_baseline_ugL: {AFTER['metal_dev']}\\n min_level_ugL: {AFTER['metal_min']}\\nreviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\")\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(figsize=(8, 3.5))\n",
|
|
"x = np.arange(len(reg))\n",
|
|
"ax.bar(x - 0.2, [(tuned_grade(c, BEFORE) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"before\", color=\"tab:gray\")\n",
|
|
"ax.bar(x + 0.2, [(tuned_grade(c, AFTER) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"after\", color=\"tab:blue\")\n",
|
|
"ax.set_xticks(x, [\"tune(31)\", \"valid(41)\", \"bench(11)\"]); ax.set_ylabel(\"misjudged hours\"); ax.legend()\n",
|
|
"ax.set_title(\"AAR retuning: misjudgments before/after (synthetic)\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] AAR 대상 오판 110건(튜닝 세트 2,688건) 원인 분류\n",
|
|
"오경보-센서 drift 38\n",
|
|
"주의/안전 경계 혼동 28\n",
|
|
"놓침-화학(초기 pH 변화) 20\n",
|
|
"놓침-중금속 완만 증가 16\n",
|
|
"오경보-단발 대장균 7\n",
|
|
"놓침-metal 1\n",
|
|
"\n",
|
|
"재조정 파라미터: {'ecoli_3h_min': 2, 'metal_dev': 1.0, 'metal_min': 5.0}\n",
|
|
"\n",
|
|
"[회귀 테스트]\n",
|
|
" 데이터 정확도 전 정확도 후 향상(%p) 오판 전→후 새로 틀림 위험 재현율 후\n",
|
|
" 튜닝(seed31) 95.9% 96.2% 0.3 110→102 8 91.8%\n",
|
|
" 검증(seed41) 97.1% 97.2% 0.1 78→75 6 92.2%\n",
|
|
"벤치마크(seed11) 97.9% 98.4% 0.5 56→43 6 96.3%\n",
|
|
"검증 세트 종합 정확도 향상 0.1%p (목표 10%p 이상: 미달 - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\n",
|
|
"\n",
|
|
"재발 오류(고친 유형의 검증 세트 재발): {'오경보-단발 대장균': 0, '놓침-중금속 완만 증가': 1} -> 합계 1건(목표 0건: 미달)\n",
|
|
"남은 원인 상위: {'주의/안전 경계 혼동': 21, '오경보-센서 dropout': 18, '놓침-화학(초기 pH 변화)': 15} -> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\n",
|
|
"\n",
|
|
"# configs/wel03_tuned_params.yaml\n",
|
|
"ecoli:\n",
|
|
" danger_3h_count: 2 # 단발 1회는 '주의 + 재채수'\n",
|
|
"metal_trend:\n",
|
|
" dev_from_24h_baseline_ugL: 1.0\n",
|
|
" min_level_ugL: 5.0\n",
|
|
"reviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 10. 가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지 완성\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 수질검사 데이터 위험 신호 대시보드를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 전처리, 재조정 기준치 판정, 랜덤 포레스트 위험 분류, 신뢰도 등급, 사람 검토 큐 등록, 상황판 요약을 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/main_wel03.py, README.md, docs/wel03_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": [
|
|
"\"\"\"입력 검증, 전처리, 재조정 기준치 판정과 랜덤 포레스트 위험 분류, 신뢰도 등급, 인간 검토 큐 등록, 상황판 요약까지 가상 수질검사 위험 신호 대시보드를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·자체 점검(함수 커버리지)·지휘관 브리핑 요약을 출력한다.\"\"\"\n",
|
|
"import sys\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n",
|
|
"# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n",
|
|
"# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n",
|
|
"SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n",
|
|
" (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n",
|
|
" (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n",
|
|
"BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n",
|
|
" \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n",
|
|
"LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n",
|
|
"EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n",
|
|
" \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n",
|
|
" \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n",
|
|
" \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n",
|
|
" \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n",
|
|
" \"creeping_metal\": ({\"metal\": 13.0}, 18),\n",
|
|
"}\n",
|
|
"FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n",
|
|
"NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n",
|
|
"DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n",
|
|
" \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" sources = sources or SOURCES\n",
|
|
" T = days * 24\n",
|
|
" tt = np.arange(T)\n",
|
|
" hour = tt % 24\n",
|
|
" night = (hour < 6) | (hour >= 20)\n",
|
|
" rain = np.zeros(T)\n",
|
|
" for _ in range(max(1, days // 4)):\n",
|
|
" s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n",
|
|
" rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n",
|
|
" if events is None:\n",
|
|
" events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n",
|
|
" int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n",
|
|
" if faults is None:\n",
|
|
" faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n",
|
|
" int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n",
|
|
" out = []\n",
|
|
" for si, (sid, stype, x, y) in enumerate(sources):\n",
|
|
" ph0, tb0, cl0, mt0 = BASE[stype]\n",
|
|
" tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n",
|
|
" \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n",
|
|
" \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n",
|
|
" \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n",
|
|
" if stype in (\"river_intake\", \"well\"):\n",
|
|
" tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n",
|
|
" f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n",
|
|
" for (s_i, et, onset, dur, scale) in events:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" delta, ramp = EVENT_TYPES[et]\n",
|
|
" f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n",
|
|
" for k, v in delta.items():\n",
|
|
" tr[k] = tr[k] + v * f * scale\n",
|
|
" etype[f > 0] = et\n",
|
|
" f_all = np.maximum(f_all, f)\n",
|
|
" tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n",
|
|
" tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n",
|
|
" tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n",
|
|
" grade = np.where(f_all >= 0.2, 2, 0)\n",
|
|
" mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n",
|
|
" grade = np.where((grade == 0) & mild, 1, grade)\n",
|
|
" ms = {}\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n",
|
|
" ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n",
|
|
" ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n",
|
|
" fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n",
|
|
" for (s_i, ft, p, s0, d) in faults:\n",
|
|
" if s_i != si:\n",
|
|
" continue\n",
|
|
" seg = slice(s0, min(T, s0 + d))\n",
|
|
" if ft == \"stuck\":\n",
|
|
" ms[p][seg] = ms[p][s0]\n",
|
|
" elif ft == \"spike\":\n",
|
|
" seg = slice(s0, s0 + 1)\n",
|
|
" ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n",
|
|
" elif ft == \"drift\":\n",
|
|
" ramp = np.linspace(0, 1, seg.stop - s0)\n",
|
|
" ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n",
|
|
" elif ft == \"dropout\":\n",
|
|
" for q in PARAMS:\n",
|
|
" ms[q][seg] = np.nan\n",
|
|
" p = \"all\"\n",
|
|
" fault[seg], fparam[seg] = ft, p\n",
|
|
" df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n",
|
|
" \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n",
|
|
" **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n",
|
|
" \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n",
|
|
" out.append(df)\n",
|
|
" return pd.concat(out, ignore_index=True)\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n",
|
|
"PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n",
|
|
"SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n",
|
|
"MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n",
|
|
"STUCK_RUN, SHORT_GAP = 6, 3\n",
|
|
"\n",
|
|
"\n",
|
|
"def clean(df, spike_k=None, min_jump=None):\n",
|
|
" \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n",
|
|
" spike_k = spike_k or SPIKE_K\n",
|
|
" min_jump = MIN_JUMP if min_jump is None else min_jump\n",
|
|
" out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" for sid, idx in out.groupby(\"source_id\").groups.items():\n",
|
|
" idx = np.asarray(idx)\n",
|
|
" kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n",
|
|
" for p in PARAMS:\n",
|
|
" v = out.loc[idx, p].to_numpy(float).copy()\n",
|
|
" fl = np.array([\"\"] * len(v), dtype=object)\n",
|
|
" lo, hi = PHYS[p]\n",
|
|
" bad = (v < lo) | (v > hi)\n",
|
|
" v[bad], fl[bad] = np.nan, \"range\"\n",
|
|
" if p != \"ecoli\":\n",
|
|
" chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n",
|
|
" rid = np.cumsum(chg)\n",
|
|
" rlen = np.bincount(rid)[rid]\n",
|
|
" fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n",
|
|
" d = np.abs(np.diff(v[~np.isnan(v)]))\n",
|
|
" sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n",
|
|
" up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n",
|
|
" thr = np.maximum(kk * sig, min_jump[p])\n",
|
|
" spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n",
|
|
" v[spike], fl[spike] = np.nan, \"spike\"\n",
|
|
" s = pd.Series(v)\n",
|
|
" na = s.isna().to_numpy()\n",
|
|
" grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n",
|
|
" glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n",
|
|
" lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n",
|
|
" short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n",
|
|
" v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n",
|
|
" fl[short & (fl == \"\")] = \"interp\"\n",
|
|
" fl[longg] = \"long_gap\"\n",
|
|
" out.loc[idx, p] = v\n",
|
|
" out.loc[idx, f\"{p}_flag\"] = fl\n",
|
|
" return out\n",
|
|
"\n",
|
|
"# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n",
|
|
"GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def rule_grade(df):\n",
|
|
" \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n",
|
|
" ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def reasons(row):\n",
|
|
" r = []\n",
|
|
" if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n",
|
|
" r.append(f\"pH {row['ph']:.2f}\")\n",
|
|
" if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n",
|
|
" r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n",
|
|
" if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n",
|
|
" r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n",
|
|
" if row[\"metal\"] > LIMITS[\"metal\"][0]:\n",
|
|
" r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n",
|
|
" if row[\"ecoli\"] >= 1:\n",
|
|
" r.append(f\"대장균 {row['ecoli']:.0f}\")\n",
|
|
" return \", \".join(r) or \"-\"\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n",
|
|
" [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def add_features(c):\n",
|
|
" \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n",
|
|
" c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n",
|
|
" c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n",
|
|
" c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n",
|
|
" base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n",
|
|
" c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n",
|
|
" c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n",
|
|
" c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n",
|
|
" c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n",
|
|
" flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n",
|
|
" c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n",
|
|
" for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n",
|
|
" c[k] = (c[\"source_type\"] == name).astype(int)\n",
|
|
" return c\n",
|
|
"\n",
|
|
"CONFIG = {\"ecoli_3h_min\": 2, \"metal_dev\": 1.0, \"metal_min\": 5.0, # 미션 9 재조정 값\n",
|
|
" \"conf_high\": 60, \"scale\": {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0}}\n",
|
|
"README = {\"1. 목적\": \"가상 수질 센서 자료로 급수원 위험 신호를 조기에 알리는 판단 보조 도구\",\n",
|
|
" \"2. 실행 방법\": \"python main_wel03.py (원클릭 실행, 외부 파일·네트워크 불필요)\",\n",
|
|
" \"3. 입력 형식\": \"급수원 ID·유형·시각·pH·탁도·잔류염소·중금속·대장균(미션 1 스키마)\",\n",
|
|
" \"4. 판정 규칙\": \"가정 기준치 + 재조정 규칙 + 모델, 신뢰도 High만 급수 중단 검토 상신\",\n",
|
|
" \"5. 인간 검토\": \"급수 중단·재개는 예방의학 담당 검토 후 지휘관 승인, 자동 확정 없음\",\n",
|
|
" \"6. 한계와 주의\": \"교육용 가상 자료, 실제 판정은 공식 수질 기준과 의무·환경 담당 확인\",\n",
|
|
" \"7. 연동 확장\": \"인접 부대 급수 정보 교환용 표준 필드(JSON) API를 차기 과제로 둔다\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def validate(df):\n",
|
|
" ok = df[\"source_id\"].astype(str).str.match(r\"^W\\d{2}$\") & df[\"t\"].ge(0)\n",
|
|
" return df[ok].copy(), int((~ok).sum())\n",
|
|
"\n",
|
|
"\n",
|
|
"def tuned_grade(c):\n",
|
|
" ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n",
|
|
" eco = (c[\"ecoli_3h\"].to_numpy() >= CONFIG[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n",
|
|
" trend = (c[\"metal_dev\"].to_numpy() > CONFIG[\"metal_dev\"]) & (mt > CONFIG[\"metal_min\"])\n",
|
|
" danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n",
|
|
" | (mt > LIMITS[\"metal\"][1]) | eco | trend\n",
|
|
" caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n",
|
|
" return np.where(danger, 2, np.where(caution, 1, 0))\n",
|
|
"\n",
|
|
"\n",
|
|
"def confidence(c, alert):\n",
|
|
" S = CONFIG[\"scale\"]\n",
|
|
" n_dev = sum(((c[f\"{p}_dev\"].abs() > S[p]) | (c[f\"{p}_d3\"].abs() > S[p])).astype(int) for p in S) + (c[\"ecoli_3h\"] >= 2)\n",
|
|
" a = pd.Series(alert.astype(int), index=c.index)\n",
|
|
" run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n",
|
|
" g = c.groupby(\"source_id\")\n",
|
|
" flagged = g[\"n_flag\"].transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n",
|
|
" # 드리프트 의심: 한 항목만 6시간 중 5시간 이상 같은 방향으로 꾸준히 변함(잡음이 커지는 야간에도 유지되는 기준)\n",
|
|
" mono = pd.concat([g[f\"{p}_d1\"].transform(lambda s: np.sign(s).rolling(6, min_periods=3).sum()).abs() >= 4 for p in S], axis=1).any(axis=1)\n",
|
|
" drift = (n_dev <= 1) & mono & (c[\"ecoli_3h\"] < 2)\n",
|
|
" return 40 * np.minimum(n_dev, 3) / 3 + 25 * np.minimum(run, 3) / 3 + 20 * (~flagged) + 15 * (~drift)\n",
|
|
"\n",
|
|
"\n",
|
|
"def train_model():\n",
|
|
" tr = pd.concat([add_features(clean(make_dataset(seed=s, n_events=14, n_faults=14))) for s in (7, 8)], ignore_index=True)\n",
|
|
" return RandomForestClassifier(n_estimators=150, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"grade\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def run_pipeline(raw, model):\n",
|
|
" data, n_bad = validate(raw)\n",
|
|
" c = add_features(clean(data))\n",
|
|
" c[\"rule\"] = tuned_grade(c)\n",
|
|
" c[\"ml\"] = model.predict(c[FEATS])\n",
|
|
" c[\"final\"] = np.maximum(c[\"rule\"], c[\"ml\"]) # 둘 중 하나라도 위험이면 위험(놓침 방지)\n",
|
|
" alert = (c[\"final\"] == 2).to_numpy()\n",
|
|
" c[\"conf\"] = np.where(alert, confidence(c, alert), np.nan)\n",
|
|
" queue = c[alert & (c[\"conf\"] >= CONFIG[\"conf_high\"])].groupby(\"source_id\").agg(first=(\"t\", \"min\"), hours=(\"t\", \"size\"))\n",
|
|
" queue[\"status\"] = \"PENDING(지휘관 결정 대기)\"\n",
|
|
" return c, queue, n_bad\n",
|
|
"\n",
|
|
"\n",
|
|
"def self_tests(model):\n",
|
|
" res = {}\n",
|
|
" clean_df = make_dataset(days=2, seed=50, events=[], faults=[])\n",
|
|
" c, q, _ = run_pipeline(clean_df, model)\n",
|
|
" res[\"오염 없는 자료에서 급수 중단 상신 0건\"] = len(q) == 0\n",
|
|
" ev = make_dataset(days=2, seed=50, events=[(0, \"sewage\", 20, 12, 1.0)], faults=[])\n",
|
|
" c, q, _ = run_pipeline(ev, model)\n",
|
|
" res[\"하수 유입 급수원(W01) 상신\"] = \"W01\" in q.index\n",
|
|
" res[\"상신 건은 모두 PENDING(자동 확정 없음)\"] = q[\"status\"].str.startswith(\"PENDING\").all()\n",
|
|
" bad = ev.copy(); bad.loc[3, \"source_id\"] = \"??\"\n",
|
|
" res[\"손상 레코드 격리\"] = run_pipeline(bad, model)[2] == 1\n",
|
|
" dr = make_dataset(days=2, seed=50, events=[], faults=[(1, \"drift\", \"turbidity\", 10, 18)])\n",
|
|
" c, q, _ = run_pipeline(dr, model)\n",
|
|
" n_alert = int((c.loc[c[\"source_id\"] == \"W02\", \"final\"] == 2).sum()) # 드리프트 구간 위험 판정 시간 중 High 상신 시간이 절반 미만이면 억제 작동\n",
|
|
" res[\"센서 드리프트 상신 시간 절반 미만으로 억제\"] = (\"W02\" not in q.index) or q.loc[\"W02\", \"hours\"] < 0.5 * n_alert\n",
|
|
" res[\"기준치 필터·판정 근거 문구 생성\"] = len(reasons(c[c[\"source_id\"] == \"W02\"].iloc[40])) > 0 and rule_grade(c).shape[0] == len(c)\n",
|
|
" res[\"결측 보정 후 남은 결측 0\"] = int(c[PARAMS].isna().sum().sum()) == 0\n",
|
|
" res[\"운영 문서 7개 장 작성\"] = all(len(v) > 10 for v in README.values())\n",
|
|
" return res\n",
|
|
"\n",
|
|
"\n",
|
|
"# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n",
|
|
"t_all = time.perf_counter()\n",
|
|
"called = set()\n",
|
|
"own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n",
|
|
"sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n",
|
|
"model = train_model()\n",
|
|
"tests = self_tests(model)\n",
|
|
"e2e = []\n",
|
|
"for s in (11, 21, 31, 41, 51):\n",
|
|
" try:\n",
|
|
" c, q, nb = run_pipeline(make_dataset(seed=s, n_events=10, n_faults=14), model)\n",
|
|
" e2e.append((s, True, (c[\"final\"] == c[\"grade\"]).mean(), len(q)))\n",
|
|
" except Exception as e:\n",
|
|
" e2e.append((s, False, np.nan, 0))\n",
|
|
"sys.setprofile(None)\n",
|
|
"cov = len(own & called) / len(own)\n",
|
|
"print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n",
|
|
"print(\"[End-to-End 시나리오 5종]\")\n",
|
|
"for s, ok, acc, nq in e2e:\n",
|
|
" print(f\" seed {s}: {'성공' if ok else '실패'}, 최종 판정 정확도 {acc:.1%}, 급수 중단 검토 상신 {nq}곳\")\n",
|
|
"rate = np.mean([x[1] for x in e2e])\n",
|
|
"print(f\"E2E 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n",
|
|
"print(\"[자체 점검]\")\n",
|
|
"for k, v in tests.items():\n",
|
|
" print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n",
|
|
"print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n",
|
|
"print(f\"운영 문서(README) {len(README)}개 장: \" + \", \".join(README))\n",
|
|
"\n",
|
|
"# ---- 브리핑 요약(벤치마크 seed 11, 운용 시점 기준) ----\n",
|
|
"c, q, _ = run_pipeline(make_dataset(seed=11, n_events=10, n_faults=14), model)\n",
|
|
"d = c[\"grade\"] == 2\n",
|
|
"print(\"\\n[지휘관 브리핑 요약]\")\n",
|
|
"print(f\"1. 급수원 8곳 14일 모의: 위험 재현율 {((c['final'] == 2) & d).sum() / d.sum():.1%}, 종합 정확도 {(c['final'] == c['grade']).mean():.1%}\")\n",
|
|
"print(f\"2. 급수 중단 검토 상신 {len(q)}곳({', '.join(q.index)}): 모두 예방의학 담당 검토·지휘관 결정 대기\")\n",
|
|
"print(\"3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\")\n",
|
|
"print(\"4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\")\n",
|
|
"\n",
|
|
"last = c[c[\"t\"] == c[\"t\"].max()].set_index(\"source_id\")\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
|
"ax[0].bar(last.index, last[\"final\"], color=[{0: \"tab:green\", 1: \"gold\", 2: \"red\"}[g] for g in last[\"final\"]])\n",
|
|
"ax[0].set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax[0].set_title(\"Final status by source (last hour)\")\n",
|
|
"ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color=\"tab:blue\"); ax[1].set_ylim(0.9, 1.0)\n",
|
|
"ax[1].set_title(\"E2E accuracy by scenario seed\"); ax[1].set_xlabel(\"seed\")\n",
|
|
"plt.suptitle(\"Integrated water risk dashboard package (synthetic)\"); plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[교육용가상자료] 통합 실행 6.6초 (모델 학습 포함)\n",
|
|
"[End-to-End 시나리오 5종]\n",
|
|
" seed 11: 성공, 최종 판정 정확도 98.6%, 급수 중단 검토 상신 6곳\n",
|
|
" seed 21: 성공, 최종 판정 정확도 99.1%, 급수 중단 검토 상신 6곳\n",
|
|
" seed 31: 성공, 최종 판정 정확도 97.0%, 급수 중단 검토 상신 6곳\n",
|
|
" seed 41: 성공, 최종 판정 정확도 97.7%, 급수 중단 검토 상신 7곳\n",
|
|
" seed 51: 성공, 최종 판정 정확도 98.0%, 급수 중단 검토 상신 7곳\n",
|
|
"E2E 성공률 100% (목표 100%: 달성)\n",
|
|
"[자체 점검]\n",
|
|
" [PASS] 오염 없는 자료에서 급수 중단 상신 0건\n",
|
|
" [PASS] 하수 유입 급수원(W01) 상신\n",
|
|
" [PASS] 상신 건은 모두 PENDING(자동 확정 없음)\n",
|
|
" [PASS] 손상 레코드 격리\n",
|
|
" [PASS] 센서 드리프트 상신 시간 절반 미만으로 억제\n",
|
|
" [PASS] 기준치 필터·판정 근거 문구 생성\n",
|
|
" [PASS] 결측 보정 후 남은 결측 0\n",
|
|
" [PASS] 운영 문서 7개 장 작성\n",
|
|
"자체 점검 8/8 통과, 함수 커버리지 100%(11/11, 목표 85% 이상: 달성)\n",
|
|
"운영 문서(README) 7개 장: 1. 목적, 2. 실행 방법, 3. 입력 형식, 4. 판정 규칙, 5. 인간 검토, 6. 한계와 주의, 7. 연동 확장\n",
|
|
"\n",
|
|
"[지휘관 브리핑 요약]\n",
|
|
"1. 급수원 8곳 14일 모의: 위험 재현율 98.4%, 종합 정확도 98.6%\n",
|
|
"2. 급수 중단 검토 상신 6곳(W02, W03, W04, W05, W07, W08): 모두 예방의학 담당 검토·지휘관 결정 대기\n",
|
|
"3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\n",
|
|
"4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"colab": {
|
|
"provenance": []
|
|
},
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 0
|
|
} |