feat: prediction 알고리즘 재설계 + 프론트 CRUD 권한 가드 보완

- prediction: dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계
- prediction: vessel_store/scheduler/config 개선, monitoring_zones 데이터 추가
- prediction: signal_api 신규, diagnostic-snapshot 스크립트 추가
- frontend: 지도 레이어 구조 정리 (BaseMap, useMapLayers, static layers)
- frontend: NoticeManagement CRUD 권한 가드 추가 (admin:notices C/U/D)
- frontend: EventList CRUD 권한 가드 추가 (enforcement:event-list U, enforcement:enforcement-history C)
- frontend: 지도 페이지 6개 + Dashboard 등 4개 페이지 소폭 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-13 11:01:35 +09:00
부모 73b55c2bde
커밋 2e66f920a5
26개의 변경된 파일1172개의 추가작업 그리고 416개의 파일을 삭제

파일 보기

@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
@ -74,6 +75,10 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
const canDelete = hasPermission('admin:notices', 'DELETE');
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
@ -146,7 +151,7 @@ export function NoticeManagement() {
description={t('notices.desc')}
demo
actions={
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
<Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
@ -237,10 +242,10 @@ export function NoticeManagement() {
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<Edit2 className="w-3 h-3" />
</button>
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<Trash2 className="w-3 h-3" />
</button>
</div>
@ -414,7 +419,7 @@ export function NoticeManagement() {
<SaveButton
onClick={handleSave}
label={editingId ? '수정' : '등록'}
disabled={!form.title.trim() || !form.message.trim()}
disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
/>
</div>
</div>

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint, MarkerData } from '@lib/map';
import {
AlertTriangle, Ship, Anchor, Eye, Navigation,
@ -187,7 +187,7 @@ function SeaAreaMap() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
createMarkerLayer('threat-markers', THREAT_MARKERS),
], []);

파일 보기

@ -7,7 +7,7 @@ import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
@ -170,7 +170,7 @@ export function DarkVesselDetection() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
createRadiusLayer(
'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({

파일 보기

@ -5,7 +5,7 @@ import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat';
@ -106,7 +106,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
createRadiusLayer(
'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({

파일 보기

@ -20,6 +20,7 @@ import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelInt
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { useSettingsStore } from '@stores/settingsStore';
import { useAuth } from '@/app/auth/AuthContext';
/*
* SFR-02
@ -51,6 +52,9 @@ export function EventList() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canAck = hasPermission('enforcement:event-list', 'UPDATE');
const canCreateEnforcement = hasPermission('enforcement:enforcement-history', 'CREATE');
const {
events: storeEvents,
rawEvents,
@ -167,9 +171,9 @@ export function EventList() {
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label="확인" title="확인(ACK)"
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
@ -180,14 +184,14 @@ export function EventList() {
</button>
{isActionable && (
<>
<button type="button" aria-label="단속 등록" title="단속 등록"
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label="오탐 처리" title="오탐 처리"
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
</>
@ -196,7 +200,7 @@ export function EventList() {
);
},
},
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]);
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate, canAck, canCreateEnforcement]);
const [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false);

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
@ -89,7 +89,7 @@ export function FleetOptimization() {
}));
return [
...STATIC_LAYERS,
...createStaticLayers(),
createZoneLayer('coverage', coverageZones, 30000, 0.12),
createMarkerLayer('coverage-labels', coverageLabels),
...routeLayers,

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
@ -50,7 +50,7 @@ export function PatrolRoute() {
const wps = route?.waypoints ?? [];
const buildLayers = useCallback(() => {
if (wps.length === 0) return [...STATIC_LAYERS];
if (wps.length === 0) return [...createStaticLayers()];
const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]);
const midMarkers = [];
@ -70,7 +70,7 @@ export function PatrolRoute() {
});
return [
...STATIC_LAYERS,
...createStaticLayers(),
createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }),
createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500),
createMarkerLayer('waypoint-markers', waypointMarkers),

파일 보기

@ -8,7 +8,7 @@ import { DataTable, type DataColumn } from '@shared/components/common/DataTable'
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event';
@ -87,7 +87,7 @@ export function EnforcementPlan() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
createRadiusLayer(
'ep-radius-confirmed',
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Button } from '@shared/components/ui/button';
@ -169,7 +169,7 @@ export function RiskMap() {
const buildLayers = useCallback(() => {
if (tab !== 'heatmap') return [];
return [
...STATIC_LAYERS,
...createStaticLayers(),
createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }),
];
}, [tab]);

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
@ -160,7 +160,7 @@ export function LiveMapView() {
// deck.gl 레이어
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
// 선박 분석 데이터 마커 (riskLevel 기반 색상)
createMarkerLayer(
'ais-vessels',

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -229,7 +229,7 @@ export function MapControl() {
}));
return [
...STATIC_LAYERS,
...createStaticLayers(),
createRadiusLayer(
'zone-circles-active',
activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),

파일 보기

@ -8,7 +8,7 @@ import {
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
Loader2, ShieldAlert, Shield, EyeOff, FileText,
} from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
import { BaseMap, createStaticLayers, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event';
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
@ -160,7 +160,7 @@ export function VesselDetail() {
// 지도 레이어
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
...createStaticLayers(),
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
})), 80000, 0.05),

파일 보기

@ -98,6 +98,12 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
map.on('load', () => { onMapReady?.(map); });
return () => {
// deck.gl overlay를 먼저 정리하여 WebGL 리소스 해제
if (overlayRef.current) {
try {
overlayRef.current.finalize();
} catch { /* 이미 해제된 경우 무시 */ }
}
map.remove();
mapRef.current = null;
overlayRef.current = null;

파일 보기

@ -35,7 +35,11 @@ export function useMapLayers(
handleRef.current?.overlay?.setProps({ layers: buildLayers() });
});
return () => cancelAnimationFrame(rafRef.current);
return () => {
cancelAnimationFrame(rafRef.current);
// 언마운트 시 레이어 초기화 — stale WebGL 참조 방지
try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ }
};
});
}
@ -60,6 +64,8 @@ export function useStoreLayerSync<T>(
return () => {
unsub();
cancelAnimationFrame(rafRef.current);
// 언마운트 시 레이어 초기화 — stale WebGL 참조 방지
try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ }
};
// buildLayers는 안정적 참조여야 함 (useCallback으로 감싸거나 모듈 스코프)
// eslint-disable-next-line react-hooks/exhaustive-deps

파일 보기

@ -14,6 +14,6 @@ export {
createPolylineLayer,
createHeatmapLayer,
createZoneLayer,
EEZ_LAYER, NLL_LAYER, STATIC_LAYERS,
createStaticLayers,
} from './layers';
export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers';

파일 보기

@ -3,4 +3,4 @@ export { createMarkerLayer, createRadiusLayer, type MarkerData } from './markers
export { createPolylineLayer } from './polyline';
export { createHeatmapLayer } from './heatmap';
export { createZoneLayer } from './zones';
export { EEZ_LAYER, NLL_LAYER, STATIC_LAYERS } from './static';
export { createEEZStaticLayer, createNLLStaticLayer, createStaticLayers } from './static';

파일 보기

@ -1,7 +1,10 @@
/**
*
* 1
* deck.gl은 id + data GPU
*
*
* 주의: deck.gl Layer WebGL / .
* WebGL stale
* "parameter 1 is not of type 'WebGLProgram'" .
* . deck.gl이 id diff로 GPU .
*/
import { PathLayer } from 'deck.gl';
import { EEZ_BOUNDARY, NLL_LINE, EEZ_STYLE, NLL_STYLE } from '../constants';
@ -20,29 +23,38 @@ function hexToRgba(hex: string, opacity: number): [number, number, number, numbe
const EEZ_DATA = [{ path: toPath(EEZ_BOUNDARY) }];
const NLL_DATA = [{ path: toPath(NLL_LINE) }];
/** EEZ 경계선 — 싱글턴, 리렌더/재생성 없음 */
export const EEZ_LAYER = new PathLayer({
id: 'eez-boundary',
data: EEZ_DATA,
getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity),
getWidth: EEZ_STYLE.weight * 2,
widthUnits: 'pixels' as const,
getDashArray: [6, 4],
dashJustified: true,
});
const EEZ_COLOR = hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity);
const NLL_COLOR = hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity);
/** NLL 경계선 — 싱글턴, 리렌더/재생성 없음 */
export const NLL_LAYER = new PathLayer({
id: 'nll-line',
data: NLL_DATA,
getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity),
getWidth: NLL_STYLE.weight * 2,
widthUnits: 'pixels' as const,
getDashArray: [8, 4],
dashJustified: true,
});
/** EEZ 경계선 레이어 생성 */
export function createEEZStaticLayer() {
return new PathLayer({
id: 'eez-boundary',
data: EEZ_DATA,
getPath: (d: { path: [number, number][] }) => d.path,
getColor: EEZ_COLOR,
getWidth: EEZ_STYLE.weight * 2,
widthUnits: 'pixels' as const,
getDashArray: [6, 4],
dashJustified: true,
});
}
/** 정적 기본 레이어 배열 (EEZ + NLL) */
export const STATIC_LAYERS = [EEZ_LAYER, NLL_LAYER] as const;
/** NLL 경계선 레이어 생성 */
export function createNLLStaticLayer() {
return new PathLayer({
id: 'nll-line',
data: NLL_DATA,
getPath: (d: { path: [number, number][] }) => d.path,
getColor: NLL_COLOR,
getWidth: NLL_STYLE.weight * 2,
widthUnits: 'pixels' as const,
getDashArray: [8, 4],
dashJustified: true,
});
}
/** 정적 기본 레이어 배열 (EEZ + NLL) — 매 호출마다 새 인스턴스 */
export function createStaticLayers() {
return [createEEZStaticLayer(), createNLLStaticLayer()];
}

파일 보기

@ -3,8 +3,8 @@ from typing import Callable, Optional
import pandas as pd
from algorithms.location import haversine_nm
GAP_SUSPICIOUS_SEC = 1800 # 30분
GAP_HIGH_SUSPICIOUS_SEC = 3600 # 1시간
GAP_SUSPICIOUS_SEC = 6000 # 100분 (30분 → 100분 상향: 자연 gap 과탐 감소)
GAP_HIGH_SUSPICIOUS_SEC = 10800 # 3시간
GAP_VIOLATION_SEC = 86400 # 24시간
# 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유)
@ -61,7 +61,7 @@ def is_dark_vessel(df_vessel: pd.DataFrame) -> tuple[bool, int]:
return False, 0
max_gap_min = max(g['gap_min'] for g in gaps)
is_dark = max_gap_min >= 30 # 30분 이상 소실
is_dark = max_gap_min >= (GAP_SUSPICIOUS_SEC / 60) # 상수에서 파생
return is_dark, int(max_gap_min)
@ -218,6 +218,10 @@ def compute_dark_suspicion(
history: dict,
now_kst_hour: int,
classify_zone_fn: Optional[Callable[[float, float], dict]] = None,
ship_kind_code: str = '',
nav_status: str = '',
heading: Optional[float] = None,
last_cog: Optional[float] = None,
) -> tuple[int, list[str], str]:
"""의도적 AIS OFF 의심 점수 산출.
@ -228,6 +232,10 @@ def compute_dark_suspicion(
history: {'count_7d': int, 'count_24h': int}
now_kst_hour: 현재 KST 시각 (0~23)
classify_zone_fn: (lat, lon) -> dict. gap_start 위치의 zone 판단
ship_kind_code: 선종 코드 (000020=어선, 000023=화물 )
nav_status: 항해 상태 텍스트 ("Under way using engine" )
heading: 선수 방향 (0~360, signal-batch API)
last_cog: gap 직전 침로 (0~360)
Returns:
(score, patterns, tier)
@ -314,9 +322,40 @@ def compute_dark_suspicion(
score += 10
patterns.append('long_gap')
# 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성
# P9: 선종별 가중치 (signal-batch API 데이터)
if ship_kind_code == '000020':
# 어선이면서 dark → 불법조업 의도 가능성
score += 10
patterns.append('fishing_vessel_dark')
elif ship_kind_code == '000023':
# 화물선은 원양 항해 중 자연 gap 빈번
score -= 10
patterns.append('cargo_natural_gap')
# P10: 항해 상태 기반 의도성
if nav_status:
status_lower = nav_status.lower()
if 'under way' in status_lower and gap_start_sog > 3.0:
# 항행 중 갑자기 OFF → 의도적
score += 20
patterns.append('underway_deliberate_off')
elif 'anchor' in status_lower or 'moored' in status_lower:
# 정박 중 gap → 자연스러움
score -= 15
patterns.append('anchored_natural_gap')
# P11: heading vs COG 불일치 (의도적 방향 전환)
if heading is not None and last_cog is not None:
diff = abs(heading - last_cog) % 360
if diff > 180:
diff = 360 - diff
if diff > 60:
score += 15
patterns.append('heading_cog_mismatch')
# 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 높음
if not _is_in_kr_coverage(gap_start_lat, gap_start_lon):
score -= 30
score -= 50
patterns.append('out_of_coverage')
score = max(0, min(100, score))

파일 보기

@ -1,19 +1,21 @@
"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현.
"""환적(Transshipment) 의심 선박 탐지 — 5단계 필터 파이프라인.
프론트엔드 useKoreaFilters.ts의 O() 근접 탐지를 대체한다.
scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다.
실무 목표: 일일 10 미만 고신뢰 의심 .
알고리즘 개요:
1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외
2. 그리드 기반 근접 탐지: O(n log n) 분할 + 인접 9 조회
3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 유지)
4. 60 이상 지속 근접 의심 쌍으로 판정
5단계 필터:
Stage 1: 이종 필수 (어선 운반선) shipKindCode 기반
Stage 2: 감시영역(Monitoring Zone) 선박만 대상
Stage 3: 3단계 패턴 검증 (APPROACH RENDEZVOUS DEPARTURE)
Stage 4: 점수 산출 (0~100, 50 미만 미출력)
Stage 5: 밀집 방폭 (1 운반선 : 최대 1 어선)
"""
from __future__ import annotations
import json
import logging
import math
import os
from datetime import datetime, timezone
from typing import Callable, Optional
@ -24,82 +26,150 @@ from fleet_tracker import GEAR_PATTERN
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# 상수 (2026-04-09 재조정 — 베테랑 관점)
# 상수
# ──────────────────────────────────────────────────────────────
SOG_THRESHOLD_KN = 1.0 # 2.0 → 1.0 (완전 정박 수준)
PROXIMITY_DEG = 0.0007 # 0.001 → 0.0007 (~77m, GPS 노이즈 포함한 근접)
SUSPECT_DURATION_MIN = 45 # 60 → 45 (gap tolerance 있음)
PAIR_EXPIRY_MIN = 180 # 120 → 180
GAP_TOLERANCE_CYCLES = 2 # 신규: 2 사이클까지 active에서 빠져도 리셋 안 함
SOG_THRESHOLD_KN = 2.0 # 저속 기준 (접현 가능 속도)
PROXIMITY_DEG = 0.002 # ~220m (0.0007 → 0.002 상향: 접현 가능 범위)
APPROACH_DEG = 0.01 # ~1.1km (접근 판정 거리)
RENDEZVOUS_MIN = 90 # 체류 최소 시간 (45 → 90분)
PAIR_EXPIRY_MIN = 240 # 쌍 만료 시간
GAP_TOLERANCE_CYCLES = 3 # 3 사이클(15분)까지 miss 허용
# 외국 해안 근접 제외 경계 (레거시 — 관할 필터로 대체됨)
_CN_LON_MAX = 123.5
_JP_LON_MIN = 130.5
_TSUSHIMA_LAT_MIN = 33.8
_TSUSHIMA_LON_MIN = 129.0
# 한국 EEZ 관할 수역 (단속 가능 범위)
_KR_EEZ_LAT = (32.0, 39.5)
_KR_EEZ_LON = (124.0, 132.0)
# 환적 불가능 선종 (여객/군함/유조/도선/예인/수색구조)
_TRANSSHIP_EXCLUDED: frozenset[str] = frozenset({
'passenger', 'military', 'tanker', 'pilot', 'tug', 'sar',
# 선종 분류 (shipKindCode 기반)
_FISHING_KINDS = frozenset({'000020'})
# 운반선: 화물선(000023) + 유조선(000024)만. 000027(기타)은 shipTy로 2차 판정
_CARRIER_KINDS = frozenset({'000023', '000024'})
# 환적 불가 선종 (shipTy 텍스트 기반 2차 필터)
_EXCLUDED_SHIP_TY = frozenset({
'Tug', 'Pilot Boat', 'Search And Rescue', 'Law Enforcement',
'AtoN', 'Anti Pollution', 'Passenger', 'Medical Transport',
})
# 그리드 셀 크기
_GRID_CELL_DEG = PROXIMITY_DEG
# ──────────────────────────────────────────────────────────────
# 감시영역 로드
# ──────────────────────────────────────────────────────────────
_ZONES_FILE = os.path.join(os.path.dirname(__file__), '..', 'data', 'monitoring_zones.json')
_TRANSSHIP_ZONES: list[dict] = []
def _load_monitoring_zones() -> None:
global _TRANSSHIP_ZONES
try:
with open(_ZONES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
_TRANSSHIP_ZONES = [
z for z in data.get('zones', [])
if z.get('type') == 'TRANSSHIP' and z.get('enabled')
]
logger.info('loaded %d transship monitoring zones', len(_TRANSSHIP_ZONES))
except Exception as e:
logger.warning('failed to load monitoring zones: %s', e)
_TRANSSHIP_ZONES = []
_load_monitoring_zones()
def _point_in_polygon(lat: float, lon: float, polygon: list[list[float]]) -> bool:
"""Ray-casting point-in-polygon. polygon: [[lon, lat], ...]"""
n = len(polygon)
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i][0], polygon[i][1] # lon, lat
xj, yj = polygon[j][0], polygon[j][1]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def _is_in_transship_zone(lat: float, lon: float) -> Optional[str]:
"""감시영역 내 여부. 해당 zone ID 반환, 미해당 시 None."""
for zone in _TRANSSHIP_ZONES:
if _point_in_polygon(lat, lon, zone['polygon']):
return zone['id']
return None
# ──────────────────────────────────────────────────────────────
# Stage 1: 이종 쌍 필터
# ──────────────────────────────────────────────────────────────
def _classify_vessel_role(
ship_kind_code: str,
ship_ty: str,
) -> str:
"""선박 역할 분류: 'FISHING', 'CARRIER', 'EXCLUDED', 'UNKNOWN'"""
if ship_kind_code in _FISHING_KINDS:
return 'FISHING'
if ship_kind_code in _CARRIER_KINDS:
# 화물선/유조선 — shipTy가 예인선/관공선이면 제외
if ship_ty in _EXCLUDED_SHIP_TY:
return 'EXCLUDED'
return 'CARRIER'
# 000027(기타) / 000028(미분류): shipTy 텍스트로 엄격 판정
if ship_kind_code in ('000027', '000028'):
if ship_ty == 'Cargo':
return 'CARRIER'
if ship_ty == 'Tanker':
return 'CARRIER'
if ship_ty in _EXCLUDED_SHIP_TY:
return 'EXCLUDED'
# N/A, Vessel, 기타 → UNKNOWN (환적 후보에서 제외)
return 'UNKNOWN'
# 000021(함정), 000022(여객), 000025(관공) → 제외
if ship_kind_code in ('000021', '000022', '000025'):
return 'EXCLUDED'
return 'UNKNOWN'
def _is_transship_pair(role_a: str, role_b: str) -> bool:
"""어선 + 운반선 조합만 True."""
return (role_a == 'FISHING' and role_b == 'CARRIER') or \
(role_b == 'FISHING' and role_a == 'CARRIER')
# ──────────────────────────────────────────────────────────────
# 내부 헬퍼
# ──────────────────────────────────────────────────────────────
def _is_near_foreign_coast(lat: float, lon: float) -> bool:
"""외국 해안 근처 여부 — 중국/일본/대마도 경계 확인."""
if lon < _CN_LON_MAX:
return True
if lon > _JP_LON_MIN:
return True
if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN:
return True
return False
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 3440.065
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _is_in_kr_jurisdiction(lat: float, lon: float) -> bool:
"""한국 EEZ 관할 수역 여부 (단속 가능 범위)."""
return (_KR_EEZ_LAT[0] <= lat <= _KR_EEZ_LAT[1]
and _KR_EEZ_LON[0] <= lon <= _KR_EEZ_LON[1])
def _is_candidate_ship_type(vessel_type_a: Optional[str], vessel_type_b: Optional[str]) -> bool:
"""환적 후보 선종인지 (명시적 제외만 차단, 미상은 허용)."""
a = (vessel_type_a or '').strip().lower()
b = (vessel_type_b or '').strip().lower()
if a in _TRANSSHIP_EXCLUDED or b in _TRANSSHIP_EXCLUDED:
def _within_proximity(a: dict, b: dict) -> bool:
"""PROXIMITY_DEG 이내인지 (위경도 근사)."""
dlat = abs(a['lat'] - b['lat'])
if dlat >= PROXIMITY_DEG:
return False
return True
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < PROXIMITY_DEG
def _is_gear_name(name: Optional[str]) -> bool:
"""어구 이름 패턴 매칭 — fleet_tracker.GEAR_PATTERN SSOT."""
if not name:
def _within_approach(a: dict, b: dict) -> bool:
"""APPROACH_DEG 이내인지 (~1km)."""
dlat = abs(a['lat'] - b['lat'])
if dlat >= APPROACH_DEG:
return False
return bool(GEAR_PATTERN.match(name))
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < APPROACH_DEG
def _cell_key(lat: float, lon: float) -> tuple[int, int]:
"""위도/경도를 그리드 셀 인덱스로 변환."""
return (int(math.floor(lat / _GRID_CELL_DEG)),
int(math.floor(lon / _GRID_CELL_DEG)))
return (int(math.floor(lat / APPROACH_DEG)),
int(math.floor(lon / APPROACH_DEG)))
def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
"""선박 리스트를 그리드 셀로 분류.
Returns: {(row, col): [record index, ...]}
"""
grid: dict[tuple[int, int], list[int]] = {}
for idx, rec in enumerate(records):
key = _cell_key(rec['lat'], rec['lon'])
@ -109,147 +179,130 @@ def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
return grid
def _within_proximity(a: dict, b: dict) -> bool:
"""두 선박이 PROXIMITY_DEG 이내인지 확인 (위경도 직교 근사)."""
dlat = abs(a['lat'] - b['lat'])
if dlat >= PROXIMITY_DEG:
return False
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < PROXIMITY_DEG
def _normalize_type(raw: Optional[str]) -> str:
"""선종 문자열 소문자 정규화."""
if not raw:
return ''
return raw.strip().lower()
def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]:
"""MMSI 순서를 정규화하여 중복 쌍 방지."""
return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a)
def _evict_expired_pairs(
pair_history: dict,
now: datetime,
) -> None:
"""PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거.
def _is_gear_name(name: Optional[str]) -> bool:
if not name:
return False
return bool(GEAR_PATTERN.match(name))
구조: {(a,b): {'first_seen': dt, 'last_seen': dt, 'miss_count': int}}
"""
expired = []
for key, meta in pair_history.items():
if not isinstance(meta, dict):
# 레거시 구조 (datetime 직접 저장)는 즉시 제거 → 다음 사이클에서 재구성
expired.append(key)
continue
last_seen = meta.get('last_seen') or meta.get('first_seen')
if last_seen is None:
expired.append(key)
continue
if (now - last_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN:
expired.append(key)
for key in expired:
del pair_history[key]
# ──────────────────────────────────────────────────────────────
# Stage 4: 점수 산출
# ──────────────────────────────────────────────────────────────
def _score_pair(
meta: dict,
now: datetime,
is_permitted_fn: Optional[Callable[[str], bool]],
now_kst_hour: int,
zone_id: Optional[str],
vessel_info_a: dict,
vessel_info_b: dict,
) -> Optional[dict]:
"""환적 의심 점수 산출. 50점 미만이면 None."""
phase = meta.get('phase', 'APPROACH')
pair = meta.get('pair')
if not pair:
return None
# 필수: RENDEZVOUS 90분 이상
rendezvous_start = meta.get('rendezvous_start')
if rendezvous_start is None:
return None
rendezvous_min = (now - rendezvous_start).total_seconds() / 60
if rendezvous_min < RENDEZVOUS_MIN:
return None
score = 0
# 3단계 패턴 완성도
has_approach = meta.get('approach_detected', False)
has_departure = meta.get('departure_detected', False)
if has_approach and has_departure:
score += 35
elif has_departure or has_approach:
score += 25
else:
score += 15
# 어선 식별 — pair에서 누가 어선이고 누가 운반선인지
role_a = meta.get('role_a', 'UNKNOWN')
role_b = meta.get('role_b', 'UNKNOWN')
fishing_mmsi = pair[0] if role_a == 'FISHING' else pair[1]
carrier_mmsi = pair[1] if role_a == 'FISHING' else pair[0]
# 운반선 무허가
if is_permitted_fn is not None:
try:
if not is_permitted_fn(carrier_mmsi):
score += 15
except Exception:
pass
# 야간 (20:00~04:00 KST)
if now_kst_hour >= 20 or now_kst_hour < 4:
score += 10
# 수역 가점 (감시영역 종류에 따라)
if zone_id:
if 'EEZ' in zone_id.upper() or '001' in zone_id:
score += 15
else:
score += 10
# 체류 시간 bonus
if rendezvous_min >= 180:
score += 10
elif rendezvous_min >= 120:
score += 5
# heading 병행 판정 (향후 detail API 데이터)
heading_a = vessel_info_a.get('heading')
heading_b = vessel_info_b.get('heading')
if heading_a is not None and heading_b is not None:
diff = abs(heading_a - heading_b)
if diff > 180:
diff = 360 - diff
if diff < 20: # 평행 = 접현 가능
score += 10
score = max(0, min(100, score))
if score < 50:
return None
if score >= 70:
severity = 'CRITICAL'
else:
severity = 'HIGH'
lat = meta.get('last_lat', 0)
lon = meta.get('last_lon', 0)
return {
'pair_a': pair[0],
'pair_b': pair[1],
'fishing_mmsi': fishing_mmsi,
'carrier_mmsi': carrier_mmsi,
'duration_min': int(rendezvous_min),
'severity': severity,
'score': score,
'lat': lat,
'lon': lon,
'zone_id': zone_id,
'phase': phase,
'has_approach': has_approach,
'has_departure': has_departure,
}
# ──────────────────────────────────────────────────────────────
# 공개 API
# ──────────────────────────────────────────────────────────────
def _score_pair(
pair: tuple[str, str],
meta: dict,
lat: float,
lon: float,
cog_a: Optional[float],
cog_b: Optional[float],
vessel_info_a: dict,
vessel_info_b: dict,
is_permitted_fn: Optional[Callable[[str], bool]],
now_kst_hour: int,
zone_code: Optional[str],
now: datetime,
) -> Optional[dict]:
"""환적 의심 pair에 대해 점수 산출 + severity 반환.
필수 조건 실패 None. WATCH 이상이면 dict 반환.
"""
# 필수 1: 한국 관할 수역
if not _is_in_kr_jurisdiction(lat, lon):
return None
# 필수 2: 선종 필터
if not _is_candidate_ship_type(
vessel_info_a.get('vessel_type'),
vessel_info_b.get('vessel_type'),
):
return None
# 필수 3: 어구 제외
if _is_gear_name(vessel_info_a.get('name')) or _is_gear_name(vessel_info_b.get('name')):
return None
# 필수 4: 지속 시간
first_seen = meta.get('first_seen')
if first_seen is None:
return None
duration_min = int((now - first_seen).total_seconds() / 60)
if duration_min < SUSPECT_DURATION_MIN:
return None
score = 40 # base
# 야간 가점 (KST 20:00~04:00)
if now_kst_hour >= 20 or now_kst_hour < 4:
score += 15
# 무허가 가점
if is_permitted_fn is not None:
try:
if not is_permitted_fn(pair[0]) or not is_permitted_fn(pair[1]):
score += 20
except Exception:
pass
# COG 편차 (같은 방향 아니면 가점 — 나란히 가는 선단 배제)
if cog_a is not None and cog_b is not None:
try:
diff = abs(float(cog_a) - float(cog_b))
if diff > 180:
diff = 360 - diff
if diff > 45:
score += 20
except Exception:
pass
# 지속 길이 추가 가점
if duration_min >= 90:
score += 20
# 영해/접속수역 추가 가점
if zone_code in ('TERRITORIAL_SEA', 'CONTIGUOUS_ZONE'):
score += 15
if score >= 90:
severity = 'CRITICAL'
elif score >= 70:
severity = 'HIGH'
elif score >= 50:
severity = 'WATCH'
else:
return None
return {
'pair_a': pair[0],
'pair_b': pair[1],
'duration_min': duration_min,
'severity': severity,
'score': score,
'lat': lat,
'lon': lon,
}
def detect_transshipment(
df: pd.DataFrame,
pair_history: dict,
@ -258,207 +311,226 @@ def detect_transshipment(
classify_zone_fn: Optional[Callable[[float, float], dict]] = None,
now_kst_hour: int = 0,
) -> list[dict]:
"""환적 의심 쌍 탐지 (점수 기반, 베테랑 관점 필터).
"""환적 의심 쌍 탐지 — 5단계 필터 파이프라인.
Args:
df: 선박 위치 DataFrame.
필수 컬럼: mmsi, lat, lon, sog
선택 컬럼: cog
pair_history: {(a,b): {'first_seen', 'last_seen', 'miss_count'}}
get_vessel_info: callable(mmsi) -> {'name', 'vessel_type', ...}
is_permitted: callable(mmsi) -> bool
classify_zone_fn: callable(lat, lon) -> dict (zone 판정)
df: 선박 위치 DataFrame (mmsi, lat, lon, sog 필수)
pair_history: 호출 유지되는 상태 dict
get_vessel_info: mmsi {ship_kind_code, ship_ty, name, heading, ...}
is_permitted: mmsi bool (허가 어선 여부)
classify_zone_fn: (lat, lon) {zone, ...}
now_kst_hour: 현재 KST 시각 (0~23)
Returns:
list[dict] severity 'CRITICAL'/'HIGH'/'WATCH' 포함 의심
list[dict] 의심 (score >= 50)
"""
if df.empty:
return []
required_cols = {'mmsi', 'lat', 'lon', 'sog'}
missing = required_cols - set(df.columns)
if missing:
logger.error('detect_transshipment: missing required columns: %s', missing)
if required_cols - set(df.columns):
return []
now = datetime.now(timezone.utc)
# ── 1. 후보 선박 필터 (SOG < 1.0) ─────────────────────────
candidate_mask = df['sog'] < SOG_THRESHOLD_KN
candidates = df[candidate_mask].copy()
if candidates.empty:
_evict_expired_pairs(pair_history, now)
return []
# 외국 해안 근처 제외 (1차 필터)
coast_mask = candidates.apply(
lambda row: not _is_near_foreign_coast(row['lat'], row['lon']),
axis=1,
)
candidates = candidates[coast_mask]
# ── Stage 1+2: 후보 필터 ──────────────────────────────
# SOG < 2kn 선박만
candidates = df[df['sog'] < SOG_THRESHOLD_KN].copy()
if len(candidates) < 2:
_evict_expired_pairs(pair_history, now)
_evict_expired(pair_history, now)
return []
has_cog = 'cog' in candidates.columns
cols = ['mmsi', 'lat', 'lon']
if has_cog:
cols.append('cog')
records = candidates[cols].to_dict('records')
for rec in records:
rec['mmsi'] = str(rec['mmsi'])
# 선종 + 감시영역 필터 → 유효 레코드만
records: list[dict] = []
for _, row in candidates.iterrows():
mmsi = str(row['mmsi'])
lat, lon = float(row['lat']), float(row['lon'])
# ── 2. 그리드 기반 근접 쌍 탐지 (77m) ───────────────────
grid = _build_grid(records)
# Stage 2: 감시영역 내 여부
zone_id = _is_in_transship_zone(lat, lon)
if zone_id is None:
continue
# Stage 1: 선종 분류
info = get_vessel_info(mmsi) if get_vessel_info else {}
kind = info.get('ship_kind_code', '')
ship_ty = info.get('ship_ty', info.get('vessel_type', ''))
role = _classify_vessel_role(kind, ship_ty)
if role in ('EXCLUDED', 'UNKNOWN'):
continue
# 어구 신호명 제외
if _is_gear_name(info.get('name')):
continue
rec = {
'mmsi': mmsi, 'lat': lat, 'lon': lon,
'role': role, 'zone_id': zone_id,
'cog': float(row.get('cog', 0)),
}
records.append(rec)
if len(records) < 2:
_evict_expired(pair_history, now)
return []
# 역할별 분리
fishing_recs = [r for r in records if r['role'] == 'FISHING']
carrier_recs = [r for r in records if r['role'] == 'CARRIER']
if not fishing_recs or not carrier_recs:
_evict_expired(pair_history, now)
return []
# ── 그리드 기반 근접 쌍 탐지 (어선 × 운반선만) ──────
carrier_grid = _build_grid(carrier_recs)
active_pairs: dict[tuple[str, str], dict] = {}
def _try_add_pair(a_rec, b_rec):
if not _within_proximity(a_rec, b_rec):
return
key = _pair_key(a_rec['mmsi'], b_rec['mmsi'])
# 중점 좌표 (점수 산출용)
mid_lat = (a_rec['lat'] + b_rec['lat']) / 2.0
mid_lon = (a_rec['lon'] + b_rec['lon']) / 2.0
active_pairs[key] = {
'lat': mid_lat, 'lon': mid_lon,
'cog_a': a_rec.get('cog'), 'cog_b': b_rec.get('cog'),
# mmsi_a < mmsi_b 순서로 정렬되었으므로 cog도 맞춰 정렬 필요
'mmsi_a': a_rec['mmsi'], 'mmsi_b': b_rec['mmsi'],
}
for f_rec in fishing_recs:
f_cell = _cell_key(f_rec['lat'], f_rec['lon'])
# 인접 9셀 탐색
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
neighbor = (f_cell[0] + dr, f_cell[1] + dc)
if neighbor not in carrier_grid:
continue
for ci in carrier_grid[neighbor]:
c_rec = carrier_recs[ci]
is_close = _within_proximity(f_rec, c_rec)
is_approaching = _within_approach(f_rec, c_rec) if not is_close else False
for (row, col), indices in grid.items():
for i in range(len(indices)):
for j in range(i + 1, len(indices)):
_try_add_pair(records[indices[i]], records[indices[j]])
for dr, dc in ((0, 1), (1, -1), (1, 0), (1, 1)):
neighbor_key = (row + dr, col + dc)
if neighbor_key not in grid:
continue
for ai in indices:
for bi in grid[neighbor_key]:
_try_add_pair(records[ai], records[bi])
if not is_close and not is_approaching:
continue
# ── 3. pair_history 갱신 (gap tolerance) ─────────────────
active_keys = set(active_pairs.keys())
key = _pair_key(f_rec['mmsi'], c_rec['mmsi'])
mid_lat = (f_rec['lat'] + c_rec['lat']) / 2
mid_lon = (f_rec['lon'] + c_rec['lon']) / 2
# 활성 쌍 → 등록/갱신
for pair in active_keys:
if pair not in pair_history or not isinstance(pair_history[pair], dict):
pair_history[pair] = {
'first_seen': now,
'last_seen': now,
active_pairs[key] = {
'is_close': is_close,
'is_approaching': is_approaching,
'lat': mid_lat, 'lon': mid_lon,
'zone_id': f_rec['zone_id'],
'role_a': 'FISHING' if key[0] == f_rec['mmsi'] else 'CARRIER',
'role_b': 'CARRIER' if key[0] == f_rec['mmsi'] else 'FISHING',
}
# ── Stage 3: pair_history 상태머신 갱신 ─────────────
for key, loc in active_pairs.items():
meta = pair_history.get(key)
if meta is None or not isinstance(meta, dict):
# 신규 쌍
meta = {
'pair': key,
'phase': 'APPROACH' if loc['is_approaching'] else 'RENDEZVOUS',
'approach_detected': loc['is_approaching'],
'rendezvous_start': now if loc['is_close'] else None,
'departure_detected': False,
'miss_count': 0,
'last_lat': loc['lat'],
'last_lon': loc['lon'],
'zone_id': loc['zone_id'],
'role_a': loc['role_a'],
'role_b': loc['role_b'],
}
else:
pair_history[pair]['last_seen'] = now
pair_history[pair]['miss_count'] = 0
pair_history[key] = meta
continue
# 비활성 쌍 → miss_count++ , GAP_TOLERANCE 초과 시 삭제
meta['miss_count'] = 0
meta['last_lat'] = loc['lat']
meta['last_lon'] = loc['lon']
if loc['is_close']:
if meta['phase'] == 'APPROACH':
# 접근 → 체류 전환
meta['phase'] = 'RENDEZVOUS'
meta['approach_detected'] = True
meta['rendezvous_start'] = meta.get('rendezvous_start') or now
elif meta['phase'] == 'DEPARTURE':
# 분리 후 재접근 → 체류 재개
meta['phase'] = 'RENDEZVOUS'
# RENDEZVOUS 상태 유지
if meta.get('rendezvous_start') is None:
meta['rendezvous_start'] = now
elif loc['is_approaching']:
if meta['phase'] == 'RENDEZVOUS':
# 체류 중 이격 시작 → 분리 단계
meta['phase'] = 'DEPARTURE'
meta['departure_detected'] = True
elif meta['phase'] == 'APPROACH':
meta['approach_detected'] = True
# 비활성 쌍 miss_count++
for key in list(pair_history.keys()):
if key in active_keys:
if key in active_pairs:
continue
meta = pair_history[key]
if not isinstance(meta, dict):
del pair_history[key]
continue
meta['miss_count'] = meta.get('miss_count', 0) + 1
# 체류 중이던 쌍이 miss → 분리 가능
if meta.get('phase') == 'RENDEZVOUS' and meta['miss_count'] >= 2:
meta['phase'] = 'DEPARTURE'
meta['departure_detected'] = True
if meta['miss_count'] > GAP_TOLERANCE_CYCLES:
del pair_history[key]
# 만료 정리
_evict_expired_pairs(pair_history, now)
_evict_expired(pair_history, now)
# ── 4. 점수 기반 의심 쌍 판정 ─────────────────────────────
suspects: list[dict] = []
rejected_jurisdiction = 0
rejected_ship_type = 0
rejected_gear = 0
rejected_duration = 0
for pair, meta in pair_history.items():
# ── Stage 4+5: 점수 산출 + 밀집 방폭 ────────────────
raw_suspects: list[dict] = []
for key, meta in pair_history.items():
if not isinstance(meta, dict):
continue
first_seen = meta.get('first_seen')
if first_seen is None:
continue
# active_pairs에 있으면 해당 사이클 좌표·cog 사용, 없으면 이전 값 재사용 (miss 중)
loc_meta = active_pairs.get(pair)
if loc_meta is not None:
lat = loc_meta['lat']
lon = loc_meta['lon']
# mmsi_a, mmsi_b 순서를 pair 순서에 맞춤
if loc_meta['mmsi_a'] == pair[0]:
cog_a, cog_b = loc_meta.get('cog_a'), loc_meta.get('cog_b')
else:
cog_a, cog_b = loc_meta.get('cog_b'), loc_meta.get('cog_a')
meta['last_lat'] = lat
meta['last_lon'] = lon
meta['last_cog_a'] = cog_a
meta['last_cog_b'] = cog_b
else:
lat = meta.get('last_lat')
lon = meta.get('last_lon')
cog_a = meta.get('last_cog_a')
cog_b = meta.get('last_cog_b')
if lat is None or lon is None:
continue
# 선박 정보 조회
info_a = get_vessel_info(pair[0]) if get_vessel_info else {}
info_b = get_vessel_info(pair[1]) if get_vessel_info else {}
# 짧게 pre-check (로깅용)
if not _is_in_kr_jurisdiction(lat, lon):
rejected_jurisdiction += 1
continue
if not _is_candidate_ship_type(info_a.get('vessel_type'), info_b.get('vessel_type')):
rejected_ship_type += 1
continue
if _is_gear_name(info_a.get('name')) or _is_gear_name(info_b.get('name')):
rejected_gear += 1
continue
duration_min = int((now - first_seen).total_seconds() / 60)
if duration_min < SUSPECT_DURATION_MIN:
rejected_duration += 1
continue
zone_code = None
if classify_zone_fn is not None:
try:
zone_code = classify_zone_fn(lat, lon).get('zone')
except Exception:
pass
info_a = get_vessel_info(key[0]) if get_vessel_info else {}
info_b = get_vessel_info(key[1]) if get_vessel_info else {}
scored = _score_pair(
pair, meta, lat, lon, cog_a, cog_b,
info_a, info_b, is_permitted,
now_kst_hour, zone_code, now,
meta, now, is_permitted, now_kst_hour,
meta.get('zone_id'), info_a, info_b,
)
if scored is not None:
suspects.append(scored)
raw_suspects.append(scored)
tier_counts = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0}
# Stage 5: 밀집 방폭 — 1 운반선 : 최대 1 어선 (최고 점수)
carrier_best: dict[str, dict] = {}
for s in raw_suspects:
carrier = s['carrier_mmsi']
if carrier not in carrier_best or s['score'] > carrier_best[carrier]['score']:
carrier_best[carrier] = s
suspects = list(carrier_best.values())
# 로그
tier_counts = {'CRITICAL': 0, 'HIGH': 0}
for s in suspects:
tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1
logger.info(
'transshipment detection: pairs=%d (critical=%d, high=%d, watch=%d, '
'rejected_jurisdiction=%d, rejected_ship_type=%d, rejected_gear=%d, '
'rejected_duration=%d, candidates=%d)',
len(suspects),
tier_counts.get('CRITICAL', 0),
tier_counts.get('HIGH', 0),
tier_counts.get('WATCH', 0),
rejected_jurisdiction,
rejected_ship_type,
rejected_gear,
rejected_duration,
len(candidates),
'transshipment: pairs=%d (critical=%d, high=%d), '
'candidates: fishing=%d carrier=%d, active_pairs=%d, history=%d',
len(suspects), tier_counts.get('CRITICAL', 0), tier_counts.get('HIGH', 0),
len(fishing_recs), len(carrier_recs), len(active_pairs), len(pair_history),
)
return suspects
def _evict_expired(pair_history: dict, now: datetime) -> None:
"""PAIR_EXPIRY_MIN 이상 갱신 없는 쌍 제거."""
expired = []
for key, meta in pair_history.items():
if not isinstance(meta, dict):
expired.append(key)
continue
miss = meta.get('miss_count', 0)
if miss > GAP_TOLERANCE_CYCLES:
expired.append(key)
for key in expired:
del pair_history[key]

파일 보기

@ -254,7 +254,12 @@ class VesselStore:
mmsi_list = list(self._tracks.keys())
try:
info = snpdb.fetch_static_info(mmsi_list)
self._static_info.update(info)
# SNPDB 필드(name, vessel_type, length, width)만 갱신하되
# signal-batch에서 추가한 필드(ship_kind_code, status, heading 등)는 보존
for mmsi, snpdb_info in info.items():
existing = self._static_info.get(mmsi, {})
existing.update(snpdb_info) # SNPDB 필드 덮어쓰기 (signal-batch 필드는 유지)
self._static_info[mmsi] = existing
self._static_refreshed_at = now
logger.info('static info refreshed: %d vessels', len(info))
except Exception as e:
@ -285,6 +290,45 @@ class VesselStore:
except Exception as e:
logger.error('fetch_permit_mmsis failed: %s', e)
# ------------------------------------------------------------------
# Signal-batch API 정적정보 보강
# ------------------------------------------------------------------
def enrich_from_signal_api(self, minutes: int = 10) -> None:
"""signal-batch recent-positions-detail API에서 정적정보 보강.
shipKindCode, status, heading, draught SNPDB에 없는 필드를 수집하여
_static_info에 merge. 기존 SNPDB 정적정보(name, vessel_type ) 유지.
Args:
minutes: API 조회 시간 범위 (기본 10, 초기 로드 120)
"""
from db.signal_api import signal_client, _KR_WATERS_POLYGON
try:
raw = signal_client.fetch_recent_detail(
minutes=minutes,
polygon=_KR_WATERS_POLYGON,
)
if not raw:
return
parsed = signal_client.parse_static_info(raw)
enriched = 0
for mmsi, new_info in parsed.items():
existing = self._static_info.get(mmsi, {})
# 신규 필드만 merge (기존 값 덮어쓰기)
existing.update(new_info)
self._static_info[mmsi] = existing
enriched += 1
logger.info(
'signal-batch enrich: %d vessels enriched (minutes=%d, api_total=%d)',
enriched, minutes, len(raw),
)
except Exception as e:
logger.warning('enrich_from_signal_api failed: %s', e)
# ------------------------------------------------------------------
# Analysis target selection
# ------------------------------------------------------------------

파일 보기

@ -36,6 +36,10 @@ class Settings(BaseSettings):
MMSI_PREFIX: str = '412'
MIN_TRAJ_POINTS: int = 100
# signal-batch API (gc-signal-batch — 정적정보 보강용)
SIGNAL_BATCH_URL: str = 'http://192.168.1.18:18090/signal-batch'
SIGNAL_BATCH_TIMEOUT: int = 60
# Ollama (LLM)
OLLAMA_BASE_URL: str = 'http://localhost:11434'
OLLAMA_MODEL: str = 'qwen3:14b' # CPU-only: 14b 권장, GPU 있으면 32b

파일 보기

@ -0,0 +1,45 @@
{
"description": "해양 감시영역 정의 — 환적/불법조업 탐지 범위. 향후 DB 테이블로 이관 + 프론트엔드 운영자 편집 가능.",
"zones": [
{
"id": "TS-001",
"name": "서해 EEZ 경계 (환적 고위험)",
"type": "TRANSSHIP",
"description": "서해 EEZ 외측 ~ 중국 근해 접경. 중국 어선-운반선 환적 주요 발생 해역.",
"polygon": [[124.0, 33.0], [125.8, 33.0], [125.8, 37.5], [124.0, 37.5], [124.0, 33.0]],
"enabled": true
},
{
"id": "TS-002",
"name": "남해 외해 (환적 관심)",
"type": "TRANSSHIP",
"description": "제주 남방 ~ 동중국해 접경. 원양 운반선 환적 경유 해역.",
"polygon": [[125.0, 31.5], [130.0, 31.5], [130.0, 33.5], [125.0, 33.5], [125.0, 31.5]],
"enabled": true
},
{
"id": "TS-003",
"name": "동해 북방 (환적 관심)",
"type": "TRANSSHIP",
"description": "동해 중부 ~ 일본해 경계. 오징어잡이 환적 발생 해역.",
"polygon": [[129.0, 36.0], [131.5, 36.0], [131.5, 38.5], [129.0, 38.5], [129.0, 36.0]],
"enabled": true
},
{
"id": "FZ-001",
"name": "서해 특정어업수역",
"type": "FISHING",
"description": "한중어업협정 특정어업수역. 불법조업 탐지 대상.",
"polygon": [[124.0, 34.5], [126.0, 34.5], [126.0, 37.0], [124.5, 37.0], [124.0, 36.0], [124.0, 34.5]],
"enabled": true
},
{
"id": "FZ-002",
"name": "제주 남방 수역",
"type": "FISHING",
"description": "제주 남방 EEZ 내 불법조업 빈발 해역.",
"polygon": [[125.5, 32.0], [127.5, 32.0], [127.5, 33.2], [125.5, 33.2], [125.5, 32.0]],
"enabled": true
}
]
}

174
prediction/db/signal_api.py Normal file
파일 보기

@ -0,0 +1,174 @@
"""signal-batch API 클라이언트 — gc-signal-batch(192.168.1.18:18090) HTTP 연동.
데이터 소스: S&P Global AIS API gc-signal-batch L0 캐시(Caffeine, 120 TTL)
용도: 정적정보 보강 (shipKindCode, status, heading, draught )
궤적 소스는 SNPDB 유지 모듈은 정적정보 전용.
호출 전략:
- 초기 로드: minutes=120 (L0 캐시 전체, 412* ~3,150)
- 5 주기 보강: minutes=10 (직전 2사이클분, 412* ~728)
- gc-signal-batch 수집 주기: :45 호출 시점 :50 이후 권장
"""
import logging
from typing import Optional
import httpx
from config import settings
logger = logging.getLogger(__name__)
_KR_WATERS_POLYGON = [[122, 31], [132, 31], [132, 39], [122, 39], [122, 31]]
class SignalBatchClient:
def __init__(
self,
base_url: str = '',
timeout_sec: int = 60,
) -> None:
self._base_url = (base_url or settings.SIGNAL_BATCH_URL).rstrip('/')
self._timeout = timeout_sec
def fetch_recent_detail(
self,
minutes: int = 10,
polygon: Optional[list[list[float]]] = None,
) -> list[dict]:
"""POST /api/v1/vessels/recent-positions-detail
L0 캐시(AisTargetCacheManager)에서 직접 조회.
공간 필터(폴리곤) 적용 가능.
Returns:
list[dict] RecentPositionDetailResponse 배열
dict 필드:
mmsi, imo, lon, lat, sog, cog, heading,
shipNm, shipTy(텍스트 선종), shipKindCode(6자리 코드),
nationalCode(MID 숫자), status(항해상태 텍스트),
destination, eta, draught, length, width, lastUpdate
"""
url = f'{self._base_url}/api/v1/vessels/recent-positions-detail'
body: dict = {'minutes': minutes}
if polygon:
body['coordinates'] = polygon
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
logger.info(
'fetch_recent_detail: %d vessels (minutes=%d)',
len(data), minutes,
)
return data
except httpx.TimeoutException:
logger.warning('fetch_recent_detail timeout (%ds)', self._timeout)
return []
except httpx.HTTPStatusError as e:
logger.warning('fetch_recent_detail HTTP %d: %s', e.response.status_code, e)
return []
except Exception as e:
logger.error('fetch_recent_detail failed: %s', e)
return []
def fetch_area_tracks(
self,
start_time: str,
end_time: str,
polygon: Optional[list[list[float]]] = None,
) -> dict:
"""POST /api/v2/tracks/area-search
D-1~D-7 인메모리 캐시 기반 항적 조회.
503 캐시 미준비 상태.
Returns:
dict AreaSearchResponse {tracks, hitDetails, summary}
"""
url = f'{self._base_url}/api/v2/tracks/area-search'
body = {
'startTime': start_time,
'endTime': end_time,
'polygons': [{
'id': 'kr-waters',
'name': '한국 해역',
'coordinates': polygon or _KR_WATERS_POLYGON,
}],
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
tracks = data.get('tracks', [])
logger.info(
'fetch_area_tracks: %d tracks (%s ~ %s)',
len(tracks), start_time, end_time,
)
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 503:
logger.warning('area-search: cache not ready (503)')
else:
logger.warning('area-search HTTP %d', e.response.status_code)
return {}
except Exception as e:
logger.error('fetch_area_tracks failed: %s', e)
return {}
@staticmethod
def parse_static_info(detail_response: list[dict]) -> dict[str, dict]:
"""recent-positions-detail 응답에서 정적정보 dict 추출.
Returns:
{mmsi: {ship_kind_code, ship_ty, national_code, status,
heading, draught, destination, name, length, width}}
"""
result: dict[str, dict] = {}
for item in detail_response:
mmsi = str(item.get('mmsi', ''))
if not mmsi:
continue
info: dict = {}
# 선종 (6자리 코드: 000020=어선, 000023=화물, 000028=미분류)
if item.get('shipKindCode'):
info['ship_kind_code'] = item['shipKindCode']
# 텍스트 선종 ("Cargo", "Tanker", "Vessel", "N/A")
if item.get('shipTy'):
info['ship_ty'] = item['shipTy']
# 국적 MID ("412"=중국, "440"=한국)
if item.get('nationalCode'):
info['national_code'] = item['nationalCode']
# 항해 상태 ("Under way using engine", "Anchored", "N/A")
if item.get('status') and item['status'] != 'N/A':
info['status'] = item['status']
# 선수 방향
if item.get('heading') is not None:
info['heading'] = float(item['heading'])
# 흘수
if item.get('draught') is not None and item['draught'] > 0:
info['draught'] = float(item['draught'])
# 목적지
if item.get('destination'):
info['destination'] = item['destination']
# 기본 정보 (기존 SNPDB 정적정보와 동일 필드)
if item.get('shipNm'):
info['name'] = item['shipNm']
if item.get('length') and item['length'] > 0:
info['length'] = int(item['length'])
if item.get('width') and item['width'] > 0:
info['width'] = int(item['width'])
if info:
result[mmsi] = info
return result
# 모듈 레벨 싱글턴
signal_client = SignalBatchClient()

파일 보기

@ -31,6 +31,10 @@ async def lifespan(application: FastAPI):
vessel_store.load_initial(settings.INITIAL_LOAD_HOURS)
logger.info('initial load complete: %s', vessel_store.stats())
# signal-batch API에서 정적정보 초기 보강 (120분 범위, 최대 커버리지)
vessel_store.enrich_from_signal_api(minutes=120)
logger.info('signal-batch enrich complete')
start_scheduler()
yield
stop_scheduler()

파일 보기

@ -52,11 +52,12 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]:
cur.execute(
"""
SELECT mmsi,
count(*) AS n7,
count(*) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24,
count(DISTINCT analyzed_at::date) AS n7,
count(DISTINCT analyzed_at::date) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24,
max(analyzed_at) AS last_at
FROM kcg.vessel_analysis_results
WHERE is_dark = true
AND gap_duration_min >= 100
AND analyzed_at > now() - interval '7 days'
AND mmsi = ANY(%s)
GROUP BY mmsi
@ -107,6 +108,8 @@ def run_analysis_cycle():
# 정적정보 / 허가어선 주기적 갱신
vessel_store.refresh_static_info()
vessel_store.refresh_permit_registry()
# signal-batch API 정적정보 보강 (shipKindCode, status, heading, draught 등)
vessel_store.enrich_from_signal_api(minutes=10)
# 2. 분석 대상 선별 (SOG/COG 계산 포함)
df_targets = vessel_store.select_analysis_targets()
@ -247,9 +250,15 @@ def run_analysis_cycle():
dark = bool(gap_info.get('is_dark'))
gap_min = int(gap_info.get('gap_min') or 0)
history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0})
v_info = vessel_store.get_vessel_info(mmsi)
last_cog_val = float(df_v.iloc[-1].get('cog', 0)) if len(df_v) > 0 else None
score, patterns, tier = compute_dark_suspicion(
gap_info, mmsi, is_permitted, history,
now_kst_hour, classify_zone,
ship_kind_code=v_info.get('ship_kind_code', ''),
nav_status=v_info.get('status', ''),
heading=v_info.get('heading'),
last_cog=last_cog_val,
)
pipeline_dark_tiers[tier] = pipeline_dark_tiers.get(tier, 0) + 1
dark_features = {
@ -385,9 +394,15 @@ def run_analysis_cycle():
gap_min = int(gap_info.get('gap_min') or 0)
history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0})
lw_info = vessel_store.get_vessel_info(mmsi)
lw_last_cog = float(df_v.iloc[-1].get('cog', 0)) if df_v is not None and len(df_v) > 0 else None
score, patterns, tier = compute_dark_suspicion(
gap_info, mmsi, is_permitted, history,
now_kst_hour, classify_zone,
ship_kind_code=lw_info.get('ship_kind_code', ''),
nav_status=lw_info.get('status', ''),
heading=lw_info.get('heading'),
last_cog=lw_last_cog,
)
lw_dark_tiers[tier] = lw_dark_tiers.get(tier, 0) + 1

파일 보기

@ -0,0 +1,326 @@
#!/bin/bash
# prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행)
#
# 용도: 알고리즘 재설계 후 동작 검증용. 단순 집계가 아닌 개별 판정 과정 추적.
# 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh &
# 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid)
# 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt
set -u
OUTDIR=/home/apps/kcg-ai-prediction/data/diag
mkdir -p "$OUTDIR"
echo $$ > "$OUTDIR/diag.pid"
export PGPASSWORD=Kcg2026ai
PSQL="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off -x"
PSQL_TABLE="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off"
INTERVAL_SEC=300 # 5분
while true; do
STAMP=$(date '+%Y%m%d-%H%M')
OUT="$OUTDIR/$STAMP.txt"
{
echo "###################################################################"
echo "# PREDICTION DIAGNOSTIC SNAPSHOT"
echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "# host: $(hostname)"
echo "# interval: ${INTERVAL_SEC}s"
echo "###################################################################"
#===================================================================
# PART 1: 종합 지표
#===================================================================
echo ""
echo "================================================================="
echo "PART 1: 종합 지표 (last 5min)"
echo "================================================================="
$PSQL_TABLE << 'SQL'
SELECT count(*) total,
count(*) FILTER (WHERE vessel_type != 'UNKNOWN') pipeline,
count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE transship_suspect) transship,
count(*) FILTER (WHERE risk_level='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk,
max(risk_score) max_risk,
round(count(*) FILTER (WHERE is_dark)::numeric / NULLIF(count(*), 0) * 100, 1) AS dark_pct
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes';
SQL
#===================================================================
# PART 2: 다크베셀 심층 진단
#===================================================================
echo ""
echo "================================================================="
echo "PART 2: DARK VESSEL 심층 진단"
echo "================================================================="
echo ""
echo "--- 2-1. dark_suspicion_score 히스토그램 ---"
$PSQL_TABLE << 'SQL'
SELECT CASE
WHEN (features->>'dark_suspicion_score')::int >= 90 THEN 'a_90-100 (CRITICAL_HIGH)'
WHEN (features->>'dark_suspicion_score')::int >= 70 THEN 'b_70-89 (CRITICAL)'
WHEN (features->>'dark_suspicion_score')::int >= 50 THEN 'c_50-69 (HIGH)'
WHEN (features->>'dark_suspicion_score')::int >= 30 THEN 'd_30-49 (WATCH)'
WHEN (features->>'dark_suspicion_score')::int >= 1 THEN 'e_1-29 (NONE_SCORED)'
ELSE 'f_0 (NOT_DARK)'
END bucket,
count(*) cnt,
round(avg(gap_duration_min)::numeric, 0) avg_gap_min,
round(avg(risk_score)::numeric, 1) avg_risk
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY bucket ORDER BY bucket;
SQL
echo ""
echo "--- 2-2. dark_patterns 발동 빈도 (어떤 규칙이 얼마나 적용되는지) ---"
$PSQL_TABLE << 'SQL'
SELECT pattern,
count(*) cnt,
round(count(*)::numeric / NULLIF((SELECT count(*) FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' AND is_dark), 0) * 100, 1) AS pct
FROM kcg.vessel_analysis_results,
LATERAL jsonb_array_elements_text(features->'dark_patterns') AS pattern
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY pattern ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-3. P9 선종별 dark 분포 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%fishing_vessel_dark%' THEN 'FISHING(+10)'
WHEN features->>'dark_patterns' LIKE '%cargo_natural_gap%' THEN 'CARGO(-10)'
ELSE 'NO_KIND_EFFECT' END AS p9_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score,
round(avg(gap_duration_min)::numeric, 0) avg_gap
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p9_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-4. P10 항해상태 dark 분포 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%underway_deliberate_off%' THEN 'UNDERWAY_OFF(+20)'
WHEN features->>'dark_patterns' LIKE '%anchored_natural_gap%' THEN 'ANCHORED(-15)'
ELSE 'NO_STATUS_EFFECT' END AS p10_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p10_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-5. P11 heading/COG 불일치 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%heading_cog_mismatch%' THEN 'MISMATCH(+15)'
ELSE 'NO_MISMATCH' END AS p11_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p11_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-6. GAP 구간별 dark_tier 교차표 (임계값 100분 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT CASE
WHEN gap_duration_min < 100 THEN 'a_lt100 (NOT_DARK 예상)'
WHEN gap_duration_min < 180 THEN 'b_100-179'
WHEN gap_duration_min < 360 THEN 'c_180-359'
WHEN gap_duration_min < 720 THEN 'd_360-719'
ELSE 'e_gte720' END gap_bucket,
count(*) total,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE features->>'dark_tier' = 'CRITICAL') crit,
count(*) FILTER (WHERE features->>'dark_tier' = 'HIGH') high,
count(*) FILTER (WHERE features->>'dark_tier' = 'WATCH') watch,
count(*) FILTER (WHERE features->>'dark_tier' = 'NONE') tier_none
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
GROUP BY gap_bucket ORDER BY gap_bucket;
SQL
echo ""
echo "--- 2-7. CRITICAL dark 상위 10건 (개별 판정 상세) ---"
$PSQL_TABLE << 'SQL'
SELECT mmsi, gap_duration_min, zone_code, activity_state,
(features->>'dark_suspicion_score')::int AS score,
features->>'dark_tier' AS tier,
features->>'dark_patterns' AS patterns,
risk_score, risk_level
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND features->>'dark_tier' = 'CRITICAL'
ORDER BY (features->>'dark_suspicion_score')::int DESC
LIMIT 10;
SQL
#===================================================================
# PART 3: 환적 탐지 심층 진단
#===================================================================
echo ""
echo "================================================================="
echo "PART 3: TRANSSHIPMENT 심층 진단"
echo "================================================================="
echo ""
echo "--- 3-1. 환적 의심 건수 + 점수 분포 ---"
$PSQL_TABLE << 'SQL'
SELECT count(*) total_suspects,
count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 70) critical,
count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50
AND (features->>'transship_score')::numeric < 70) high,
round(avg((features->>'transship_score')::numeric)::numeric, 1) avg_score,
round(avg(transship_duration_min)::numeric, 0) avg_duration_min
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND transship_suspect = true;
SQL
echo ""
echo "--- 3-2. 환적 의심 개별 건 상세 (전체) ---"
$PSQL_TABLE << 'SQL'
SELECT mmsi, transship_pair_mmsi AS pair_mmsi,
transship_duration_min AS dur_min,
(features->>'transship_score')::numeric AS score,
features->>'transship_tier' AS tier,
zone_code,
activity_state,
risk_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND transship_suspect = true
ORDER BY (features->>'transship_score')::numeric DESC;
SQL
echo ""
echo "--- 3-3. 환적 후보 선종 분포 (Stage 1 이종 쌍 검증) ---"
echo " (이 쿼리는 journalctl 로그에서 추출)"
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -o 'transshipment:.*' | tail -1
#===================================================================
# PART 4: 이벤트 + KPI
#===================================================================
echo ""
echo "================================================================="
echo "PART 4: 이벤트 + KPI (시스템 출력 검증)"
echo "================================================================="
echo ""
echo "--- 4-1. prediction_events (last 5min) ---"
$PSQL_TABLE << 'SQL'
SELECT category, level, count(*) cnt
FROM kcg.prediction_events
WHERE created_at > now() - interval '5 minutes'
GROUP BY category, level ORDER BY cnt DESC;
SQL
echo ""
echo "--- 4-2. KPI 실시간 ---"
$PSQL_TABLE << 'SQL'
SELECT kpi_key, value, trend, delta_pct, updated_at
FROM kcg.prediction_kpi_realtime ORDER BY kpi_key;
SQL
#===================================================================
# PART 5: signal-batch 정적정보 보강 검증
#===================================================================
echo ""
echo "================================================================="
echo "PART 5: signal-batch 정적정보 보강 검증"
echo "================================================================="
echo ""
echo "--- 5-1. 직전 사이클 enrich 로그 ---"
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -E 'signal-batch enrich|fetch_recent_detail' | tail -2
echo ""
echo "--- 5-2. features 내 신규 패턴(P9/P10/P11) 적용 비율 ---"
$PSQL_TABLE << 'SQL'
WITH dark_vessels AS (
SELECT features FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true
)
SELECT
count(*) AS total_dark,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%'
OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') AS p9_applied,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%'
OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') AS p10_applied,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') AS p11_applied,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%'
OR features->>'dark_patterns' LIKE '%cargo_natural_gap%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p9_pct,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%'
OR features->>'dark_patterns' LIKE '%anchored_natural_gap%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p10_pct,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p11_pct
FROM dark_vessels;
SQL
#===================================================================
# PART 6: 사이클 로그 (직전 6분)
#===================================================================
echo ""
echo "================================================================="
echo "PART 6: 사이클 로그 (최근 6분)"
echo "================================================================="
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -E 'analysis cycle:|lightweight analysis:|pipeline dark:|event_generator:|kpi_writer:|stats_aggregator|enrich|transship|ERROR|Traceback' | \
tail -20
#===================================================================
# PART 7: 해역별 + 위험도 교차 (운영 지표)
#===================================================================
echo ""
echo "================================================================="
echo "PART 7: 해역별 × 위험도 교차표"
echo "================================================================="
$PSQL_TABLE << 'SQL'
SELECT zone_code,
count(*) total,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE risk_level='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk,
count(*) FILTER (WHERE transship_suspect) transship
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
GROUP BY zone_code ORDER BY total DESC;
SQL
echo ""
echo "================================================================="
echo "END OF SNAPSHOT $STAMP"
echo "================================================================="
} > "$OUT" 2>&1
echo "[diag] $(date '+%H:%M:%S') saved: $OUT ($(wc -l < "$OUT") lines)"
sleep $INTERVAL_SEC
done