# WEL01 정답 코드 - 더미 생체신호 기반 열·한랭 스트레스 경보 프로토타입 > [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다. ## 실행 환경 - Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib - 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다. - 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다. - 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다. ## 미션 1. 전투원 착용 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터 규격 정의 - 난이도: 초급 / 예상 소요시간: 1.5시간 - 코드 설명: 전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 스키마를 정의하고, 가상 샘플 20건을 검증한다. 진단 목적이 아닌 교육용 가상 자료이다. ```python """전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 스키마를 정의하고, 가상 샘플 20건을 검증한다. 진단 목적이 아닌 교육용 가상 자료이다.""" import json import re import numpy as np import pandas as pd # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # 1) 스키마 정의: 신호 규격, 가상 병사 특성, 기상(온·습도·풍속), 기기 상태 SCHEMA = { "required": ["record_id", "soldier_id", "n_samples", "sampling_s", "device", "person", "weather", "samples"], "fields": { "record_id": {"type": str, "pattern": r"^REC-\d{4}$"}, "soldier_id": {"type": str, "pattern": r"^S\d{3}$"}, "n_samples": {"type": int, "min": 1, "max": 100000}, "sampling_s": {"type": (int, float), "min": 1, "max": 600}, }, "device": { "type": {"type": str, "enum": ["wrist", "chest"]}, "battery_pct": {"type": (int, float), "min": 0, "max": 100}, "contact_quality": {"type": (int, float), "min": 0, "max": 1}, }, "person": { "rest_hr": {"type": (int, float), "min": 40, "max": 100}, "age": {"type": int, "min": 18, "max": 45}, "bmi": {"type": (int, float), "min": 15, "max": 40}, "acclim": {"type": int, "min": 0, "max": 1}, }, "weather": { "temp_c": {"type": (int, float), "min": -40, "max": 55}, "rh": {"type": (int, float), "min": 0, "max": 100}, "wind": {"type": (int, float), "min": 0, "max": 30}, }, "sample": { "hr": {"type": (int, float), "min": 30, "max": 220}, "temp": {"type": (int, float), "min": 30, "max": 43}, "eda": {"type": (int, float), "min": 0, "max": 40}, }, } def check(name, value, rule): """값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.""" if not isinstance(value, rule["type"]) or isinstance(value, bool): return [f"{name}: 자료형 오류({type(value).__name__})"] errs = [] if "enum" in rule and value not in rule["enum"]: errs.append(f"{name}: 허용되지 않은 값 '{value}'") if "min" in rule and value < rule["min"]: errs.append(f"{name}: 최소값 {rule['min']} 미만") if "max" in rule and value > rule["max"]: errs.append(f"{name}: 최대값 {rule['max']} 초과") if "pattern" in rule and not re.match(rule["pattern"], value): errs.append(f"{name}: 형식 불일치 '{value}'") return errs def validate(rec): errs = [f"{k}: 필수 필드 누락" for k in SCHEMA["required"] if k not in rec] for k, rule in SCHEMA["fields"].items(): if k in rec: errs += check(k, rec[k], rule) for group in ("device", "person", "weather"): for k, rule in SCHEMA[group].items(): if k not in rec.get(group, {}): errs.append(f"{group}.{k}: 필수 필드 누락") else: errs += check(f"{group}.{k}", rec[group][k], rule) for i, smp in enumerate(rec.get("samples", [])): for k, rule in SCHEMA["sample"].items(): if k not in smp: errs.append(f"samples[{i}].{k}: 필수 필드 누락") else: errs += check(f"samples[{i}].{k}", smp[k], rule) if "n_samples" in rec and rec["n_samples"] != len(rec.get("samples", [])): errs.append("n_samples: 샘플 개수와 불일치") return errs # 2) [교육용가상자료] 가상 병사 20명의 더미 세션에서 샘플 레코드 생성(센서 오류 없는 정상 데이터) rng = np.random.default_rng(1) records = [] for i in range(20): s = make_session(i, TYPES[i % 3], rng, glitch=False) smp = [{"hr": round(float(r.hr), 1), "temp": round(float(r.temp), 2), "eda": round(float(r.eda), 2)} for r in s["df"].itertuples()] p, e = s["person"], s["env"] records.append({"record_id": f"REC-{i:04d}", "soldier_id": s["id"], "n_samples": len(smp), "sampling_s": 60, "device": {"type": str(rng.choice(["wrist", "chest"])), "battery_pct": int(rng.integers(30, 100)), "contact_quality": round(float(rng.uniform(0.7, 1)), 2)}, "person": {"rest_hr": round(p["rest_hr"], 1), "age": p["age"], "bmi": round(p["bmi"], 1), "acclim": p["acclim"]}, "weather": {k: round(v, 1) for k, v in e.items()}, "samples": smp}) # 3) 정상 데이터 검증 ok = sum(1 for r in records if not validate(r)) print(f"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}") # 4) 오류 주입 시험 bad = json.loads(json.dumps(records[0])) bad["soldier_id"] = "SOLDIER-1" # 형식 bad["samples"][0]["temp"] = 45.0 # 범위(센서 튐) del bad["weather"]["wind"] # 누락(풍속) bad["device"]["type"] = "ankle" # 허용값 bad["n_samples"] = 99 # 논리(개수 불일치) errs = validate(bad) print(f"\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건") for e in errs: print(" -", e) print(f"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}") # 5) 스키마 필드 정의서(명세서 초안) rows = [("기본", k, r["type"].__name__ if isinstance(r["type"], type) else "number", str({x: y for x, y in r.items() if x != "type"})) for k, r in SCHEMA["fields"].items()] for g in ("device", "person", "weather", "sample"): for k, r in SCHEMA[g].items(): t = r["type"].__name__ if isinstance(r["type"], type) else "number" rows.append((g, k, t, str({x: y for x, y in r.items() if x != "type"}))) print("\n[데이터 명세 요약] 필드 수", len(rows)) print(pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"]).to_string(index=False)) print("\n상태 단계(교육용 가상 라벨):", STAGE, "/ 위험 유형:", TYPES) print("본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.") ``` 실행 결과 예시 ```text 정상 샘플 20건 중 검증 통과 20건, 오류 0건 -> 목표(검증 오류 0건): 달성 [오류 주입 결과] 주입 5종 -> 검출 5건 - soldier_id: 형식 불일치 'SOLDIER-1' - device.type: 허용되지 않은 값 'ankle' - weather.wind: 필수 필드 누락 - samples[0].temp: 최대값 43 초과 - n_samples: 샘플 개수와 불일치 주입 오류 전부 검출: 예 [데이터 명세 요약] 필드 수 17 그룹 필드 자료형 규칙 기본 record_id str {'pattern': '^REC-\\d{4}$'} 기본 soldier_id str {'pattern': '^S\\d{3}$'} 기본 n_samples int {'min': 1, 'max': 100000} 기본 sampling_s number {'min': 1, 'max': 600} device type str {'enum': ['wrist', 'chest']} device battery_pct number {'min': 0, 'max': 100} device contact_quality number {'min': 0, 'max': 1} person rest_hr number {'min': 40, 'max': 100} person age int {'min': 18, 'max': 45} person bmi number {'min': 15, 'max': 40} person acclim int {'min': 0, 'max': 1} weather temp_c number {'min': -40, 'max': 55} weather rh number {'min': 0, 'max': 100} weather wind number {'min': 0, 'max': 30} sample hr number {'min': 30, 'max': 220} sample temp number {'min': 30, 'max': 43} sample eda number {'min': 0, 'max': 40} 상태 단계(교육용 가상 라벨): {0: '정상', 1: '주의', 2: '경보'} / 위험 유형: ['normal', 'heat', 'cold'] 본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다. ``` ## 미션 2. 야전 활동 중 발생하는 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 구현 - 난이도: 초급~중급 / 예상 소요시간: 2.0시간 - 코드 설명: 야전 활동 중 생체신호의 결측·센서 튐·고착(센서 단절)을 탐지·보정하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·보정 전후 체온 오차와 경보 분류 성능을 측정한다. 진단 목적이 아닌 교육용 가상 자료이다. ```python """야전 활동 중 생체신호의 결측·센서 튐·고착(센서 단절)을 탐지·보정하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·보정 전후 체온 오차와 경보 분류 성능을 측정한다. 진단 목적이 아닌 교육용 가상 자료이다.""" import time import numpy as np import pandas as pd import matplotlib.pyplot as plt # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- def flatline(series, run=8): """같은 값이 run분 이상 이어지면 센서 고착(단절)으로 보고 해당 구간을 True로 표시한다.""" same = series.diff().abs().fillna(1) < 1e-9 grp = (~same).cumsum() return same.groupby(grp).transform("sum").add(1).where(same | same.shift(-1, fill_value=False), 1) >= run def preprocess(df): """고착 구간을 결측으로 바꾼 뒤 clean()으로 범위 검사·보간·튐 제거. 반환: (정제 df, 보정 건수, 고착 건수)""" d = df.copy(); stuck = 0 for c in ("hr", "temp"): fl = flatline(d[c].round(3)) stuck += int(fl.sum()); d.loc[fl, c] = np.nan out, fixed = clean(d) return out, fixed, stuck def inject_faults(df, rng): """센서 단절 모의: 체온이 가장 크게 변하는 시점 직전부터 센서가 12분간 같은 값으로 고착된다(실제 변화를 따라가지 못함).""" d = df.copy(); s = int(np.clip(np.argmax(np.abs(d["core"].values - 36.9)) - 12 + rng.integers(0, 4), 3, N_MIN - 13)) d.loc[s:s + 11, "temp"] = d.loc[s, "temp"] return d rng = np.random.default_rng(5) data = make_dataset(120, seed=21) # 1) 처리 속도와 오류율 t0 = time.perf_counter(); n_rows = 0; errors = 0; fixed_all = 0 for s in data: try: out, fx, st = preprocess(s["df"]); n_rows += len(out) * 3; fixed_all += fx errors += int(out[["hr", "temp", "eda"]].isna().any().any() or (out["temp"] > 43).any()) except Exception: errors += 1 sec = time.perf_counter() - t0 print(f"전처리 {n_rows:,}건(신호값) / {sec:.2f}초 = {n_rows / sec:,.0f}건/초 (목표 1,000 이상: {'달성' if n_rows / sec >= 1000 else '미달'})") print(f"전처리 오류 {errors}건 -> 오류율 {errors / len(data):.0%} (목표 0%), 범위 밖·결측 보정 {fixed_all}건") # 2) 단위 테스트 5종 def t1(): d = pd.DataFrame({"min": range(6), "hr": [80, np.nan, 82, 83, 84, 85.], "temp": 37.0, "eda": 2.0, "act": 1.5, "stage": 0, "rtype": "normal"}) out, fx = clean(d); assert out["hr"].notna().all() and fx == 1 def t2(): d = pd.DataFrame({"min": range(6), "hr": [80, 81, 230, 82, 83, 84.], "temp": 37.0, "eda": 2.0, "act": 1.5, "stage": 0, "rtype": "normal"}) out, fx = clean(d); assert out["hr"].max() < 100 and fx == 1 def t3(): s = pd.Series([36.9, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.1]); assert flatline(s).sum() >= 8 def t4(): s = pd.Series(np.linspace(36.5, 37.5, 20)); assert not flatline(s).any() def t5(): out, fx, st = preprocess(inject_faults(make_session(1, "normal", np.random.default_rng(3))["df"], np.random.default_rng(4))); assert st >= 8 and len(out) == N_MIN tests = {"결측 보간": t1, "센서 튐 제거": t2, "고착 구간 탐지": t3, "정상 변화 오탐 없음": t4, "고착 주입 후 보정": t5} passed = 0 for name, fn in tests.items(): try: fn(); passed += 1; print(f"[PASS] {name}") except AssertionError: print(f"[FAIL] {name}") print(f"단위 테스트 {passed}/5 통과") # 3) 보정 전후 체온 오차(정답 core 대비 RMSE)와 경보 분류 성능 def rmse(a, b): m = ~np.isnan(a) return float(np.sqrt(np.mean((a[m] - b[m]) ** 2))) def hot_cold_f1(df_use, stage_src): """체온 38.0 이상 또는 36.3 이하를 위험으로 본 경보와 정답 stage>=1의 F1.""" pred = (df_use["temp"] >= 38.0) | (df_use["temp"] <= 36.3) truth = stage_src["stage"] >= 1 tp = (pred & truth).sum(); p = tp / max(pred.sum(), 1); r = tp / max(truth.sum(), 1) return round(2 * p * r / max(p + r, 1e-9), 3), int((pred & ~truth).sum()) rows = [] for kind, label in (("normal", "평시"), ("heat", "폭염"), ("cold", "혹한")): raw, cln, tr_ = [], [], [] for s in [x for x in data if x["kind"] == kind]: bad = inject_faults(s["df"], rng) out, _, _ = preprocess(bad) raw.append(bad); cln.append(out); tr_.append(s["df"]) raw, cln, tr_ = pd.concat(raw), pd.concat(cln), pd.concat(tr_) f_raw, fp_raw = hot_cold_f1(raw, tr_) f_cln, fp_cln = hot_cold_f1(cln, tr_) rows.append({"상황": label, "체온 RMSE 전": round(rmse(raw["temp"].where(raw["temp"] <= 43).values, tr_["core"].values), 3), "체온 RMSE 후": round(rmse(cln["temp"].values, tr_["core"].values), 3), "경보 F1 전": f_raw, "경보 F1 후": f_cln, "오경보 전": fp_raw, "오경보 후": fp_cln}) print("\n[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)") print(pd.DataFrame(rows).to_string(index=False)) print("전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.") s = data[1]; bad = inject_faults(s["df"], rng); out, _, _ = preprocess(bad) plt.figure(figsize=(9, 4)) plt.plot(bad["min"], bad["temp"], ".", color="tab:red", label="raw (faults)"); plt.plot(out["min"], out["temp"], color="tab:blue", label="cleaned") plt.plot(s["df"]["min"], s["df"]["core"], "k:", label="true (simulated)"); plt.ylim(34, 41); plt.xlabel("minute"); plt.ylabel("temp (C)") plt.title(f"{s['id']} temperature before/after preprocessing (synthetic)"); plt.legend(); plt.show() ``` 실행 결과 예시 ```text 전처리 21,600건(신호값) / 1.20초 = 18,030건/초 (목표 1,000 이상: 달성) 전처리 오류 0건 -> 오류율 0% (목표 0%), 범위 밖·결측 보정 663건 [PASS] 결측 보간 [PASS] 센서 튐 제거 [PASS] 고착 구간 탐지 [PASS] 정상 변화 오탐 없음 [PASS] 고착 주입 후 보정 단위 테스트 5/5 통과 [보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용) 상황 체온 RMSE 전 체온 RMSE 후 경보 F1 전 경보 F1 후 오경보 전 오경보 후 평시 0.141 0.121 0.000 0.000 4 0 폭염 0.146 0.104 0.818 0.820 378 417 혹한 0.219 0.203 0.814 0.828 397 419 전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다. ``` ## 미션 3. 체온-심박수 상관관계 기반 1차 열사병/저체온증 경보 Baseline 모델 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 체온과 심박수의 상관관계(체온 상승 시 심박수 동반 상승)를 이용한 규칙 기반 1차 온열·한랭 스트레스 경보 Baseline을 구현하고, 가상 병사 데이터에서 정확도와 위험 구간 재현율을 측정한다. 진단 아님·의무대 연계 원칙을 따른다. ```python """체온과 심박수의 상관관계(체온 상승 시 심박수 동반 상승)를 이용한 규칙 기반 1차 온열·한랭 스트레스 경보 Baseline을 구현하고, 가상 병사 데이터에서 정확도와 위험 구간 재현율을 측정한다. 진단 아님·의무대 연계 원칙을 따른다.""" import numpy as np import pandas as pd import matplotlib.pyplot as plt # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- # 판단 기준(투명성): ① 최근 5분 평균 체온이 38.0도 이상이고 심박수가 95 이상이면 '온열 스트레스 의심' # ② 최근 5분 평균 체온이 36.3도 이하이면 '한랭 스트레스 의심' ③ 그 외 정상 TH_HEAT, TH_COLD, HR_GATE = 38.0, 36.3, 95 def rule(r, th=TH_HEAT, tc=TH_COLD, hg=HR_GATE): if r["temp5"] >= th and r["hr5"] >= hg: return "heat" if r["temp5"] <= tc: return "cold" return "normal" data = make_dataset(90, seed=7) tab = table(data, use_env=False, personal=False) tab["pred"] = tab.apply(rule, axis=1) acc = (tab["pred"] == tab["rtype"]).mean() print(f"[Baseline] 가상 병사 90명 x 55분 = {len(tab):,}개 구간, 정답 분포 {tab['rtype'].value_counts().to_dict()}") print(f"경보 유형 분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}") print("\n[유형별 판정] (행: 정답, 열: 판정)") cm = pd.crosstab(tab["rtype"], tab["pred"]).reindex(index=TYPES, columns=TYPES, fill_value=0) print(cm.to_string()) for c in ("heat", "cold"): rec = cm.loc[c, c] / cm.loc[c].sum(); prec = cm.loc[c, c] / max(cm[c].sum(), 1) print(f"{c}: 재현율 {rec:.1%}, 정밀도 {prec:.1%}") print("\n[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)") risk = tab[tab["stage"] >= 1] print(risk.groupby("stage").apply(lambda g: f"{(g['pred'] == g['rtype']).mean():.1%} ({len(g)}구간)").to_string()) print("\n[체온-심박수 상관] 전체 상관계수 %.2f / 폭염 세션 %.2f / 혹한 세션 %.2f" % ( tab[["temp5", "hr5"]].corr().iloc[0, 1], tab[tab["kind"] == "heat"][["temp5", "hr5"]].corr().iloc[0, 1], tab[tab["kind"] == "cold"][["temp5", "hr5"]].corr().iloc[0, 1])) print("\n[임계값 민감도] 온열 체온 기준 변화") for th in (37.7, 38.0, 38.3, 38.6): p = tab.apply(lambda r: rule(r, th=th), axis=1) r = ((p == "heat") & (tab["rtype"] == "heat")).sum() / (tab["rtype"] == "heat").sum(); f = ((p == "heat") & (tab["rtype"] != "heat")).sum() print(f"기준 {th}: 온열 재현율 {r:.1%}, 오경보 {f}구간") print("한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.") print("본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.") colr = {"normal": "tab:gray", "heat": "tab:red", "cold": "tab:blue"} plt.figure(figsize=(7, 5)) for k, g in tab.groupby("rtype"): plt.scatter(g["temp5"], g["hr5"], s=6, alpha=0.4, color=colr[k], label=k) plt.axvline(TH_HEAT, ls="--", color="k"); plt.axvline(TH_COLD, ls="--", color="k"); plt.axhline(HR_GATE, ls=":", color="k") plt.xlabel("temperature (5-min mean, C)"); plt.ylabel("heart rate (5-min mean, bpm)"); plt.legend(); plt.title("Baseline rule on dummy signals") plt.show() ``` 실행 결과 예시 ```text [Baseline] 가상 병사 90명 x 55분 = 4,950개 구간, 정답 분포 {'normal': 3536, 'cold': 761, 'heat': 653} 경보 유형 분류 정확도 86.9% (목표 75% 이상) -> 달성 [유형별 판정] (행: 정답, 열: 판정) pred normal heat cold rtype normal 3056 246 234 heat 160 493 0 cold 7 0 754 heat: 재현율 75.5%, 정밀도 66.7% cold: 재현율 99.1%, 정밀도 76.3% [단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율) stage 1 86.8% (1221구간) 2 96.9% (193구간) [체온-심박수 상관] 전체 상관계수 0.48 / 폭염 세션 0.32 / 혹한 세션 0.41 [임계값 민감도] 온열 체온 기준 변화 기준 37.7: 온열 재현율 75.7%, 오경보 462구간 기준 38.0: 온열 재현율 75.5%, 오경보 246구간 기준 38.3: 온열 재현율 67.7%, 오경보 38구간 기준 38.6: 온열 재현율 31.1%, 오경보 0구간 한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다. 본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다. ``` ## 미션 4. 기상 데이터(온·습도, 풍속) 결합 온열/한랭 스트레스 복합 위험도 예측 모델 고도화 - 난이도: 중급 / 예상 소요시간: 3.0시간 - 코드 설명: 체온·심박수·피부전도도에 기상(온도·습도·풍속)과 개인 특성(안정시 심박, 순응 여부, 체격)을 결합한 복합 환경 위험도 분류 모델(그래디언트 부스팅)로 고도화하고, 위험 구간 재현율·처리 지연을 Baseline과 비교한다. 센서 이상 예외 탐지 10종도 시험한다. 진단 아님·의무대 연계. ```python """체온·심박수·피부전도도에 기상(온도·습도·풍속)과 개인 특성(안정시 심박, 순응 여부, 체격)을 결합한 복합 환경 위험도 분류 모델(그래디언트 부스팅)로 고도화하고, 위험 구간 재현율·처리 지연을 Baseline과 비교한다. 센서 이상 예외 탐지 10종도 시험한다. 진단 아님·의무대 연계.""" import time import numpy as np import pandas as pd from sklearn.ensemble import GradientBoostingClassifier # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- ADV_FEATS = ["temp5", "hr5", "eda5", "dtemp", "act", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"] LABEL = {"normal": 0, "heat": 1, "cold": 2} def baseline_pred(t): return np.where((t["temp5"] >= 38.0) & (t["hr5"] >= 95), "heat", np.where(t["temp5"] <= 36.3, "cold", "normal")) train = table(make_dataset(150, seed=31)) clf = GradientBoostingClassifier(n_estimators=80, max_depth=3, learning_rate=0.1, random_state=0).fit(train[ADV_FEATS], train["rtype"].map(LABEL)) inv = {v: k for k, v in LABEL.items()} # 시험셋 2종: 일반 / 복합 교란(강풍 혹한 + 고강도 활동 + 센서 튐 많음) std = make_dataset(90, seed=77) rng = np.random.default_rng(9) hard = [] for i in range(60): s = make_session(i, ["heat", "cold"][i % 2], rng) if s["kind"] == "cold": s["env"]["wind"] = float(rng.uniform(7, 11)) for col, val in (("hr", 230.0), ("temp", 45.0)): idx = rng.choice(np.arange(5, N_MIN), 3, replace=False); s["df"].loc[idx, col] = val hard.append(s) rows = [] for name, sess in (("일반", std), ("복합 교란", hard)): t = table(sess) pb = baseline_pred(t) t0 = time.perf_counter(); pa = np.array([inv[v] for v in clf.predict(t[ADV_FEATS])]); ms = (time.perf_counter() - t0) * 1000 / len(t) risk = t["stage"] >= 1; danger = t["stage"] == 2 rows.append({"시험셋": name, "구간 수": len(t), "Baseline 위험 재현율": round(float((pb[risk] == t["rtype"][risk]).mean()), 3), "고도화 위험 재현율": round(float((pa[risk] == t["rtype"][risk]).mean()), 3), "고도화 경보(2단계) 재현율": round(float((pa[danger] == t["rtype"][danger]).mean()), 3), "Baseline 오경보": int(((pb != "normal") & ~risk).sum()), "고도화 오경보": int(((pa != "normal") & ~risk).sum()), "지연(ms/구간)": round(ms, 3)}) out = pd.DataFrame(rows) print(out.T.to_string(header=False)) rec = out["고도화 위험 재현율"].min(); lat = out["지연(ms/구간)"].max() imp = pd.Series(clf.feature_importances_, index=ADV_FEATS).sort_values(ascending=False) print("\n특징 중요도 상위:", ", ".join(f"{k} {v:.2f}" for k, v in imp.head(5).items())) print(f"\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}") print(f"처리 지연 최대 {lat:.3f} ms/구간 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}") # ---- 예외(센서 이상·기만) 탐지 10종: 모델에 넣기 전 신호 자체의 신뢰성을 점검한다 ---- def anomaly(w): """최근 10분 원시 신호 창(hr, temp, eda 배열 dict)의 이상 유형 목록""" flags = [] for c in ("hr", "temp", "eda"): v = w[c] if np.isnan(v).all(): flags.append(f"{c}_missing_all") else: lo, hi = LIMITS[c] if ((v < lo) | (v > hi)).any(): flags.append(f"{c}_out_of_range") if np.isnan(v).mean() > 0.3: flags.append(f"{c}_many_missing") ok = v[~np.isnan(v)] if len(ok) >= 8 and np.ptp(ok) < 1e-6: flags.append(f"{c}_flatline") if len(ok) >= 3 and np.abs(np.diff(ok)).max() > {"hr": 60, "temp": 1.5, "eda": 10}[c]: flags.append(f"{c}_jump") if "temp_out_of_range" not in flags and "hr_out_of_range" not in flags and not np.isnan(w["temp"]).all() and not np.isnan(w["hr"]).all(): if np.nanmean(w["temp"]) >= 38.5 and np.nanmean(w["hr"]) < 60: flags.append("temp_hr_inconsistent") return flags def win(hr=80, temp=37.0, eda=2.0, n=10): return {"hr": np.full(n, float(hr)) + np.arange(n) % 3, "temp": np.full(n, float(temp)) + np.arange(n) * 0.01, "eda": np.full(n, float(eda)) + np.arange(n) % 2 * 0.1} cases = [] w = win(); cases.append(("1 정상", w, [])) w = win(); w["hr"][4] = 230; cases.append(("2 심박 튐", w, ["hr_out_of_range", "hr_jump"])) w = win(); w["temp"][:] = 37.0; cases.append(("3 체온 고착", w, ["temp_flatline"])) w = win(); w["temp"][:6] = np.nan; cases.append(("4 체온 결측 다수", w, ["temp_many_missing"])) w = win(); w["hr"][:] = np.nan; cases.append(("5 심박 전체 두절", w, ["hr_missing_all"])) w = win(); w["temp"][3] = 45.0; cases.append(("6 체온 범위 이탈", w, ["temp_out_of_range", "temp_jump"])) w = win(temp=39.0, hr=55); cases.append(("7 체온-심박 불일치", w, ["temp_hr_inconsistent"])) w = win(); w["eda"][:] = 2.0; cases.append(("8 피부전도 고착", w, ["eda_flatline"])) w = win(); w["temp"][5:] += 2.0; cases.append(("9 체온 급변", w, ["temp_jump"])) w = win(); w["hr"][:] = 80.0; w["temp"][:] = 37.0; cases.append(("10 심박·체온 동시 고착", w, ["hr_flatline", "temp_flatline"])) hit = 0 print("\n[센서 이상 탐지 시험 10종]") for name, w, expect in cases: got = anomaly(w); good = all(e in got for e in expect) and (bool(got) == bool(expect)); hit += good print(f"[{'PASS' if good else 'FAIL'}] {name}: {', '.join(got) or '이상 없음'}") print(f"통과 {hit}/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).") print("본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.") ``` 실행 결과 예시 ```text 시험셋 일반 복합 교란 구간 수 4950 3300 Baseline 위험 재현율 0.879 0.916 고도화 위험 재현율 0.929 0.934 고도화 경보(2단계) 재현율 1.0 1.0 Baseline 오경보 446 422 고도화 오경보 108 98 지연(ms/구간) 0.004 0.004 특징 중요도 상위: temp5 0.48, eda5 0.42, hr_rel 0.05, hr5 0.01, fit 0.01 복합 위협 탐지 재현율(최저 시험셋) 92.9% (목표 85% 이상) -> 달성 처리 지연 최대 0.004 ms/구간 (목표 200 ms 이하) -> 달성 [센서 이상 탐지 시험 10종] [PASS] 1 정상: 이상 없음 [PASS] 2 심박 튐: hr_out_of_range, hr_jump [PASS] 3 체온 고착: temp_flatline [PASS] 4 체온 결측 다수: temp_many_missing [PASS] 5 심박 전체 두절: hr_missing_all [PASS] 6 체온 범위 이탈: temp_out_of_range, temp_jump [PASS] 7 체온-심박 불일치: temp_hr_inconsistent [PASS] 8 피부전도 고착: eda_flatline [PASS] 9 체온 급변: temp_jump [PASS] 10 심박·체온 동시 고착: hr_flatline, temp_flatline 통과 10/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지). 본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다. ``` ## 미션 5. 전투원 신체 조건별 개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진 개발 - 난이도: 중급~고급 / 예상 소요시간: 3.0시간 - 코드 설명: 개인별 안정시 심박·체격·순응 여부를 반영해 활동 강도로 설명되는 심박 상승을 보정한 개인화 위험 지수를 계산하고, 오경보를 줄이는 신뢰도 점수와 등급(High/Medium/Low)을 산출해 고정 임계값 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다. 진단 아님·의무대 연계. ```python """개인별 안정시 심박·체격·순응 여부를 반영해 활동 강도로 설명되는 심박 상승을 보정한 개인화 위험 지수를 계산하고, 오경보를 줄이는 신뢰도 점수와 등급(High/Medium/Low)을 산출해 고정 임계값 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다. 진단 아님·의무대 연계.""" import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, roc_curve from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- PF = ["temp5", "dtemp", "hr_excess", "eda5", "hi", "wc", "acclim", "bmi", "tdev"] def personalize(t): """활동 강도·개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승(hr_excess)'과 체온 편차(tdev)를 만든다.""" t = t.copy() expected = t["hr5"] - t["hr_rel"] + 9.5 * (t["act"].rolling(5, min_periods=1).mean() - 1.5) * (1.15 - 0.3 * t["fit"]) t["hr_excess"] = t["hr5"] - expected t["tdev"] = (t["temp5"] - 36.9).abs() return t def grade(p): return "High" if p >= 0.8 else ("Medium" if p >= 0.5 else "Low") train = personalize(table(make_dataset(180, seed=41))) test = personalize(table(make_dataset(90, seed=43))) model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", C=1.0, max_iter=3000, random_state=0)) model.fit(train[PF], train["stage"] >= 1) test["score"] = model.predict_proba(test[PF])[:, 1] test["grade"] = test["score"].map(grade) true = (test["stage"] >= 1).values fixed = ((test["temp5"] >= 37.8) | (test["temp5"] <= 36.5) | (test["hr5"] >= 130)).values # 기존: 고정 임계값 경보 eng = (test["score"] >= 0.5).values def far_rec(alert): return (alert & ~true).sum() / max(alert.sum(), 1), (alert & true).sum() / true.sum() f0, r0 = far_rec(fixed); f1, r1 = far_rec(eng) print(pd.DataFrame([{"방식": "고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상)", "경보 구간": int(fixed.sum()), "오경보율": round(f0, 3), "위험 재현율": round(r0, 3)}, {"방식": "개인화 신뢰도 엔진(0.5 이상)", "경보 구간": int(eng.sum()), "오경보율": round(f1, 3), "위험 재현율": round(r1, 3)}]).to_string(index=False)) cut = 1 - f1 / f0 print(f"\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC {roc_auc_score(true, test['score']):.3f}") # 점수 일관성: 센서 값에 +-2% 측정 흔들림을 주어도 같은 등급이 유지되는 비율 rng = np.random.default_rng(0) X = test[PF].values; g0 = test["grade"].values same = [np.mean(np.array([grade(v) for v in model.predict_proba(pd.DataFrame(X * (1 + rng.normal(0, 0.02, X.shape)), columns=PF))[:, 1]]) == g0) for _ in range(10)] print(f"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})") print("\n[단계별 등급 분포] (행: 정답 단계)") print(pd.crosstab(test["stage"].map(STAGE).rename("정답 단계"), test["grade"].rename("등급")).to_string()) # 개인화 효과: 같은 심박·체온이라도 체력·순응 여부에 따라 지수가 달라지는 예 ex = test.iloc[[0]].copy() rows = [] for name, fit, acc in (("체력 낮음·비순응", 0.2, 0), ("체력 높음·순응", 0.8, 1)): e = ex.copy(); e["fit"], e["acclim"] = fit, acc e["hr_rel"] = e["hr5"] - 64; e["hr_excess"] = 0; e = personalize(e); e["act"] = 4.5 e["hr5"] = 128.0; e["temp5"] = 38.3; e["hi"], e["wc"] = 41.0, 30.0; e["tdev"] = 1.1; e["dtemp"] = 0.2; e["eda5"] = 3.5; e = personalize(e) rows.append({"가상 병사": name, "초과 심박": round(float(e["hr_excess"].iloc[0]), 1), "위험 점수": round(float(model.predict_proba(e[PF])[:, 1][0]), 3)}) print("\n[개인화 예시: 같은 심박 128·체온 38.3]") print(pd.DataFrame(rows).to_string(index=False)) print("같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).") sc, lr = model[0], model[-1] i = int(np.argmax(test["score"].values)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0] print(f"\n최고 점수 구간 근거: ", ", ".join(f"{PF[j]}({c[j]:+.2f})" for j in np.argsort(-np.abs(c))[:3])) print("등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) for st, col in ((0, "tab:gray"), (1, "tab:orange"), (2, "tab:red")): ax[0].hist(test.loc[test["stage"] == st, "score"], bins=10, range=(0, 1), alpha=0.6, color=col, label=f"stage {st}") ax[0].axvline(0.5, color="k", ls="--"); ax[0].set_xlabel("confidence score"); ax[0].legend(); ax[0].set_title("Score by true stage") fpr, tpr, _ = roc_curve(true, test["score"]); ax[1].plot(fpr, tpr); ax[1].plot([0, 1], [0, 1], "k:"); ax[1].set_title("ROC"); ax[1].set_xlabel("FPR"); ax[1].set_ylabel("TPR") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text 방식 경보 구간 오경보율 위험 재현율 고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상) 2614 0.392 0.999 개인화 신뢰도 엔진(0.5 이상) 1797 0.165 0.943 오경보율 감축 57.9% (목표 40% 이상: 달성), ROC-AUC 0.986 신뢰도 등급 일관성 92.9% (같은 입력 재실행 100%, 목표 90% 이상: 달성) [단계별 등급 분포] (행: 정답 단계) 등급 High Low Medium 정답 단계 경보 183 0 0 정상 96 3063 201 주의 1153 90 164 [개인화 예시: 같은 심박 128·체온 38.3] 가상 병사 초과 심박 위험 점수 체력 낮음·비순응 1.9 0.288 체력 높음·순응 7.0 0.170 같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐). 최고 점수 구간 근거: tdev(+21.02), hi(+8.18), wc(-7.12) 등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다. ``` ## 미션 6. 소대장/의무관용 장병 생체 위험 신호 모니터링 및 긴급 경보 대시보드 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 소대장·의무관용 가상 소대 24명의 생체 위험 신호 모니터링 상황판을 구현한다. 병사별 상태(정상·주의·경보·신호두절), 체온·심박 추세, 조치 권고 문구를 1초 주기로 갱신하고 처리시간을 측정한다. 진단 아님·의무대 연계. ```python """소대장·의무관용 가상 소대 24명의 생체 위험 신호 모니터링 상황판을 구현한다. 병사별 상태(정상·주의·경보·신호두절), 체온·심박 추세, 조치 권고 문구를 1초 주기로 갱신하고 처리시간을 측정한다. 진단 아님·의무대 연계.""" import time import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"] tr = table(make_dataset(120, seed=51)) model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1) # [교육용가상자료] 가상 소대 24명(가명 ID): 폭염 8, 혹한 8, 평시 8 구역에서 활동 중인 병사들의 화면 rng = np.random.default_rng(3) kinds = ["heat"] * 8 + ["cold"] * 8 + ["normal"] * 8 squad = [make_session(i, k, rng) for i, k in enumerate(kinds)] for s in squad[:2]: s["df"].loc[40:, ["hr", "temp", "eda"]] = np.nan # 신호 두절 2명(기기 이탈·배터리 방전 모의) FEAT = {s["id"]: features(s) for s in squad} # 병사별 분 단위 특징표(재생용) RAW = {s["id"]: s["df"] for s in squad} def snapshot(minute): """분 단위 1틱: 병사별 상태·점수·권고를 계산해 상황판 표를 만든다.""" rows = [] for s in squad: f = FEAT[s["id"]]; r = f[f["min"] == minute] raw = RAW[s["id"]].loc[max(minute - 4, 0):minute, ["hr", "temp"]] if raw.isna().all().all(): rows.append({"병사": s["id"], "상태": "신호두절", "점수": np.nan, "체온": np.nan, "심박": np.nan, "추세": "-", "권고": "기기 착용·배터리 확인, 소대장 대면 확인"}); continue sc = float(model.predict_proba(r[FE])[:, 1][0]) st = "경보" if sc >= 0.8 else ("주의" if sc >= 0.5 else "정상") rec = {"경보": "소대장 즉시 확인, 의무대 연락 검토", "주의": "재측정·휴식 상태 확인", "정상": "-"}[st] rows.append({"병사": s["id"], "상태": st, "점수": round(sc, 2), "체온": round(float(r["temp5"].iloc[0]), 1), "심박": int(r["hr5"].iloc[0]), "추세": f"{float(r['dtemp'].iloc[0]):+.1f}", "권고": rec}) return pd.DataFrame(rows) # 1) 1초 주기 갱신 모의: 5틱 갱신 시간 측정 times = [] for m in range(45, 50): t0 = time.perf_counter(); board = snapshot(m); times.append((time.perf_counter() - t0) * 1000) print(f"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms, 최대 {max(times):.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if max(times) < 1000 else '미달'})") # 2) 병사 24명 -> 확인 대상 요약(인지 부하 감소) order = {"신호두절": 0, "경보": 1, "주의": 2, "정상": 3} board = board.sort_values(["상태", "점수"], key=lambda s: s.map(order) if s.name == "상태" else -s.fillna(2), kind="stable") cnt = board["상태"].value_counts().reindex(["경보", "주의", "신호두절", "정상"]).fillna(0).astype(int) print(f"\n[소대 현황] 병사 {len(board)}명 -> 확인 대상 {len(board) - cnt['정상']}명으로 압축:", cnt.to_dict()) print("\n[우선 확인 목록: 신호두절 > 경보 > 주의 순]") print(board[board["상태"] != "정상"].head(12).fillna("-").to_string(index=False)) # 3) 렌더링 시간(UI 응답성)과 상황판 그림 t0 = time.perf_counter() col = {"경보": "tab:red", "주의": "orange", "정상": "tab:green", "신호두절": "dimgray"}; EN = {"경보": "ALERT", "주의": "CAUTION", "정상": "NORMAL", "신호두절": "NO SIGNAL"} fig, ax = plt.subplots(2, 3, figsize=(13, 6)) focus = board[board["상태"].isin(["경보", "주의"])].head(5)["병사"].tolist() for a, sid in zip(ax.ravel()[:5], focus): d = RAW[sid]; st = board[board["병사"] == sid]["상태"].iloc[0] a.plot(d["min"], d["temp"].interpolate(), color=col[st]); a.axvline(49, color="k", ls=":"); a.set_ylim(34, 41) a.set_title(f"{sid} [{EN[st]}] temperature trend", fontsize=9); a.set_xlabel("minute") ax.ravel()[5].bar([EN[k] for k in cnt.index], cnt.values, color=[col[k] for k in cnt.index]); ax.ravel()[5].set_title("Platoon status") plt.suptitle("Soldier bio-signal board (dummy data, not diagnosis)"); plt.tight_layout(); fig.canvas.draw() render = (time.perf_counter() - t0) * 1000 print(f"\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {np.mean(times):.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)") print("상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.") plt.show() ``` 실행 결과 예시 ```text 상황판 갱신 처리시간 평균 74.0 ms, 최대 77.3 ms (목표: 갱신 주기 1초 이내 -> 달성) [소대 현황] 병사 24명 -> 확인 대상 12명으로 압축: {'경보': 7, '주의': 3, '신호두절': 2, '정상': 12} [우선 확인 목록: 신호두절 > 경보 > 주의 순] 병사 상태 점수 체온 심박 추세 권고 S000 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인 S001 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인 S003 경보 1.0 38.7 119.0 +0.1 소대장 즉시 확인, 의무대 연락 검토 S010 경보 1.0 35.4 65.0 -0.1 소대장 즉시 확인, 의무대 연락 검토 S009 경보 0.99 35.3 93.0 -0.1 소대장 즉시 확인, 의무대 연락 검토 S006 경보 0.96 38.5 110.0 +0.1 소대장 즉시 확인, 의무대 연락 검토 S014 경보 0.94 35.9 85.0 -0.1 소대장 즉시 확인, 의무대 연락 검토 S004 경보 0.93 38.3 81.0 -0.0 소대장 즉시 확인, 의무대 연락 검토 S005 경보 0.86 38.1 125.0 +0.0 소대장 즉시 확인, 의무대 연락 검토 S007 주의 0.67 38.5 110.0 +0.0 재측정·휴식 상태 확인 S002 주의 0.62 38.3 91.0 +0.1 재측정·휴식 상태 확인 S012 주의 0.52 36.2 92.0 -0.0 재측정·휴식 상태 확인 한 화면 렌더링 565 ms (UI 응답 목표 100 ms: 데이터 갱신 74.0 ms는 달성, 렌더링은 실행 환경에 따라 달라짐) 상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다. ``` ## 미션 7. 위험 장병 발생 시 군의관 원격 검토 및 임무 배제/휴식 명령 HITL 절차 설계 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: 위험 신호가 발생한 가상 병사에 대해 군의관이 원격 검토하고 휴식 권고·임무 배제 요청·의무대 인계 요청·재측정 보류를 결정하는 인간 개입(HITL) 절차를 구현한다. AI는 어떤 조치도 자동으로 명령하지 않으며 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다. 진단 아님. ```python """위험 신호가 발생한 가상 병사에 대해 군의관이 원격 검토하고 휴식 권고·임무 배제 요청·의무대 인계 요청·재측정 보류를 결정하는 인간 개입(HITL) 절차를 구현한다. AI는 어떤 조치도 자동으로 명령하지 않으며 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다. 진단 아님.""" import hashlib import json import pandas as pd ACTIONS = {"REST": "휴식 권고(현장 확인 후 이행)", "EXCLUDE": "임무 배제 요청(지휘관 승인 대상)", "REFER": "의무대 인계 요청", "HOLD": "재측정·보류"} ROLES = {"platoon_leader": "소대장", "medical_officer": "군의관", "commander": "지휘관"} PRIORITY = {"High": 0, "Medium": 1, "Low": 2} HOLD_LIMIT_S = 60 class WorkflowError(ValueError): pass class ReviewQueue: def __init__(self, clock): self.items, self.log, self.clock = {}, [], clock # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ---- def _record(self, body): prev = self.log[-1]["hash"] if self.log else "GENESIS" body = {**body, "ts": round(self.clock(), 3)} h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest() self.log.append({**body, "prev": prev, "hash": h}) def verify_log(self): prev = "GENESIS" for n, rec in enumerate(self.log, start=1): body = {k: v for k, v in rec.items() if k not in ("prev", "hash")} ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest() if rec["prev"] != prev or rec["hash"] != ok_hash: return False, n prev = rec["hash"] return True, -1 def submit(self, sid, score, grade, summary): """AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.""" self.items[sid] = {"id": sid, "score": score, "grade": grade, "summary": summary, "status": "PENDING", "submitted": self.clock(), "hold_since": None, "escalated": False, "decided": None} self._record({"event": "SUBMIT", "id": sid, "score": score, "by": "AI"}) def pending(self): todo = [i for i in self.items.values() if i["status"] in ("PENDING", "HOLD", "EXCLUDE_REQUESTED")] return sorted(todo, key=lambda i: (not i["escalated"], PRIORITY[i["grade"]], -i["score"])) def decide(self, sid, action, operator, role, reason): if action not in ACTIONS: raise WorkflowError(f"허용되지 않은 조치: {action}") if role not in ROLES or not operator: raise WorkflowError("담당자 ID와 역할 필요") item = self.items.get(sid) if item is None: raise WorkflowError("대기열에 없는 병사") if item["status"] in ("CLOSED",): raise WorkflowError("이미 종결된 건") if len(reason.strip()) < 5: raise WorkflowError("판단 근거 5자 이상 필요") if action in ("EXCLUDE", "REFER") and role == "platoon_leader": if action == "EXCLUDE": raise WorkflowError("임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)") if action == "EXCLUDE" and item["status"] == "EXCLUDE_REQUESTED" and role != "commander": raise WorkflowError("임무 배제 최종 승인은 지휘관만 가능") if action == "EXCLUDE" and item["status"] != "EXCLUDE_REQUESTED": if role != "medical_officer": raise WorkflowError("임무 배제 요청은 군의관만 올릴 수 있음") item["status"] = "EXCLUDE_REQUESTED" # 군의관 요청 단계: 지휘관 승인 전까지 확정 아님 elif action == "EXCLUDE": item["status"] = "CLOSED" # 지휘관 승인 = 조치 지시 요청까지 elif action == "REST" and item["grade"] == "High" and role == "platoon_leader": raise WorkflowError("High 등급 휴식 판단은 군의관 검토 필요") elif action in ("REST", "REFER"): item["status"] = "CLOSED" else: item["status"] = "HOLD" item["hold_since"] = self.clock() if action == "HOLD" else None item["decided"] = self.clock() if item["status"] == "CLOSED" else None self._record({"event": "DECIDE", "id": sid, "action": action, "operator": operator, "role": role, "reason": reason}) def check_holds(self): """보류 기한 초과 시 자동 조치하지 않고 군의관에게 상신한다.""" out = [] for i in self.items.values(): if i["status"] == "HOLD" and not i["escalated"] and self.clock() - i["hold_since"] > HOLD_LIMIT_S: i["escalated"] = True; out.append(i["id"]) self._record({"event": "ESCALATE", "id": i["id"], "by": "SYSTEM"}) return out class FakeClock: t = 1000.0 def __call__(self): return self.t clock = FakeClock() q = ReviewQueue(clock) # [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 위험 신호 6건(가명 병사 ID) for sid, sc, g, sm in [("S003", 0.98, "High", "체온 38.7, 심박 119"), ("S010", 0.97, "High", "체온 35.4, 한랭"), ("S007", 0.66, "Medium", "체온 38.5, 심박 110"), ("S012", 0.52, "Medium", "체온 36.2"), ("S002", 0.55, "Medium", "체온 38.3"), ("S015", 0.35, "Low", "심박 일시 상승")]: q.submit(sid, sc, g, sm) print("검토 순서:", [i["id"] for i in q.pending()]) print("자동 조치(휴식·배제·인계)된 건수:", sum(i["status"] == "CLOSED" for i in q.items.values())) # 규칙 위반 시도가 모두 차단되는지 확인 tries = [("S003", "REST", "pl01", "platoon_leader", "더워서 쉬게 함 확인 완료"), ("S010", "EXCLUDE", "pl01", "platoon_leader", "저체온 의심되어 배제 요청"), ("S007", "REST", "", "platoon_leader", "휴식 후 재측정 예정"), ("S007", "DISCHARGE", "md01", "medical_officer", "허용되지 않은 조치"), ("S012", "REFER", "pl01", "platoon_leader", "짧음")] for args in tries: try: q.decide(*args) except WorkflowError as e: print("차단:", e) clock.t += 3; q.decide("S003", "EXCLUDE", "md01", "medical_officer", "원격 검토: 체온 상승 지속, 임무 배제 및 의무대 인계 요청") clock.t += 4; q.decide("S003", "EXCLUDE", "cdr01", "commander", "군의관 의견 확인, 임무 배제 승인 후 의무대 인계 지시 요청") clock.t += 5; q.decide("S010", "HOLD", "md01", "medical_officer", "센서 냉각 오차 가능성, 재측정 후 판단") clock.t += 4; q.decide("S007", "REST", "pl01", "platoon_leader", "그늘 휴식 후 재측정, 상태 호전 확인") clock.t += 3; q.decide("S015", "REST", "pl01", "platoon_leader", "운동 직후 일시 상승으로 확인") clock.t += 70 print("보류 기한 초과 상신:", q.check_holds()) print("상신 후 검토 순서:", [i["id"] for i in q.pending()]) df = pd.DataFrame(q.items.values()) df["대기시간(s)"] = (df["decided"] - df["submitted"]).round(1) print("\n" + df[["id", "grade", "score", "status", "escalated", "대기시간(s)"]].fillna("-").to_string(index=False)) ev = [r for r in q.log if r["event"] == "DECIDE"] print(f"\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}") q.log[7]["operator"] = "md99" # 위·변조 시도 print("8번째 기록 변조 후 검증(False, 깨진 위치):", q.verify_log()) print("\n책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.") print("본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.") ``` 실행 결과 예시 ```text 검토 순서: ['S003', 'S010', 'S007', 'S002', 'S012', 'S015'] 자동 조치(휴식·배제·인계)된 건수: 0 차단: High 등급 휴식 판단은 군의관 검토 필요 차단: 임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가) 차단: 담당자 ID와 역할 필요 차단: 허용되지 않은 조치: DISCHARGE 차단: 판단 근거 5자 이상 필요 보류 기한 초과 상신: ['S010'] 상신 후 검토 순서: ['S010', 'S002', 'S012'] id grade score status escalated 대기시간(s) S003 High 0.98 CLOSED False 7.0 S010 High 0.97 HOLD True - S007 Medium 0.66 CLOSED False 16.0 S012 Medium 0.52 PENDING False - S002 Medium 0.55 PENDING False - S015 Low 0.35 CLOSED False 19.0 처리 결정 5건 중 로그 기록 5건(성공률 100%), 전체 감사 로그 12건, 해시 체인 검증: (True, -1) 8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8) 책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다. 본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다. ``` ## 미션 8. 폭염/혹한 야전 기동훈련 모의 대량 생체 데이터 유입 스트레스 부하 테스트 - 난이도: 고급 / 예상 소요시간: 3.5시간 - 코드 설명: 폭염·혹한 기동훈련에서 생체신호를 보내는 병사 수가 평시의 6배(트래픽 500% 증가)로 늘고 센서 튐·결측·기기 두절·전송 손실이 겹쳐도 시스템이 멈추지 않는지(다운 0건), 위험 경보가 유실되지 않는지(유실률 0%), 용량 초과 시 기능 축소 모드와 신호두절 경보가 안전하게 동작하는지 검증한다. 진단 아님·의무대 연계. ```python """폭염·혹한 기동훈련에서 생체신호를 보내는 병사 수가 평시의 6배(트래픽 500% 증가)로 늘고 센서 튐·결측·기기 두절·전송 손실이 겹쳐도 시스템이 멈추지 않는지(다운 0건), 위험 경보가 유실되지 않는지(유실률 0%), 용량 초과 시 기능 축소 모드와 신호두절 경보가 안전하게 동작하는지 검증한다. 진단 아님·의무대 연계.""" import time import warnings import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- warnings.simplefilter("ignore", RuntimeWarning) # 신호 전체 두절 시 빈 평균 경고는 신호두절 처리로 대체됨 BASE_N = 20 # 평시 동시 접속 병사 수 FULL_CAPACITY = 60 # 한 틱(1초)에 정밀 모델로 처리하는 병사 수(초과분은 기능 축소 모드) FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"] tr = table(make_dataset(120, seed=51)) model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1) # [교육용가상자료] 병사 120명 풀(폭염 60 / 혹한 60): 분 단위 원시 신호를 [병사, 분] 배열로 보관 rng0 = np.random.default_rng(8) POOL = [make_session(i, "heat" if i % 2 == 0 else "cold", rng0) for i in range(120)] HR, TEMP, EDA = (np.array([s["df"][c].values for s in POOL]) for c in ("hr", "temp", "eda")) STAGE_ = np.array([s["df"]["stage"].values for s in POOL]) STATIC = pd.DataFrame([{"rest_hr": s["person"]["rest_hr"], "acclim": s["person"]["acclim"], "bmi": s["person"]["bmi"], "fit": s["person"]["fit"], "hi": env_index(s["env"])[0], "wc": env_index(s["env"])[1], "wind": s["env"]["wind"]} for s in POOL]) def valid(a, lo, hi): return np.where((a >= lo) & (a <= hi), a, np.nan) def window_mean(arr, m, lo, hi, w=5): return np.nanmean(valid(arr[:, max(m - w + 1, 0):m + 1], lo, hi), axis=1) def tick(idx, m, hr, temp, eda, mode_full): """병사 idx의 분 m 신호를 처리해 경보 목록과 신호두절 목록을 반환한다.""" with np.errstate(all="ignore"): t5, h5, e5 = window_mean(temp, m, 30, 43), window_mean(hr, m, 30, 220), window_mean(eda, m, 0, 40) t5p = window_mean(temp, max(m - 4, 0), 30, 43) lost = np.isnan(t5) | np.isnan(h5) # 최근 5분 유효 값 없음 = 신호두절 ok = ~lost alert = np.zeros(len(idx), bool) if mode_full.any(): sel = ok & mode_full if sel.any(): S = STATIC.iloc[idx[sel]] X = pd.DataFrame({"temp5": t5[sel], "dtemp": np.nan_to_num(t5[sel] - t5p[sel]), "hr5": h5[sel], "eda5": np.nan_to_num(e5[sel], nan=2.0), "hr_rel": h5[sel] - S["rest_hr"].values, "acclim": S["acclim"].values, "bmi": S["bmi"].values, "fit": S["fit"].values, "hi": S["hi"].values, "wc": S["wc"].values, "wind": S["wind"].values})[FE] alert[np.where(sel)[0]] = model.predict_proba(X)[:, 1] >= 0.5 light = ok & ~mode_full alert[light] = (t5[light] >= 38.0) | (t5[light] <= 36.3) # 기능 축소: 체온 기준만(오경보는 늘고 미탐은 피함) return alert, lost def scenario(n, loss=0.0, spike=0.0, dead=0, ack_loss=0.0, seed=0): rng = np.random.default_rng(seed) idx = np.arange(n) % len(POOL) hr, temp, eda = HR[idx].copy(), TEMP[idx].copy(), EDA[idx].copy() for a in (hr, temp, eda): a[rng.random(a.shape) < loss] = np.nan # 패킷 손실로 인한 결측 m_sp = rng.random(hr.shape) < spike; hr[m_sp] = 230.0; temp[m_sp] = 45.0 # 센서 튐 dead_ids = rng.choice(n, dead, replace=False) if dead else [] for d in dead_ids: hr[d, 40:] = temp[d, 40:] = eda[d, 40:] = np.nan # 기기 두절(배터리·이탈) mode_full = np.arange(n) < FULL_CAPACITY generated, ledger, buf, ms, caught = 0, [], [], [], 0 hits = risk_tot = 0 for m in range(30, N_MIN): t0 = time.perf_counter() try: al, lost = tick(idx, m, hr, temp, eda, mode_full) new = [("RISK", i, m) for i in np.where(al)[0]] + [("LOST", i, m) for i in np.where(lost)[0]] truth = STAGE_[idx, m] >= 1; hits += int((al & truth).sum()); risk_tot += int(truth.sum()) except Exception as e: # 처리 오류는 격리하고 이번 틱 전체를 신호두절 경보로 대체 caught += 1; new = [("LOST", i, m) for i in range(n)] generated += len(new); buf += new for a in list(buf): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송 if rng.random() >= ack_loss: ledger.append(a); buf.remove(a) ms.append((time.perf_counter() - t0) * 1000) ledger += buf # 마지막에 남은 건은 수신 확인 후 반영 kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict() return {"병사 수": n, "배율": f"x{n / BASE_N:.0f}", "생성 경보": generated, "기록 경보": len(ledger), "유실률": f"{1 - len(ledger) / max(generated, 1):.0%}", "위험 재현율": f"{hits / max(risk_tot, 1):.0%}", "신호두절 경보": kinds.get("LOST", 0), "처리오류 격리": caught, "축소모드 병사": max(n - FULL_CAPACITY, 0), "틱 평균 ms": round(float(np.mean(ms)), 1)}, kinds cases = [("평시 20명", dict(n=20)), ("x2 40명", dict(n=40)), ("x4 80명", dict(n=80)), ("x6 120명(트래픽 500% 증가)", dict(n=120)), ("x6 + 패킷손실 20% + 센서튐 3%", dict(n=120, loss=0.2, spike=0.03, ack_loss=0.3)), ("x6 + 기기두절 8명 + 패킷손실 30%", dict(n=120, loss=0.3, dead=8, ack_loss=0.3))] rows = [] for i, (name, kw) in enumerate(cases): try: r, kinds = scenario(seed=i, **kw) except Exception as e: r = {"병사 수": kw["n"], "배율": "-", "생성 경보": 0, "기록 경보": 0, "유실률": "100%", "위험 재현율": "-", "신호두절 경보": 0, "처리오류 격리": 0, "축소모드 병사": 0, "틱 평균 ms": 0} print("다운:", e); r["다운"] = 1 r.setdefault("다운", 0) rows.append({"상황": name, **r}) df = pd.DataFrame(rows) print(df.drop(columns=["처리오류 격리"]).to_string(index=False)) print(f"\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}") # 기능 축소 모드의 대가: 같은 병사를 정밀·축소 모드로 처리했을 때 경보 수와 위험 재현율 n = 120; idx = np.arange(n) res = {} for name, full in (("정밀", np.ones(n, bool)), ("축소", np.zeros(n, bool))): a = f = h = t = 0 for m in range(30, N_MIN): al, _ = tick(idx, m, HR, TEMP, EDA, full); tru = STAGE_[idx, m] >= 1 a += al.sum(); f += (al & ~tru).sum(); h += (al & tru).sum(); t += tru.sum() res[name] = (a, f / max(a, 1), h / t) print(f"정밀 모드: 경보 {res['정밀'][0]}건(오경보율 {res['정밀'][1]:.1%}, 위험 재현율 {res['정밀'][2]:.1%}) / 축소 모드: 경보 {res['축소'][0]}건(오경보율 {res['축소'][1]:.1%}, 위험 재현율 {res['축소'][2]:.1%})") print("안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.") print("본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.") plt.figure(figsize=(9, 4)) plt.bar(range(len(df)), df["틱 평균 ms"], color=["tab:blue"] * 3 + ["tab:orange"] * 3) plt.xticks(range(len(df)), [f"case{i + 1}" for i in range(len(df))]); plt.axhline(1000, color="red", ls="--", label="1 s refresh budget") plt.ylabel("ms per tick"); plt.legend(); plt.title("Stress test: tick latency (orange = 120 soldiers)") plt.show() ``` 실행 결과 예시 ```text 상황 병사 수 배율 생성 경보 기록 경보 유실률 위험 재현율 신호두절 경보 축소모드 병사 틱 평균 ms 다운 평시 20명 20 x1 377 377 0% 97% 0 0 3.2 0 x2 40명 40 x2 790 790 0% 96% 0 0 3.4 0 x4 80명 80 x4 1768 1768 0% 98% 0 20 4.4 0 x6 120명(트래픽 500% 증가) 120 x6 2818 2818 0% 98% 0 60 3.4 0 x6 + 패킷손실 20% + 센서튐 3% 120 x6 2801 2801 0% 98% 10 60 4.2 0 x6 + 기기두절 8명 + 패킷손실 30% 120 x6 2812 2812 0% 93% 158 60 3.6 0 시스템 다운 0건(목표 0건), 최대 유실률 0%(목표 0%) -> 달성 정밀 모드: 경보 2655건(오경보율 14.6%, 위험 재현율 97.2%) / 축소 모드: 경보 2986건(오경보율 22.1%, 위험 재현율 99.7%) 안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다. 본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다. ``` ## 미션 9. AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값 파라미터 재보정 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(센서 냉각 오차, 센서 튐, 임계값 근접 등)로 분류하고, 체온 임계값·지속 시간·센서 냉각 보정을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다. 진단 아님·의무대 연계. ```python """AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(센서 냉각 오차, 센서 튐, 임계값 근접 등)로 분류하고, 체온 임계값·지속 시간·센서 냉각 보정을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다. 진단 아님·의무대 연계.""" import itertools import numpy as np import pandas as pd import matplotlib.pyplot as plt # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- rng = np.random.default_rng(13) OPS = [] for i in range(90): s = make_session(i, ["heat", "cold", "normal"][i % 3], rng) if s["kind"] == "cold" and i % 2 == 0: s["env"]["wind"] = float(rng.uniform(7.5, 11)) # 강풍 혹한: 센서 냉각 오차 구간 OPS.append(s) TAB = table(OPS) SPIKE = {s["id"]: set(np.where((s["df"]["hr"] > 220) | (s["df"]["temp"] > 43))[0]) for s in OPS} def alerts(tab, th_heat, th_cold, persist, corr): """체온 기준 경보: 센서 냉각 보정(강풍·한랭 환경에서 corr만큼 보정) 후 임계값을 persist분 연속 넘어야 경보.""" t = tab["temp5"] + np.where((tab["wind"] >= 7) & (tab["wc"] < 0), corr, 0.0) raw = (t >= th_heat) | (t <= th_cold) return raw.groupby(tab["sid"]).transform(lambda v: v.rolling(persist, min_periods=persist).min().fillna(0).astype(bool)) def summarize(tab, al): truth = tab["stage"] >= 1 tp = (al & truth).sum(); far = (al & ~truth).sum() / max(al.sum(), 1); rec = tp / truth.sum() return {"경보": int(al.sum()), "오경보율": round(float(far), 3), "재현율": round(float(rec), 3), "종합정확도(F1)": round(float(2 * (1 - far) * rec / max(1 - far + rec, 1e-9)), 3)} def cause(r): near = min(abs(r["temp5"] - 38.0), abs(r["temp5"] - 36.3)) < 0.35 if r["wind"] >= 7 and r["wc"] < 0: return "강풍 센서 냉각 오차" if any(m in SPIKE[r["sid"]] for m in range(int(r["min"]) - 4, int(r["min"]) + 1)): return "센서 튐 영향" return "임계값 근접(조기 경보)" if near else "기타" def aar_log(tab, al): rows = [{"구분": "오경보", "원인": cause(r), "병사": r["sid"], "유형": r["rtype"]} for r in tab[al & (tab["stage"] == 0)].to_dict("records")] miss = tab[~al & (tab["stage"] >= 1)] rows += [{"구분": "미탐", "원인": "임계값·지속시간 미충족", "병사": sid, "유형": "-"} for sid in miss["sid"].unique() for _ in range(int(len(miss[miss["sid"] == sid]) > 0))] return pd.DataFrame(rows, columns=["구분", "원인", "병사", "유형"]) # 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 열 기준, 높은 한랭 기준, 지속 1분) INIT = {"th_heat": 37.8, "th_cold": 36.6, "persist": 1, "corr": 0.0} a0 = alerts(TAB, **INIT) before = summarize(TAB, a0) log = aar_log(TAB, a0) print("[1. 무엇이 일어났나] 초기 설정", INIT, "->", before) print(log.groupby(["구분", "원인"]).size().rename("건수").to_string()) fa = log[log["구분"] == "오경보"] print("\n[2. 왜 일어났나] 오경보 유형:", fa["유형"].value_counts().to_dict(), "/ 오경보 발생 병사 수:", fa["병사"].nunique()) # 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 재발 최소 -> F1 최대 조합 탐색 seen = set(map(tuple, fa[["병사", "원인"]].drop_duplicates().values)) rows = [] for th, tc, ps, co in itertools.product((37.8, 38.0, 38.2, 38.4), (36.6, 36.4, 36.2, 36.0), (1, 2, 3), (0.0, 0.25)): al = alerts(TAB, th, tc, ps, co) new = aar_log(TAB, al); new = new[new["구분"] == "오경보"] rep = len(seen & set(map(tuple, new[["병사", "원인"]].drop_duplicates().values))) rows.append({"th_heat": th, "th_cold": tc, "persist": ps, "corr": co, **summarize(TAB, al), "재발": rep}) g = pd.DataFrame(rows) ok = g[(g["오경보율"] <= 0.15) & (g["재현율"] >= 0.90)].sort_values(["재발", "종합정확도(F1)", "재현율"], ascending=[True, False, False]) best = ok.iloc[0] print("\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)" % (len(g), len(ok))) print(ok.head().to_string(index=False)) after = summarize(TAB, alerts(TAB, best["th_heat"], best["th_cold"], int(best["persist"]), best["corr"])) gain = (after["종합정확도(F1)"] - before["종합정확도(F1)"]) * 100 print(f"\n재보정 후: {after}") print(f"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})") print(f"AAR에서 식별한 (병사, 원인) 오경보 {len(seen)}종 -> 재보정 후 같은 병사·같은 원인으로 재발 {int(best['재발'])}건 (목표 0건: {'달성' if best['재발'] == 0 else '미달'})") tuned = (f"heat_temp_threshold: {best['th_heat']}\ncold_temp_threshold: {best['th_cold']}\nmin_persist_minutes: {int(best['persist'])}\n" f"wind_cooling_correction_c: {best['corr']}\nconstraints: {{max_far: 0.15, min_recall: 0.90}}") print("\n[재보정 파라미터(wel01_tuned_params.yaml 내용)]\n" + tuned) print(f"개선 조치: 열 기준 {best['th_heat']}도·한랭 기준 {best['th_cold']}도, {int(best['persist'])}분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 {best['corr']}도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.") plt.figure(figsize=(6, 4)) plt.scatter(g["오경보율"], g["재현율"], c=g["persist"], cmap="viridis", s=18) plt.axvline(0.15, ls="--", c="gray"); plt.axhline(0.90, ls="--", c="gray"); plt.colorbar(label="persist (min)") plt.xlabel("false alarm rate"); plt.ylabel("risk recall"); plt.title("AAR re-tuning grid (dummy data)") plt.show() ``` 실행 결과 예시 ```text [1. 무엇이 일어났나] 초기 설정 {'th_heat': 37.8, 'th_cold': 36.6, 'persist': 1, 'corr': 0.0} -> {'경보': 2570, '오경보율': 0.405, '재현율': 1.0, '종합정확도(F1)': 0.746} 구분 원인 오경보 강풍 센서 냉각 오차 380 기타 5 센서 튐 영향 6 임계값 근접(조기 경보) 650 [2. 왜 일어났나] 오경보 유형: {'normal': 1041} / 오경보 발생 병사 수: 58 [3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 96개 중 8개 만족) th_heat th_cold persist corr 경보 오경보율 재현율 종합정확도(F1) 재발 38.2 36.2 3 0.00 1487 0.073 0.902 0.914 20 38.2 36.4 3 0.25 1545 0.103 0.906 0.902 21 38.2 36.2 2 0.00 1543 0.086 0.923 0.919 23 38.2 36.4 2 0.25 1603 0.116 0.927 0.905 27 38.2 36.2 1 0.00 1601 0.102 0.940 0.918 29 재보정 후: {'경보': 1487, '오경보율': 0.073, '재현율': 0.902, '종합정확도(F1)': 0.914} 종합정확도 변화 0.746 -> 0.914 (+16.8%p, 목표 +10%p 이상: 달성) AAR에서 식별한 (병사, 원인) 오경보 62종 -> 재보정 후 같은 병사·같은 원인으로 재발 20건 (목표 0건: 미달) [재보정 파라미터(wel01_tuned_params.yaml 내용)] heat_temp_threshold: 38.2 cold_temp_threshold: 36.2 min_persist_minutes: 3 wind_cooling_correction_c: 0.0 constraints: {max_far: 0.15, min_recall: 0.90} 개선 조치: 열 기준 38.2도·한랭 기준 36.2도, 3분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 0.0도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다. ``` ## 미션 10. 더미 생체신호 기반 온열·한랭 스트레스 경보 시스템 통합 패키지 완성 - 난이도: 최고급 / 예상 소요시간: 3.5시간 - 코드 설명: 데이터 검증, 전처리, 개인화 위험 점수, 지속 조건 경보, 군의관 검토 대기열(HITL), 소대 상황판, AAR 재보정값 적용까지 온열·한랭 스트레스 경보 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 지휘관·의무관 브리핑 요약을 출력한다. 진단 아님·의무대 연계. ```python """데이터 검증, 전처리, 개인화 위험 점수, 지속 조건 경보, 군의관 검토 대기열(HITL), 소대 상황판, AAR 재보정값 적용까지 온열·한랭 스트레스 경보 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 지휘관·의무관 브리핑 요약을 출력한다. 진단 아님·의무대 연계.""" import dis import hashlib import json import sys import time import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ---------------- # 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다. # 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다. N_MIN = 60 STAGE = {0: "정상", 1: "주의", 2: "경보"} TYPES = ["normal", "heat", "cold"] def make_person(i, rng): return {"id": f"S{i:03d}", "rest_hr": float(rng.normal(64, 7)), "age": int(rng.integers(19, 27)), "bmi": float(rng.normal(23.5, 2.5)), "acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))} def make_env(kind, rng): if kind == "heat": return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))} if kind == "cold": return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))} return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))} def make_session(sid, kind, rng, p=None, glitch=True): """반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.""" p = p or make_person(sid, rng) env = make_env(kind, rng) act = np.zeros(N_MIN); t = 0 while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0) wc = env["temp_c"] - 1.6 * env["wind"] core, cores = 36.9, [] for m in range(N_MIN): heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p["acclim"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p["bmi"] - 23.5)) cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m]) ss = 36.9 + 0.09 * act[m] + heat + cold core += (ss - core) / 18 + rng.normal(0, 0.03) cores.append(core) core = np.array(cores) hr = p["rest_hr"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p["fit"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN) eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN) bias = rng.normal(0, 0.1) temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차 df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act}) if glitch: for col in ("hr", "temp", "eda"): df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측 s = int(rng.integers(5, N_MIN)) if rng.random() < 0.12: df.loc[s, "hr"] = 230.0 # 센서 튐 if rng.random() < 0.12: df.loc[s, "temp"] = 45.0 ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0)) df["core"] = core df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal")) return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df} def make_dataset(n, seed): rng = np.random.default_rng(seed) kinds = ["normal", "heat", "cold"] return [make_session(i, kinds[i % 3], rng) for i in range(n)] # --------------------------------------------------------------------------- # ---------------- 전처리·특징 추출 공통 함수 ---------------- LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류) def clean(df): """범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)""" out = df.copy(); fixed = 0 for c, (lo, hi) in LIMITS.items(): bad = out[c].isna() | (out[c] < lo) | (out[c] > hi) fixed += int(bad.sum()) out.loc[bad, c] = np.nan out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median() return out, fixed def env_index(env): hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식) wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식) return hi, wc def features(s, use_env=True, personal=True): """세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.""" df, _ = clean(s["df"]); p, e = s["person"], s["env"] f = pd.DataFrame({"sid": s["id"], "min": df["min"]}) f["temp5"] = df["temp"].rolling(5, min_periods=1).mean() f["hr5"] = df["hr"].rolling(5, min_periods=1).mean() f["eda5"] = df["eda"].rolling(5, min_periods=1).mean() f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0) f["act"] = df["act"] if personal: f["hr_rel"] = f["hr5"] - p["rest_hr"] f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"] if use_env: f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"] f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"] return f[f["min"] >= 5].reset_index(drop=True) def table(sessions, **kw): return pd.concat([features(s, **kw) for s in sessions], ignore_index=True) # --------------------------------------------------------------------------- CONFIG = {"alert_threshold": 0.4, "min_persist": 3, "heat_temp": 38.2, "cold_temp": 36.2} # 미션 9 재보정값 + 모델 임계값(지속 조건이 오경보를 막으므로 미션 5의 0.5보다 낮춰 미탐을 줄임) FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"] def validate(s): errs = [] if s["kind"] not in TYPES: errs.append("kind 허용값 아님") if len(s["df"]) != N_MIN or not {"hr", "temp", "eda"} <= set(s["df"].columns): errs.append("신호 길이·필드 오류") if not str(s["id"]).startswith("S"): errs.append("soldier_id 형식 오류") return errs def run_session(s, model): """세션 1개: 정제 -> 특징 -> 점수 -> 지속 조건 경보. 신호가 5분 이상 끊기면 '신호두절' 플래그.""" f = features(s) raw = s["df"].set_index("min") f["lost"] = [bool(raw.loc[max(m - 4, 0):m, ["hr", "temp"]].isna().all().all()) for m in f["min"]] f["score"] = model.predict_proba(f[FE])[:, 1] hot = (f["temp5"] >= CONFIG["heat_temp"]) | (f["temp5"] <= CONFIG["cold_temp"]) cond = (f["score"] >= CONFIG["alert_threshold"]) & (hot | (f["score"] >= 0.8)) f["alert"] = cond.rolling(CONFIG["min_persist"], min_periods=CONFIG["min_persist"]).min().fillna(0).astype(bool) & ~f["lost"] f["grade"] = np.where(f["score"] >= 0.8, "High", np.where(f["score"] >= 0.5, "Medium", "Low")) return f class Queue: def __init__(self): self.items, self.log = [], [] def submit(self, sid, minute, score, grade): prev = self.log[-1]["hash"] if self.log else "GENESIS" body = {"event": "SUBMIT", "id": f"{sid}@{minute}", "score": round(score, 3), "by": "AI"} self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()}) self.items.append({"id": f"{sid}@{minute}", "grade": grade, "status": "PENDING"}) def verify(self): prev = "GENESIS" for rec in self.log: body = {k: v for k, v in rec.items() if k not in ("prev", "hash")} if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest(): return False prev = rec["hash"] return True def episodes(f): """연속 경보 분을 하나의 사건으로 묶어 첫 분만 검토 대기열에 올린다(같은 사건 중복 통보 방지).""" a = f["alert"].values return [i for i in range(len(a)) if a[i] and (i == 0 or not a[i - 1])] t_start = time.perf_counter() # ---------------- 1. 학습(점수 엔진 준비) ---------------- tr = table(make_dataset(150, seed=51)) model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1) # ---------------- 2. 운용 데이터 처리(손상 1건, 기기 두절 1건 주입) ---------------- rng = np.random.default_rng(61) ops = [make_session(i, TYPES[i % 3], rng) for i in range(60)] ops[4] = {**ops[4], "kind": "tundra"} # 허용되지 않은 유형 dead = ops[8]["df"].copy(); dead.loc[35:, ["hr", "temp", "eda"]] = np.nan; ops[8] = {**ops[8], "df": dead} # 기기 두절 queue, rejected, runs, results = Queue(), {}, [], [] codes = {f.__code__ for f in (validate, run_session, episodes, Queue.submit, Queue.verify, clean, env_index, features, table)} lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes} trace_lines = set() def tracer(frame, event, arg): if frame.f_code in codes: def local(fr, ev, ar): if ev == "line": trace_lines.add((fr.f_code, fr.f_lineno)) return local return local sys.settrace(tracer) for s in ops: errs = validate(s) if errs: rejected[s["id"]] = errs; continue t0 = time.perf_counter() f = run_session(s, model) for i in episodes(f): queue.submit(s["id"], int(f["min"].iloc[i]), float(f["score"].iloc[i]), f["grade"].iloc[i]) runs.append({"sid": s["id"], "ms_per_min": (time.perf_counter() - t0) * 1000 / len(f), "events": len(episodes(f)), "lost": int(f["lost"].any())}) results.append(f) queue.verify() sys.settrace(None) covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes) cov = covered / sum(len(v) for v in lines_total.values()) # ---------------- 3. 성능 평가 ---------------- allf = pd.concat(results, ignore_index=True) truth = allf["stage"] >= 1 far = (allf["alert"] & ~truth).sum() / max(allf["alert"].sum(), 1) rec = (allf["alert"] & truth).sum() / truth.sum() risk_sids = set(allf.loc[truth, "sid"]); alerted = set(allf.loc[allf["alert"] & truth, "sid"]) ms = np.mean([r["ms_per_min"] for r in runs]) # ---------------- 4. 자체 점검(통합 테스트) ---------------- chk = { "손상 데이터 차단": "S004" in rejected, "처리 세션 59개 전량 성공": len(runs) == 59, "신호두절 병사 식별": any(r["lost"] for r in runs) and bool(allf.loc[allf["sid"] == "S008", "lost"].any()), "위험 구간 재현율 85% 이상": rec >= 0.85, "오경보율 15% 이하": far <= 0.15, "위험 병사 사건 식별 90% 이상": len(alerted) / max(len(risk_sids), 1) >= 0.9, "자동 조치 0건": all(i["status"] == "PENDING" for i in queue.items), "감사 로그 건수 일치·무결": len(queue.log) == len(queue.items) and queue.verify(), "분당 처리 50ms 이내": ms < 50, "실행 라인 커버리지 85% 이상": cov >= 0.85, } passed = sum(chk.values()) for k, v in chk.items(): print(f"[{'PASS' if v else 'FAIL'}] {k}") print(f"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}") # ---------------- 5. 소대 상황판 ---------------- colr = {"High": "tab:red", "Medium": "orange", "Low": "tab:gray"} fig, ax = plt.subplots(2, 2, figsize=(12, 8)) top = sorted(results, key=lambda f: -f["score"].max())[:2] for a, f in zip(ax[0], top): a.plot(f["min"], f["temp5"], label="temp (5-min mean)"); a.scatter(f["min"][f["alert"]], f["temp5"][f["alert"]], color="red", s=12, label="alert") a.set_title(f"{f['sid'].iloc[0]} temperature and alerts"); a.set_xlabel("minute"); a.legend() g = pd.Series([i["grade"] for i in queue.items]).value_counts().reindex(["High", "Medium", "Low"]).fillna(0) ax[1, 0].bar(g.index, g.values, color=[colr[i] for i in g.index]); ax[1, 0].set_title("Review queue events by grade (pending human review)") ax[1, 1].axis("off") ax[1, 1].text(0, 0.9, f"Risk recall {rec:.1%}\nFalse alarm {far:.1%}\nQueue {len(queue.items)} pending\nLatency {ms:.2f} ms/min\nSelf-check {passed}/{len(chk)}", family="monospace", fontsize=13, va="top") plt.suptitle("WEL01 integrated pipeline - dummy data, not diagnosis"); plt.tight_layout(); plt.show() # ---------------- 6. 브리핑 요약 ---------------- print(f""" [열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님] 1. 처리: 가상 병사 {len(ops)}명 중 {len(runs)}명 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 신호두절 {sum(r['lost'] for r in runs)}명 확인 필요 표시 2. 성능: 위험 구간 재현율 {rec:.1%}, 오경보율 {far:.1%}, 분당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s 3. 경보: {len(queue.items)}건 군의관·소대장 검토 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)} / Low {sum(i['grade'] == 'Low' for i in queue.items)}), 자동 조치 0건 4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다. 5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.""") ``` 실행 결과 예시 ```text [PASS] 손상 데이터 차단 [PASS] 처리 세션 59개 전량 성공 [PASS] 신호두절 병사 식별 [PASS] 위험 구간 재현율 85% 이상 [PASS] 오경보율 15% 이하 [PASS] 위험 병사 사건 식별 90% 이상 [PASS] 자동 조치 0건 [PASS] 감사 로그 건수 일치·무결 [PASS] 분당 처리 50ms 이내 [PASS] 실행 라인 커버리지 85% 이상 자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예, 핵심 모듈 실행 라인 커버리지 92.7% [열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님] 1. 처리: 가상 병사 60명 중 59명 처리, 손상 데이터 1건 차단(S004), 신호두절 1명 확인 필요 표시 2. 성능: 위험 구간 재현율 85.6%, 오경보율 8.3%, 분당 2.24 ms, 전체 소요 10.2 s 3. 경보: 40건 군의관·소대장 검토 대기 (High 24 / Medium 15 / Low 1), 자동 조치 0건 4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다. 5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다. ```