diff --git a/bitnet_tools/geo.py b/bitnet_tools/geo.py new file mode 100644 index 0000000..125e77a --- /dev/null +++ b/bitnet_tools/geo.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import math +from typing import Any + + +MISSING_OR_NON_NUMERIC = 'missing_or_non_numeric' +OUT_OF_RANGE = 'out_of_range' +DISTANCE_THRESHOLD_EXCEEDED = 'distance_threshold_exceeded' + + +def _coerce_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def validate_lat_lon(lat: Any, lon: Any) -> bool: + lat_f = _coerce_float(lat) + lon_f = _coerce_float(lon) + if lat_f is None or lon_f is None: + return False + return -90.0 <= lat_f <= 90.0 and -180.0 <= lon_f <= 180.0 + + +def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + radius_km = 6371.0 + lat1_rad = math.radians(lat1) + lon1_rad = math.radians(lon1) + lat2_rad = math.radians(lat2) + lon2_rad = math.radians(lon2) + + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2 + ) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + return radius_km * c + + +def flag_geo_suspects( + rows: list[dict[str, Any]], + lat_col: str, + lon_col: str, + threshold_km: float = 25, +) -> list[dict[str, Any]]: + flagged: list[dict[str, Any]] = [] + prev_valid_coord: tuple[float, float] | None = None + + for row in rows: + out = dict(row) + reasons: list[str] = [] + lat_raw = row.get(lat_col) + lon_raw = row.get(lon_col) + lat = _coerce_float(lat_raw) + lon = _coerce_float(lon_raw) + distance_km: float | None = None + + if lat is None or lon is None: + reasons.append(MISSING_OR_NON_NUMERIC) + elif not validate_lat_lon(lat, lon): + reasons.append(OUT_OF_RANGE) + else: + if prev_valid_coord is not None: + distance_km = haversine_km(prev_valid_coord[0], prev_valid_coord[1], lat, lon) + if distance_km >= float(threshold_km): + reasons.append(DISTANCE_THRESHOLD_EXCEEDED) + prev_valid_coord = (lat, lon) + + out['is_suspect'] = bool(reasons) + out['suspect_reason'] = '|'.join(reasons) + out['distance_km'] = round(distance_km, 3) if distance_km is not None else None + flagged.append(out) + + return flagged diff --git a/bitnet_tools/ui/app.js b/bitnet_tools/ui/app.js index c367aee..0bd97ce 100644 --- a/bitnet_tools/ui/app.js +++ b/bitnet_tools/ui/app.js @@ -45,6 +45,11 @@ const UI = { filterType: document.getElementById('filterType'), insightList: document.getElementById('insightList'), insightDrilldown: document.getElementById('insightDrilldown'), + geoLatCol: document.getElementById('geoLatCol'), + geoLonCol: document.getElementById('geoLonCol'), + geoThreshold: document.getElementById('geoThreshold'), + geoExtractBtn: document.getElementById('geoExtractBtn'), + geoResult: document.getElementById('geoResult'), }; const STATUS = { @@ -360,6 +365,7 @@ function toggleBusy(isBusy) { UI.retryChartsJobBtn, UI.switchToCsvBtn, UI.candidateTableSelect, + UI.geoExtractBtn, ...document.querySelectorAll('.mode-btn'), ...document.querySelectorAll('.chip'), ]; @@ -955,6 +961,85 @@ async function runByIntent() { setStatus('의도 라우팅 실패'); } + + +function renderGeoResult(data) { + if (!UI.geoResult) return; + const artifactLinks = Object.entries(data?.artifacts || {}) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + UI.geoResult.textContent = [ + `총 ${data?.count ?? 0}건 / 의심 ${data?.suspect_count ?? 0}건 / 정상 ${data?.normal_count ?? 0}건`, + `threshold_km=${data?.threshold_km ?? 25}`, + artifactLinks, + ].filter(Boolean).join('\n'); +} + +async function runGeoSuspectExtract() { + const file = UI.csvFile?.files?.[0] || null; + const latCol = String(UI.geoLatCol?.value || '').trim(); + const lonCol = String(UI.geoLonCol?.value || '').trim(); + const threshold = Number(UI.geoThreshold?.value || 25); + + if (!latCol || !lonCol) { + showError('위도/경도 컬럼명을 입력하세요.', 'geoLatCol/geoLonCol is empty'); + return; + } + + let payload; + if (file) { + const inputType = getInputTypeForFile(file); + if (inputType === 'excel') { + payload = { + input_type: 'excel', + source_name: file.name, + file_base64: await readFileAsBase64(file), + sheet_name: UI.sheetSelect?.value || '', + }; + } else if (inputType === 'document') { + payload = { + input_type: 'document', + source_name: file.name, + file_base64: await readFileAsBase64(file), + table_index: Number(UI.sheetSelect?.value || 0), + }; + } else { + payload = { + input_type: 'csv', + source_name: file.name, + normalized_csv_text: await file.text(), + }; + } + } else { + payload = { + input_type: 'csv', + source_name: '', + normalized_csv_text: UI.csvText?.value || '', + }; + } + + clearError(); + try { + toggleBusy(true); + setStatus('Geo 의심 케이스 추출 중...'); + const data = await postJson('/api/geo/suspects', { + ...payload, + lat_col: latCol, + lon_col: lonCol, + threshold_km: Number.isFinite(threshold) ? threshold : 25, + inline: false, + include_geojson: false, + }, 'Geo 의심 케이스 추출'); + renderGeoResult(data); + setStatus('Geo 의심 케이스 추출 완료'); + } catch (err) { + showError(err.userMessage || 'Geo 의심 케이스 추출 실패', err.detail || ''); + setStatus('Geo 의심 케이스 추출 실패'); + } finally { + toggleBusy(false); + } +} + function bindEvents() { document.querySelectorAll('.mode-btn').forEach((btn) => { btn.addEventListener('click', () => setMode(btn.dataset.mode)); @@ -1019,6 +1104,7 @@ function bindEvents() { UI.runBtn?.addEventListener('click', runModel); UI.multiAnalyzeBtn?.addEventListener('click', runMultiAnalyze); UI.startChartsJobBtn?.addEventListener('click', startChartsJob); + UI.geoExtractBtn?.addEventListener('click', runGeoSuspectExtract); UI.retryChartsJobBtn?.addEventListener('click', retryChartsJob); UI.retryPreprocessBtn?.addEventListener('click', async () => { if (!appState.preprocessJob.payload) { diff --git a/bitnet_tools/ui/index.html b/bitnet_tools/ui/index.html index 7ed2212..dce571f 100644 --- a/bitnet_tools/ui/index.html +++ b/bitnet_tools/ui/index.html @@ -83,6 +83,30 @@

3) 실행 상태

+ +
+

Geo 의심 케이스 추출

+

지도 렌더링 없이 의심 케이스 파일(CSV/JSON)을 생성합니다.

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Geo 추출 대기 중
+
+

4) 결과