Merge pull request 'release: 2026-04-16.7 (4건 커밋)' (#67) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
This commit is contained in:
커밋
c8673246f3
@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-16.7]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **경량 분석 riskScore 해상도 개선** — `compute_lightweight_risk_score` 에 `dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
|
||||||
|
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
|
||||||
|
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
|
||||||
|
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- **선박 유형 한글 카탈로그** — `shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
|
||||||
|
|
||||||
## [2026-04-16.6]
|
## [2026-04-16.6]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||||
|
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||||
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
||||||
import { useSettingsStore } from '@stores/settingsStore';
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -52,18 +53,29 @@ function deriveVesselStatus(score: number): VesselStatus {
|
|||||||
return '양호';
|
return '양호';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
function mapToVesselItem(
|
||||||
|
item: VesselAnalysisItem,
|
||||||
|
idx: number,
|
||||||
|
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||||
|
lang: 'ko' | 'en',
|
||||||
|
): VesselItem {
|
||||||
const score = item.algorithms.riskScore.score;
|
const score = item.algorithms.riskScore.score;
|
||||||
const vt = item.classification.vesselType;
|
const vt = item.classification.vesselType;
|
||||||
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
||||||
|
// 이름: fleet_vessels 매핑으로 vessel_type 이 채워진 경우 한글 유형 라벨, 아니면 '중국어선'
|
||||||
|
const name = hasType ? getVesselTypeLabel(vt, t, lang) : '중국어선';
|
||||||
|
// 타입 뱃지: fishingPct 기반 Fishing / 그 외는 vessel_type 라벨
|
||||||
|
const type = item.classification.fishingPct > 0.5
|
||||||
|
? 'Fishing'
|
||||||
|
: hasType ? getVesselTypeLabel(vt, t, lang) : getVesselTypeLabel('UNKNOWN', t, lang);
|
||||||
return {
|
return {
|
||||||
id: String(idx + 1),
|
id: String(idx + 1),
|
||||||
mmsi: item.mmsi,
|
mmsi: item.mmsi,
|
||||||
callSign: '-',
|
callSign: '-',
|
||||||
channel: '',
|
channel: '',
|
||||||
source: 'AIS',
|
source: 'AIS',
|
||||||
name: hasType ? vt : '중국어선',
|
name,
|
||||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
|
type,
|
||||||
country: 'China',
|
country: 'China',
|
||||||
status: deriveVesselStatus(score),
|
status: deriveVesselStatus(score),
|
||||||
riskPct: score,
|
riskPct: score,
|
||||||
@ -289,8 +301,8 @@ export function ChinaFishing() {
|
|||||||
|
|
||||||
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||||
const vesselList: VesselItem[] = useMemo(
|
const vesselList: VesselItem[] = useMemo(
|
||||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
|
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
|
||||||
[topVessels],
|
[topVessels, tcCommon, lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
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 { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||||
|
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||||
import {
|
import {
|
||||||
getAnalysisVessels,
|
getAnalysisVessels,
|
||||||
@ -21,6 +23,12 @@ interface Props {
|
|||||||
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
|
||||||
|
mmsiPrefix?: string;
|
||||||
|
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
|
||||||
|
minRiskScore?: number;
|
||||||
|
/** 서버 조회 건수 (dark/transship 기본 200) */
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZONE_LABELS: Record<string, string> = {
|
const ZONE_LABELS: Record<string, string> = {
|
||||||
@ -40,7 +48,9 @@ const ENDPOINT_LABEL: Record<Props['mode'], string> = {
|
|||||||
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
|
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore, size = 200 }: Props) {
|
||||||
|
const { t, i18n } = useTranslation('common');
|
||||||
|
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
||||||
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
||||||
const [available, setAvailable] = useState(true);
|
const [available, setAvailable] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -51,10 +61,10 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const page = mode === 'dark'
|
const page = mode === 'dark'
|
||||||
? await getDarkVessels({ hours: 1, size: 200 })
|
? await getDarkVessels({ hours: 1, size })
|
||||||
: mode === 'transship'
|
: mode === 'transship'
|
||||||
? await getTransshipSuspects({ hours: 1, size: 200 })
|
? await getTransshipSuspects({ hours: 1, size })
|
||||||
: await getAnalysisVessels({ hours: 1, size: 200 });
|
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
|
||||||
setItems(page.content.map(toVesselItem));
|
setItems(page.content.map(toVesselItem));
|
||||||
setAvailable(true);
|
setAvailable(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@ -63,7 +73,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [mode]);
|
}, [mode, mmsiPrefix, minRiskScore, size]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@ -160,8 +170,10 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|||||||
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
||||||
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
||||||
<td className="px-2 py-1.5 text-heading font-medium">
|
<td className="px-2 py-1.5 text-heading font-medium">
|
||||||
{v.classification.vesselType}
|
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
|
||||||
|
{v.classification.confidence > 0 && (
|
||||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-2 py-1.5 text-center">
|
||||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||||
@ -221,4 +233,12 @@ function StatBox({ label, value, color }: { label: string; value: number | undef
|
|||||||
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
|
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
|
||||||
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
|
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
|
||||||
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
|
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
|
||||||
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />;
|
// 중국 선박 감시 페이지 전용 — MMSI prefix 412 고정
|
||||||
|
export const RealAllVessels = () => (
|
||||||
|
<RealVesselAnalysis
|
||||||
|
mode="all"
|
||||||
|
title="중국 선박 전체 분석 결과 (실시간)"
|
||||||
|
icon={<Radar className="w-4 h-4 text-blue-400" />}
|
||||||
|
mmsiPrefix="412"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses';
|
|||||||
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
|
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
|
||||||
import { ZONE_CODES } from './zoneCodes';
|
import { ZONE_CODES } from './zoneCodes';
|
||||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||||
|
import { VESSEL_TYPES } from './vesselTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||||
@ -91,6 +92,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
|||||||
source: 'backend ViolationType enum',
|
source: 'backend ViolationType enum',
|
||||||
items: VIOLATION_TYPES,
|
items: VIOLATION_TYPES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'vessel-type',
|
||||||
|
showcaseId: 'TRK-CAT-vessel-type',
|
||||||
|
titleKo: '선박 유형',
|
||||||
|
titleEn: 'Vessel Type',
|
||||||
|
description: 'TRAWL / PURSE / GILLNET / LONGLINE / TRAP / CARGO / UNKNOWN — prediction 분류 + fleet_vessels 매핑',
|
||||||
|
source: 'prediction AnalysisResult.vessel_type',
|
||||||
|
items: VESSEL_TYPES,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'event-status',
|
id: 'event-status',
|
||||||
showcaseId: 'TRK-CAT-event-status',
|
showcaseId: 'TRK-CAT-event-status',
|
||||||
|
|||||||
53
frontend/src/shared/constants/vesselTypes.ts
Normal file
53
frontend/src/shared/constants/vesselTypes.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 선박 유형 공통 카탈로그
|
||||||
|
*
|
||||||
|
* SSOT: prediction `AnalysisResult.vessel_type` (TRAWL / PURSE / LONGLINE / TRAP / GILLNET / CARGO / UNKNOWN).
|
||||||
|
* prediction 은 분류 파이프라인 결과 + fleet_vessels 등록 fishery_code → vessel_type 매핑으로 값을 채운다.
|
||||||
|
*
|
||||||
|
* 사용처: RealVesselAnalysis '선박 유형' 컬럼, ChinaFishing 특이운항 리스트,
|
||||||
|
* VesselDetail / EventList 등 선박 유형 배지.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
|
|
||||||
|
export type VesselTypeCode =
|
||||||
|
| 'TRAWL'
|
||||||
|
| 'PURSE'
|
||||||
|
| 'LONGLINE'
|
||||||
|
| 'TRAP'
|
||||||
|
| 'GILLNET'
|
||||||
|
| 'CARGO'
|
||||||
|
| 'UNKNOWN';
|
||||||
|
|
||||||
|
export interface VesselTypeMeta {
|
||||||
|
code: VesselTypeCode;
|
||||||
|
i18nKey: string;
|
||||||
|
fallback: { ko: string; en: string };
|
||||||
|
intent: BadgeIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
|
||||||
|
TRAWL: { code: 'TRAWL', i18nKey: 'vesselType.trawl', fallback: { ko: '저인망', en: 'Trawl' }, intent: 'warning' },
|
||||||
|
PURSE: { code: 'PURSE', i18nKey: 'vesselType.purse', fallback: { ko: '선망', en: 'Purse Seine' }, intent: 'warning' },
|
||||||
|
GILLNET: { code: 'GILLNET', i18nKey: 'vesselType.gillnet', fallback: { ko: '유자망', en: 'Gillnet' }, intent: 'warning' },
|
||||||
|
LONGLINE: { code: 'LONGLINE', i18nKey: 'vesselType.longline', fallback: { ko: '연승', en: 'Longline' }, intent: 'info' },
|
||||||
|
TRAP: { code: 'TRAP', i18nKey: 'vesselType.trap', fallback: { ko: '통발', en: 'Trap' }, intent: 'info' },
|
||||||
|
CARGO: { code: 'CARGO', i18nKey: 'vesselType.cargo', fallback: { ko: '운반선', en: 'Cargo' }, intent: 'muted' },
|
||||||
|
UNKNOWN: { code: 'UNKNOWN', i18nKey: 'vesselType.unknown', fallback: { ko: '미분류', en: 'Unknown' }, intent: 'muted' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getVesselTypeLabel(
|
||||||
|
code: string | null | undefined,
|
||||||
|
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||||
|
lang: 'ko' | 'en' = 'ko',
|
||||||
|
): string {
|
||||||
|
if (!code) return t(VESSEL_TYPES.UNKNOWN.i18nKey, { defaultValue: VESSEL_TYPES.UNKNOWN.fallback[lang] });
|
||||||
|
const meta = VESSEL_TYPES[code as VesselTypeCode];
|
||||||
|
if (!meta) return code;
|
||||||
|
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVesselTypeIntent(code: string | null | undefined): BadgeIntent {
|
||||||
|
if (!code) return VESSEL_TYPES.UNKNOWN.intent;
|
||||||
|
return VESSEL_TYPES[code as VesselTypeCode]?.intent ?? 'muted';
|
||||||
|
}
|
||||||
@ -14,42 +14,71 @@ def compute_lightweight_risk_score(
|
|||||||
is_dark: bool = False,
|
is_dark: bool = False,
|
||||||
gap_duration_min: int = 0,
|
gap_duration_min: int = 0,
|
||||||
spoofing_score: float = 0.0,
|
spoofing_score: float = 0.0,
|
||||||
|
dark_suspicion_score: int = 0,
|
||||||
|
dist_from_baseline_nm: float = 999.0,
|
||||||
|
dark_history_24h: int = 0,
|
||||||
) -> Tuple[int, str]:
|
) -> Tuple[int, str]:
|
||||||
"""위치·허가·다크/스푸핑 기반 경량 위험도 (파이프라인 미통과 선박용).
|
"""위치·허가·다크/스푸핑 기반 경량 위험도 (파이프라인 미통과 선박용).
|
||||||
|
|
||||||
pipeline path의 compute_vessel_risk_score와 동일한 임계값(70/50/30)을 사용해
|
compute_dark_suspicion 의 패턴 기반 0~100 점수를 직접 반영해 해상도를 높인다.
|
||||||
분류 결과의 일관성을 유지한다. dark/spoofing 신호를 추가하여 max 100점 도달 가능.
|
이중계산 방지: dark_suspicion_score 는 이미 무허가/반복을 포함하므로 dark_suspicion_score > 0
|
||||||
|
인 경우 허가/반복 가산을 축소한다.
|
||||||
|
|
||||||
|
임계값 70/50/30 은 pipeline path(compute_vessel_risk_score)와 동일.
|
||||||
|
|
||||||
Returns: (risk_score, risk_level)
|
Returns: (risk_score, risk_level)
|
||||||
"""
|
"""
|
||||||
score = 0
|
score = 0
|
||||||
|
|
||||||
# 1. 위치 기반 (최대 40점)
|
# 1. 위치 기반 (최대 40점) — EEZ 외 기선 근접도 추가
|
||||||
zone = zone_info.get('zone', '')
|
zone = zone_info.get('zone', '')
|
||||||
if zone == 'TERRITORIAL_SEA':
|
if zone == 'TERRITORIAL_SEA':
|
||||||
score += 40
|
score += 40
|
||||||
elif zone == 'CONTIGUOUS_ZONE':
|
elif zone == 'CONTIGUOUS_ZONE':
|
||||||
score += 10
|
score += 15
|
||||||
elif zone.startswith('ZONE_'):
|
elif zone.startswith('ZONE_'):
|
||||||
if is_permitted is not None and not is_permitted:
|
if is_permitted is not None and not is_permitted:
|
||||||
score += 25
|
score += 25
|
||||||
|
elif zone == 'EEZ_OR_BEYOND':
|
||||||
|
# EEZ 외라도 기선 근접 시 가산 (공해·외해 분산)
|
||||||
|
if dist_from_baseline_nm < 12:
|
||||||
|
score += 15
|
||||||
|
elif dist_from_baseline_nm < 24:
|
||||||
|
score += 8
|
||||||
|
|
||||||
# 2. 다크 베셀 (최대 25점)
|
# 2. 다크 베셀 (최대 30점) — dark_suspicion_score 우선
|
||||||
if is_dark:
|
if is_dark:
|
||||||
if gap_duration_min >= 60:
|
if dark_suspicion_score >= 1:
|
||||||
|
# compute_dark_suspicion 이 산출한 패턴 기반 의심도 반영
|
||||||
|
score += min(30, round(dark_suspicion_score * 0.3))
|
||||||
|
else:
|
||||||
|
# fallback: gap 길이만 기준
|
||||||
|
if gap_duration_min >= 720:
|
||||||
score += 25
|
score += 25
|
||||||
|
elif gap_duration_min >= 180:
|
||||||
|
score += 20
|
||||||
|
elif gap_duration_min >= 60:
|
||||||
|
score += 15
|
||||||
elif gap_duration_min >= 30:
|
elif gap_duration_min >= 30:
|
||||||
score += 10
|
score += 8
|
||||||
|
|
||||||
# 3. 스푸핑 (최대 15점)
|
# 3. 스푸핑 (최대 15점) — 현재 중국 선박은 거의 0 (별도 PR 에서 산출 로직 재설계 예정)
|
||||||
if spoofing_score > 0.7:
|
if spoofing_score > 0.7:
|
||||||
score += 15
|
score += 15
|
||||||
elif spoofing_score > 0.5:
|
elif spoofing_score > 0.5:
|
||||||
score += 8
|
score += 8
|
||||||
|
|
||||||
# 4. 허가 이력 (최대 20점)
|
# 4. 허가 이력 (최대 15점) — 이중계산 방지
|
||||||
if is_permitted is not None and not is_permitted:
|
if is_permitted is not None and not is_permitted:
|
||||||
score += 20
|
# dark_suspicion_score 에 이미 무허가 +10 반영됨 → 축소
|
||||||
|
score += 8 if dark_suspicion_score > 0 else 15
|
||||||
|
|
||||||
|
# 5. 반복 이력 (최대 10점) — dark_suspicion_score 미반영 케이스만
|
||||||
|
if dark_suspicion_score == 0 and dark_history_24h > 0:
|
||||||
|
if dark_history_24h >= 5:
|
||||||
|
score += 10
|
||||||
|
elif dark_history_24h >= 2:
|
||||||
|
score += 5
|
||||||
|
|
||||||
score = min(score, 100)
|
score = min(score, 100)
|
||||||
|
|
||||||
|
|||||||
27
prediction/algorithms/vessel_type_mapping.py
Normal file
27
prediction/algorithms/vessel_type_mapping.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""한중어업협정 fishery_code → VesselType 매핑.
|
||||||
|
|
||||||
|
파이프라인 미통과 선박(경량 분석 경로)은 AIS 샘플 부족으로 분류기가 UNKNOWN 을 반환한다.
|
||||||
|
등록선은 fishery_code 가 이미 확정이므로 이를 활용해 vessel_type 을 채운다.
|
||||||
|
|
||||||
|
VesselType 값 확장:
|
||||||
|
기존: TRAWL / PURSE / LONGLINE / TRAP / UNKNOWN
|
||||||
|
신규: GILLNET (유자망) / CARGO (운반선)
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
FISHERY_CODE_TO_VESSEL_TYPE = {
|
||||||
|
'PT': 'TRAWL', # 쌍끌이 저인망
|
||||||
|
'PT-S': 'TRAWL', # 쌍끌이 부속선
|
||||||
|
'OT': 'TRAWL', # 단선 저인망
|
||||||
|
'GN': 'GILLNET', # 유자망
|
||||||
|
'PS': 'PURSE', # 대형선망/위망
|
||||||
|
'FC': 'CARGO', # 운반선 (조업 아님)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fishery_code_to_vessel_type(fishery_code: Optional[str]) -> str:
|
||||||
|
"""등록 어업 코드 → 선박 유형. 매칭 없으면 'UNKNOWN'."""
|
||||||
|
if not fishery_code:
|
||||||
|
return 'UNKNOWN'
|
||||||
|
return FISHERY_CODE_TO_VESSEL_TYPE.get(fishery_code.upper(), 'UNKNOWN')
|
||||||
@ -507,6 +507,7 @@ def run_analysis_cycle():
|
|||||||
# ── 5.5 경량 분석 — 파이프라인 미통과 412* 선박 ──
|
# ── 5.5 경량 분석 — 파이프라인 미통과 412* 선박 ──
|
||||||
# vessel_store._tracks의 24h 누적 궤적을 직접 활용하여 dark/spoof 신호도 산출.
|
# vessel_store._tracks의 24h 누적 궤적을 직접 활용하여 dark/spoof 신호도 산출.
|
||||||
from algorithms.risk import compute_lightweight_risk_score
|
from algorithms.risk import compute_lightweight_risk_score
|
||||||
|
from algorithms.vessel_type_mapping import fishery_code_to_vessel_type
|
||||||
|
|
||||||
pipeline_mmsis = {c['mmsi'] for c in classifications}
|
pipeline_mmsis = {c['mmsi'] for c in classifications}
|
||||||
lightweight_mmsis = vessel_store.get_chinese_mmsis() - pipeline_mmsis
|
lightweight_mmsis = vessel_store.get_chinese_mmsis() - pipeline_mmsis
|
||||||
@ -607,17 +608,29 @@ def run_analysis_cycle():
|
|||||||
if spoof_score > 0.5:
|
if spoof_score > 0.5:
|
||||||
lw_spoof += 1
|
lw_spoof += 1
|
||||||
|
|
||||||
|
# dark_features 에 저장된 패턴 기반 점수 + 반복 이력을 리스크 산출에 직접 연결
|
||||||
|
# (경량 경로가 45점 포화되던 원인 해소)
|
||||||
risk_score, risk_level = compute_lightweight_risk_score(
|
risk_score, risk_level = compute_lightweight_risk_score(
|
||||||
zone_info, sog, is_permitted=is_permitted,
|
zone_info, sog, is_permitted=is_permitted,
|
||||||
is_dark=dark, gap_duration_min=gap_min,
|
is_dark=dark, gap_duration_min=gap_min,
|
||||||
spoofing_score=spoof_score,
|
spoofing_score=spoof_score,
|
||||||
|
dark_suspicion_score=int(dark_features.get('dark_suspicion_score', 0) or 0),
|
||||||
|
dist_from_baseline_nm=float(zone_info.get('dist_from_baseline_nm', 999.0) or 999.0),
|
||||||
|
dark_history_24h=int(dark_features.get('dark_history_24h', 0) or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 등록선은 fishery_code 로 vessel_type 채움 (미등록선은 UNKNOWN 유지)
|
||||||
|
registered_fc = (
|
||||||
|
fleet_tracker.get_registered_fishery_code(mmsi)
|
||||||
|
if hasattr(fleet_tracker, 'get_registered_fishery_code') else None
|
||||||
|
)
|
||||||
|
vessel_type = fishery_code_to_vessel_type(registered_fc)
|
||||||
|
|
||||||
# BD-09 오프셋은 중국 선박이므로 제외 (412* = 중국)
|
# BD-09 오프셋은 중국 선박이므로 제외 (412* = 중국)
|
||||||
results.append(AnalysisResult(
|
results.append(AnalysisResult(
|
||||||
mmsi=mmsi,
|
mmsi=mmsi,
|
||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
vessel_type='UNKNOWN',
|
vessel_type=vessel_type,
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
fishing_pct=0.0,
|
fishing_pct=0.0,
|
||||||
lat=float(lat) if lat is not None else None,
|
lat=float(lat) if lat is not None else None,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user