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

This commit is contained in:
htlee 2026-04-13 11:08:11 +09:00
부모 73b55c2bde
커밋 45371315ba
26개의 변경된 파일1172개의 추가작업 그리고 416개의 파일을 삭제

파일 보기

@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants'; import type { BadgeIntent } from '@lib/theme/variants';
import { import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar, Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
@ -74,6 +75,10 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() { export function NoticeManagement() {
const { t } = useTranslation('admin'); 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 [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -146,7 +151,7 @@ export function NoticeManagement() {
description={t('notices.desc')} description={t('notices.desc')}
demo demo
actions={ 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> </Button>
} }
@ -237,10 +242,10 @@ export function NoticeManagement() {
</td> </td>
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.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" /> <Edit2 className="w-3 h-3" />
</button> </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" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
@ -414,7 +419,7 @@ export function NoticeManagement() {
<SaveButton <SaveButton
onClick={handleSave} onClick={handleSave}
label={editingId ? '수정' : '등록'} label={editingId ? '수정' : '등록'}
disabled={!form.title.trim() || !form.message.trim()} disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
/> />
</div> </div>
</div> </div>

파일 보기

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

파일 보기

@ -7,7 +7,7 @@ import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; 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 type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
@ -170,7 +170,7 @@ export function DarkVesselDetection() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'dv-radius', 'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({ 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; 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 type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat'; import { formatDate } from '@shared/utils/dateFormat';
@ -106,7 +106,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'gear-radius', 'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({ 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 { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useAuth } from '@/app/auth/AuthContext';
/* /*
* SFR-02 * SFR-02
@ -51,6 +52,9 @@ export function EventList() {
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth();
const canAck = hasPermission('enforcement:event-list', 'UPDATE');
const canCreateEnforcement = hasPermission('enforcement:enforcement-history', 'CREATE');
const { const {
events: storeEvents, events: storeEvents,
rawEvents, rawEvents,
@ -167,9 +171,9 @@ export function EventList() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isNew && ( {isNew && (
<button type="button" aria-label="확인" title="확인(ACK)" <button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
</button> </button>
)} )}
@ -180,14 +184,14 @@ export function EventList() {
</button> </button>
{isActionable && ( {isActionable && (
<> <>
<button type="button" aria-label="단속 등록" title="단속 등록" <button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}> disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<button type="button" aria-label="오탐 처리" title="오탐 처리" <button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" /> <Ban className="w-3.5 h-3.5" />
</button> </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 [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);

파일 보기

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

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type maplibregl from 'maplibre-gl'; 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 { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
@ -50,7 +50,7 @@ export function PatrolRoute() {
const wps = route?.waypoints ?? []; const wps = route?.waypoints ?? [];
const buildLayers = useCallback(() => { 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 routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]);
const midMarkers = []; const midMarkers = [];
@ -70,7 +70,7 @@ export function PatrolRoute() {
}); });
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }), createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }),
createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500), createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500),
createMarkerLayer('waypoint-markers', waypointMarkers), 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 { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react'; 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 type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement'; import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event'; import { getEvents, type PredictionEvent } from '@/services/event';
@ -87,7 +87,7 @@ export function EnforcementPlan() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'ep-radius-confirmed', 'ep-radius-confirmed',
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({ PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({

파일 보기

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

파일 보기

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

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react'; 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 { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
@ -229,7 +229,7 @@ export function MapControl() {
})); }));
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'zone-circles-active', 'zone-circles-active',
activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })), 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, Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
Loader2, ShieldAlert, Shield, EyeOff, FileText, Loader2, ShieldAlert, Shield, EyeOff, FileText,
} from 'lucide-react'; } 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 { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event'; import { getEvents, type PredictionEvent } from '@/services/event';
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi'; import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
@ -160,7 +160,7 @@ export function VesselDetail() {
// 지도 레이어 // 지도 레이어
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
})), 80000, 0.05), })), 80000, 0.05),

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -3,4 +3,4 @@ export { createMarkerLayer, createRadiusLayer, type MarkerData } from './markers
export { createPolylineLayer } from './polyline'; export { createPolylineLayer } from './polyline';
export { createHeatmapLayer } from './heatmap'; export { createHeatmapLayer } from './heatmap';
export { createZoneLayer } from './zones'; 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 { PathLayer } from 'deck.gl';
import { EEZ_BOUNDARY, NLL_LINE, EEZ_STYLE, NLL_STYLE } from '../constants'; 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 EEZ_DATA = [{ path: toPath(EEZ_BOUNDARY) }];
const NLL_DATA = [{ path: toPath(NLL_LINE) }]; const NLL_DATA = [{ path: toPath(NLL_LINE) }];
/** EEZ 경계선 — 싱글턴, 리렌더/재생성 없음 */ const EEZ_COLOR = hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity);
export const EEZ_LAYER = new PathLayer({ const NLL_COLOR = hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity);
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,
});
/** NLL 경계선 — 싱글턴, 리렌더/재생성 없음 */ /** EEZ 경계선 레이어 생성 */
export const NLL_LAYER = new PathLayer({ export function createEEZStaticLayer() {
id: 'nll-line', return new PathLayer({
data: NLL_DATA, id: 'eez-boundary',
getPath: (d: { path: [number, number][] }) => d.path, data: EEZ_DATA,
getColor: hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity), getPath: (d: { path: [number, number][] }) => d.path,
getWidth: NLL_STYLE.weight * 2, getColor: EEZ_COLOR,
widthUnits: 'pixels' as const, getWidth: EEZ_STYLE.weight * 2,
getDashArray: [8, 4], widthUnits: 'pixels' as const,
dashJustified: true, getDashArray: [6, 4],
}); dashJustified: true,
});
}
/** 정적 기본 레이어 배열 (EEZ + NLL) */ /** NLL 경계선 레이어 생성 */
export const STATIC_LAYERS = [EEZ_LAYER, NLL_LAYER] as const; 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 import pandas as pd
from algorithms.location import haversine_nm from algorithms.location import haversine_nm
GAP_SUSPICIOUS_SEC = 1800 # 30분 GAP_SUSPICIOUS_SEC = 6000 # 100분 (30분 → 100분 상향: 자연 gap 과탐 감소)
GAP_HIGH_SUSPICIOUS_SEC = 3600 # 1시간 GAP_HIGH_SUSPICIOUS_SEC = 10800 # 3시간
GAP_VIOLATION_SEC = 86400 # 24시간 GAP_VIOLATION_SEC = 86400 # 24시간
# 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유) # 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유)
@ -61,7 +61,7 @@ def is_dark_vessel(df_vessel: pd.DataFrame) -> tuple[bool, int]:
return False, 0 return False, 0
max_gap_min = max(g['gap_min'] for g in gaps) 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) return is_dark, int(max_gap_min)
@ -218,6 +218,10 @@ def compute_dark_suspicion(
history: dict, history: dict,
now_kst_hour: int, now_kst_hour: int,
classify_zone_fn: Optional[Callable[[float, float], dict]] = None, 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]: ) -> tuple[int, list[str], str]:
"""의도적 AIS OFF 의심 점수 산출. """의도적 AIS OFF 의심 점수 산출.
@ -228,6 +232,10 @@ def compute_dark_suspicion(
history: {'count_7d': int, 'count_24h': int} history: {'count_7d': int, 'count_24h': int}
now_kst_hour: 현재 KST 시각 (0~23) now_kst_hour: 현재 KST 시각 (0~23)
classify_zone_fn: (lat, lon) -> dict. gap_start 위치의 zone 판단 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: Returns:
(score, patterns, tier) (score, patterns, tier)
@ -314,9 +322,40 @@ def compute_dark_suspicion(
score += 10 score += 10
patterns.append('long_gap') 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): if not _is_in_kr_coverage(gap_start_lat, gap_start_lon):
score -= 30 score -= 50
patterns.append('out_of_coverage') patterns.append('out_of_coverage')
score = max(0, min(100, score)) score = max(0, min(100, score))

파일 보기

@ -1,19 +1,21 @@
"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현. """환적(Transshipment) 의심 선박 탐지 — 5단계 필터 파이프라인.
프론트엔드 useKoreaFilters.ts의 O() 근접 탐지를 대체한다. 실무 목표: 일일 10 미만 고신뢰 의심 .
scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다.
알고리즘 개요: 5단계 필터:
1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외 Stage 1: 이종 필수 (어선 운반선) shipKindCode 기반
2. 그리드 기반 근접 탐지: O(n log n) 분할 + 인접 9 조회 Stage 2: 감시영역(Monitoring Zone) 선박만 대상
3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 유지) Stage 3: 3단계 패턴 검증 (APPROACH RENDEZVOUS DEPARTURE)
4. 60 이상 지속 근접 의심 쌍으로 판정 Stage 4: 점수 산출 (0~100, 50 미만 미출력)
Stage 5: 밀집 방폭 (1 운반선 : 최대 1 어선)
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import math import math
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@ -24,82 +26,150 @@ from fleet_tracker import GEAR_PATTERN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# 상수 (2026-04-09 재조정 — 베테랑 관점) # 상수
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
SOG_THRESHOLD_KN = 1.0 # 2.0 → 1.0 (완전 정박 수준) SOG_THRESHOLD_KN = 2.0 # 저속 기준 (접현 가능 속도)
PROXIMITY_DEG = 0.0007 # 0.001 → 0.0007 (~77m, GPS 노이즈 포함한 근접) PROXIMITY_DEG = 0.002 # ~220m (0.0007 → 0.002 상향: 접현 가능 범위)
SUSPECT_DURATION_MIN = 45 # 60 → 45 (gap tolerance 있음) APPROACH_DEG = 0.01 # ~1.1km (접근 판정 거리)
PAIR_EXPIRY_MIN = 180 # 120 → 180 RENDEZVOUS_MIN = 90 # 체류 최소 시간 (45 → 90분)
GAP_TOLERANCE_CYCLES = 2 # 신규: 2 사이클까지 active에서 빠져도 리셋 안 함 PAIR_EXPIRY_MIN = 240 # 쌍 만료 시간
GAP_TOLERANCE_CYCLES = 3 # 3 사이클(15분)까지 miss 허용
# 외국 해안 근접 제외 경계 (레거시 — 관할 필터로 대체됨) # 선종 분류 (shipKindCode 기반)
_CN_LON_MAX = 123.5 _FISHING_KINDS = frozenset({'000020'})
_JP_LON_MIN = 130.5 # 운반선: 화물선(000023) + 유조선(000024)만. 000027(기타)은 shipTy로 2차 판정
_TSUSHIMA_LAT_MIN = 33.8 _CARRIER_KINDS = frozenset({'000023', '000024'})
_TSUSHIMA_LON_MIN = 129.0 # 환적 불가 선종 (shipTy 텍스트 기반 2차 필터)
_EXCLUDED_SHIP_TY = frozenset({
# 한국 EEZ 관할 수역 (단속 가능 범위) 'Tug', 'Pilot Boat', 'Search And Rescue', 'Law Enforcement',
_KR_EEZ_LAT = (32.0, 39.5) 'AtoN', 'Anti Pollution', 'Passenger', 'Medical Transport',
_KR_EEZ_LON = (124.0, 132.0)
# 환적 불가능 선종 (여객/군함/유조/도선/예인/수색구조)
_TRANSSHIP_EXCLUDED: frozenset[str] = frozenset({
'passenger', 'military', 'tanker', 'pilot', 'tug', 'sar',
}) })
# 그리드 셀 크기 # ──────────────────────────────────────────────────────────────
_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: def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""외국 해안 근처 여부 — 중국/일본/대마도 경계 확인.""" R = 3440.065
if lon < _CN_LON_MAX: dlat = math.radians(lat2 - lat1)
return True dlon = math.radians(lon2 - lon1)
if lon > _JP_LON_MIN: a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
return True return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN:
return True
return False
def _is_in_kr_jurisdiction(lat: float, lon: float) -> bool: def _within_proximity(a: dict, b: dict) -> bool:
"""한국 EEZ 관할 수역 여부 (단속 가능 범위).""" """PROXIMITY_DEG 이내인지 (위경도 근사)."""
return (_KR_EEZ_LAT[0] <= lat <= _KR_EEZ_LAT[1] dlat = abs(a['lat'] - b['lat'])
and _KR_EEZ_LON[0] <= lon <= _KR_EEZ_LON[1]) if dlat >= PROXIMITY_DEG:
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:
return False 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: def _within_approach(a: dict, b: dict) -> bool:
"""어구 이름 패턴 매칭 — fleet_tracker.GEAR_PATTERN SSOT.""" """APPROACH_DEG 이내인지 (~1km)."""
if not name: dlat = abs(a['lat'] - b['lat'])
if dlat >= APPROACH_DEG:
return False 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]: def _cell_key(lat: float, lon: float) -> tuple[int, int]:
"""위도/경도를 그리드 셀 인덱스로 변환.""" return (int(math.floor(lat / APPROACH_DEG)),
return (int(math.floor(lat / _GRID_CELL_DEG)), int(math.floor(lon / APPROACH_DEG)))
int(math.floor(lon / _GRID_CELL_DEG)))
def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]: def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
"""선박 리스트를 그리드 셀로 분류.
Returns: {(row, col): [record index, ...]}
"""
grid: dict[tuple[int, int], list[int]] = {} grid: dict[tuple[int, int], list[int]] = {}
for idx, rec in enumerate(records): for idx, rec in enumerate(records):
key = _cell_key(rec['lat'], rec['lon']) 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 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]: 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) return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a)
def _evict_expired_pairs( def _is_gear_name(name: Optional[str]) -> bool:
pair_history: dict, if not name:
now: datetime, return False
) -> None: return bool(GEAR_PATTERN.match(name))
"""PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거.
구조: {(a,b): {'first_seen': dt, 'last_seen': dt, 'miss_count': int}}
""" # ──────────────────────────────────────────────────────────────
expired = [] # Stage 4: 점수 산출
for key, meta in pair_history.items(): # ──────────────────────────────────────────────────────────────
if not isinstance(meta, dict):
# 레거시 구조 (datetime 직접 저장)는 즉시 제거 → 다음 사이클에서 재구성 def _score_pair(
expired.append(key) meta: dict,
continue now: datetime,
last_seen = meta.get('last_seen') or meta.get('first_seen') is_permitted_fn: Optional[Callable[[str], bool]],
if last_seen is None: now_kst_hour: int,
expired.append(key) zone_id: Optional[str],
continue vessel_info_a: dict,
if (now - last_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN: vessel_info_b: dict,
expired.append(key) ) -> Optional[dict]:
for key in expired: """환적 의심 점수 산출. 50점 미만이면 None."""
del pair_history[key] 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 # 공개 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( def detect_transshipment(
df: pd.DataFrame, df: pd.DataFrame,
pair_history: dict, pair_history: dict,
@ -258,207 +311,226 @@ def detect_transshipment(
classify_zone_fn: Optional[Callable[[float, float], dict]] = None, classify_zone_fn: Optional[Callable[[float, float], dict]] = None,
now_kst_hour: int = 0, now_kst_hour: int = 0,
) -> list[dict]: ) -> list[dict]:
"""환적 의심 쌍 탐지 (점수 기반, 베테랑 관점 필터). """환적 의심 쌍 탐지 — 5단계 필터 파이프라인.
Args: Args:
df: 선박 위치 DataFrame. df: 선박 위치 DataFrame (mmsi, lat, lon, sog 필수)
필수 컬럼: mmsi, lat, lon, sog pair_history: 호출 유지되는 상태 dict
선택 컬럼: cog get_vessel_info: mmsi {ship_kind_code, ship_ty, name, heading, ...}
pair_history: {(a,b): {'first_seen', 'last_seen', 'miss_count'}} is_permitted: mmsi bool (허가 어선 여부)
get_vessel_info: callable(mmsi) -> {'name', 'vessel_type', ...} classify_zone_fn: (lat, lon) {zone, ...}
is_permitted: callable(mmsi) -> bool
classify_zone_fn: callable(lat, lon) -> dict (zone 판정)
now_kst_hour: 현재 KST 시각 (0~23) now_kst_hour: 현재 KST 시각 (0~23)
Returns: Returns:
list[dict] severity 'CRITICAL'/'HIGH'/'WATCH' 포함 의심 list[dict] 의심 (score >= 50)
""" """
if df.empty: if df.empty:
return [] return []
required_cols = {'mmsi', 'lat', 'lon', 'sog'} required_cols = {'mmsi', 'lat', 'lon', 'sog'}
missing = required_cols - set(df.columns) if required_cols - set(df.columns):
if missing:
logger.error('detect_transshipment: missing required columns: %s', missing)
return [] return []
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# ── 1. 후보 선박 필터 (SOG < 1.0) ───────────────────────── # ── Stage 1+2: 후보 필터 ──────────────────────────────
candidate_mask = df['sog'] < SOG_THRESHOLD_KN # SOG < 2kn 선박만
candidates = df[candidate_mask].copy() candidates = df[df['sog'] < SOG_THRESHOLD_KN].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]
if len(candidates) < 2: if len(candidates) < 2:
_evict_expired_pairs(pair_history, now) _evict_expired(pair_history, now)
return [] return []
has_cog = 'cog' in candidates.columns # 선종 + 감시영역 필터 → 유효 레코드만
cols = ['mmsi', 'lat', 'lon'] records: list[dict] = []
if has_cog: for _, row in candidates.iterrows():
cols.append('cog') mmsi = str(row['mmsi'])
records = candidates[cols].to_dict('records') lat, lon = float(row['lat']), float(row['lon'])
for rec in records:
rec['mmsi'] = str(rec['mmsi'])
# ── 2. 그리드 기반 근접 쌍 탐지 (77m) ─────────────────── # Stage 2: 감시영역 내 여부
grid = _build_grid(records) 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] = {} active_pairs: dict[tuple[str, str], dict] = {}
def _try_add_pair(a_rec, b_rec): for f_rec in fishing_recs:
if not _within_proximity(a_rec, b_rec): f_cell = _cell_key(f_rec['lat'], f_rec['lon'])
return # 인접 9셀 탐색
key = _pair_key(a_rec['mmsi'], b_rec['mmsi']) for dr in (-1, 0, 1):
# 중점 좌표 (점수 산출용) for dc in (-1, 0, 1):
mid_lat = (a_rec['lat'] + b_rec['lat']) / 2.0 neighbor = (f_cell[0] + dr, f_cell[1] + dc)
mid_lon = (a_rec['lon'] + b_rec['lon']) / 2.0 if neighbor not in carrier_grid:
active_pairs[key] = { continue
'lat': mid_lat, 'lon': mid_lon, for ci in carrier_grid[neighbor]:
'cog_a': a_rec.get('cog'), 'cog_b': b_rec.get('cog'), c_rec = carrier_recs[ci]
# mmsi_a < mmsi_b 순서로 정렬되었으므로 cog도 맞춰 정렬 필요 is_close = _within_proximity(f_rec, c_rec)
'mmsi_a': a_rec['mmsi'], 'mmsi_b': b_rec['mmsi'], is_approaching = _within_approach(f_rec, c_rec) if not is_close else False
}
for (row, col), indices in grid.items(): if not is_close and not is_approaching:
for i in range(len(indices)): continue
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])
# ── 3. pair_history 갱신 (gap tolerance) ───────────────── key = _pair_key(f_rec['mmsi'], c_rec['mmsi'])
active_keys = set(active_pairs.keys()) mid_lat = (f_rec['lat'] + c_rec['lat']) / 2
mid_lon = (f_rec['lon'] + c_rec['lon']) / 2
# 활성 쌍 → 등록/갱신 active_pairs[key] = {
for pair in active_keys: 'is_close': is_close,
if pair not in pair_history or not isinstance(pair_history[pair], dict): 'is_approaching': is_approaching,
pair_history[pair] = { 'lat': mid_lat, 'lon': mid_lon,
'first_seen': now, 'zone_id': f_rec['zone_id'],
'last_seen': now, '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, '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[key] = meta
pair_history[pair]['last_seen'] = now continue
pair_history[pair]['miss_count'] = 0
# 비활성 쌍 → 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()): for key in list(pair_history.keys()):
if key in active_keys: if key in active_pairs:
continue continue
meta = pair_history[key] meta = pair_history[key]
if not isinstance(meta, dict): if not isinstance(meta, dict):
del pair_history[key] del pair_history[key]
continue continue
meta['miss_count'] = meta.get('miss_count', 0) + 1 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: if meta['miss_count'] > GAP_TOLERANCE_CYCLES:
del pair_history[key] del pair_history[key]
# 만료 정리 _evict_expired(pair_history, now)
_evict_expired_pairs(pair_history, now)
# ── 4. 점수 기반 의심 쌍 판정 ───────────────────────────── # ── Stage 4+5: 점수 산출 + 밀집 방폭 ────────────────
suspects: list[dict] = [] raw_suspects: list[dict] = []
rejected_jurisdiction = 0 for key, meta in pair_history.items():
rejected_ship_type = 0
rejected_gear = 0
rejected_duration = 0
for pair, meta in pair_history.items():
if not isinstance(meta, dict): if not isinstance(meta, dict):
continue continue
first_seen = meta.get('first_seen')
if first_seen is None:
continue
# active_pairs에 있으면 해당 사이클 좌표·cog 사용, 없으면 이전 값 재사용 (miss 중) info_a = get_vessel_info(key[0]) if get_vessel_info else {}
loc_meta = active_pairs.get(pair) info_b = get_vessel_info(key[1]) if get_vessel_info else {}
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
scored = _score_pair( scored = _score_pair(
pair, meta, lat, lon, cog_a, cog_b, meta, now, is_permitted, now_kst_hour,
info_a, info_b, is_permitted, meta.get('zone_id'), info_a, info_b,
now_kst_hour, zone_code, now,
) )
if scored is not None: 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: for s in suspects:
tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1 tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1
logger.info( logger.info(
'transshipment detection: pairs=%d (critical=%d, high=%d, watch=%d, ' 'transshipment: pairs=%d (critical=%d, high=%d), '
'rejected_jurisdiction=%d, rejected_ship_type=%d, rejected_gear=%d, ' 'candidates: fishing=%d carrier=%d, active_pairs=%d, history=%d',
'rejected_duration=%d, candidates=%d)', len(suspects), tier_counts.get('CRITICAL', 0), tier_counts.get('HIGH', 0),
len(suspects), len(fishing_recs), len(carrier_recs), len(active_pairs), len(pair_history),
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),
) )
return suspects 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()) mmsi_list = list(self._tracks.keys())
try: try:
info = snpdb.fetch_static_info(mmsi_list) 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 self._static_refreshed_at = now
logger.info('static info refreshed: %d vessels', len(info)) logger.info('static info refreshed: %d vessels', len(info))
except Exception as e: except Exception as e:
@ -285,6 +290,45 @@ class VesselStore:
except Exception as e: except Exception as e:
logger.error('fetch_permit_mmsis failed: %s', 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 # Analysis target selection
# ------------------------------------------------------------------ # ------------------------------------------------------------------

파일 보기

@ -36,6 +36,10 @@ class Settings(BaseSettings):
MMSI_PREFIX: str = '412' MMSI_PREFIX: str = '412'
MIN_TRAJ_POINTS: int = 100 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 (LLM)
OLLAMA_BASE_URL: str = 'http://localhost:11434' OLLAMA_BASE_URL: str = 'http://localhost:11434'
OLLAMA_MODEL: str = 'qwen3:14b' # CPU-only: 14b 권장, GPU 있으면 32b 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) vessel_store.load_initial(settings.INITIAL_LOAD_HOURS)
logger.info('initial load complete: %s', vessel_store.stats()) 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() start_scheduler()
yield yield
stop_scheduler() stop_scheduler()

파일 보기

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