kcg-monitoring/frontend/src/utils/fishingAnalysis.ts
2026-03-20 12:47:29 +09:00

293 lines
11 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══ 중국 어선 조업 분석 — GC-KCG-2026-001 보고서 기반 ═══
// 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류
import type { Ship } from '../types';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point, multiPolygon } from '@turf/helpers';
import type { Feature, MultiPolygon } from 'geojson';
import zone1Data from '../data/zones/특정어업수역Ⅰ.json';
import zone2Data from '../data/zones/특정어업수역Ⅱ.json';
import zone3Data from '../data/zones/특정어업수역Ⅲ.json';
import zone4Data from '../data/zones/특정어업수역Ⅳ.json';
/**
* 중국 허가 업종 코드 (허가번호 접두사)
* PT(C21): 2척식저인망 본선 323척 + 부속선(PT-S) 323척
* OT(C22): 1척식저인망 13척
* PS(C23): 위망(선망) 16척 (宁波海裕 단일법인)
* GN(C25): 유망(유자망) 200척
* FC: 운반선 31척
*/
export type FishingGearType = 'trawl_pair' | 'trawl_single' | 'gillnet' | 'stow_net' | 'purse_seine' | 'carrier' | 'unknown';
export interface FishingAnalysis {
gearType: FishingGearType;
gearTypeKo: string;
permitCode: string; // PT, OT, GN, PS, FC
gbCode: string; // GB/T 5147-2003 코드
isOperating: boolean;
operatingStatusKo: string;
confidence: number;
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
riskReason: string;
}
/** 업종별 메타데이터 */
const GEAR_META: Record<FishingGearType, {
ko: string; icon: string; color: string;
permitCode: string; gbCode: string;
speedRange: [number, number]; aiConfidence: string;
}> = {
trawl_pair: { ko: '2척식 저인망(PT)', icon: '🔻', color: '#ef4444', permitCode: 'PT', gbCode: 'TDS', speedRange: [2, 5], aiConfidence: '89~95%' },
trawl_single: { ko: '1척식 저인망(OT)', icon: '🔻', color: '#dc2626', permitCode: 'OT', gbCode: 'TDD', speedRange: [2, 5], aiConfidence: '89~92%' },
gillnet: { ko: '유자망(GN)', icon: '🔲', color: '#f97316', permitCode: 'GN', gbCode: 'CLD', speedRange: [0, 2], aiConfidence: '74~80%' },
stow_net: { ko: '안강망(Z)', icon: '🪤', color: '#eab308', permitCode: '-', gbCode: 'ZD', speedRange: [0, 1], aiConfidence: '70~78%' },
purse_seine: { ko: '위망/선망(PS)', icon: '🔄', color: '#3b82f6', permitCode: 'PS', gbCode: 'WDD', speedRange: [3, 10], aiConfidence: '94~97%' },
carrier: { ko: '운반선(FC)', icon: '🚢', color: '#6b7280', permitCode: 'FC', gbCode: '-', speedRange: [0, 12], aiConfidence: '-' },
unknown: { ko: '미분류', icon: '🐟', color: '#9ca3af', permitCode: '-', gbCode: '-', speedRange: [0, 0], aiConfidence: '-' },
};
export { GEAR_META as GEAR_LABELS };
/**
* EPSG:3857 → WGS84 좌표 변환
*/
function epsg3857ToWgs84(x: number, y: number): [number, number] {
const lon = (x / (Math.PI * 6378137)) * 180;
const lat = Math.atan(Math.exp(y / 6378137)) * (360 / Math.PI) - 90;
return [lon, lat]; // GeoJSON [lng, lat] 순서
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function convertZoneToWgs84(data: any): Feature<MultiPolygon> {
const feat = data.features[0];
const multiCoords: number[][][][] = feat.geometry.coordinates.map(
(poly: number[][][]) => poly.map(
(ring: number[][]) => ring.map(([x, y]: number[]) => epsg3857ToWgs84(x, y)),
),
);
return multiPolygon(multiCoords).geometry
? { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multiCoords } }
: multiPolygon(multiCoords) as unknown as Feature<MultiPolygon>;
}
export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE';
export interface FishingZoneInfo {
zone: FishingZoneId;
name: string;
allowed: string[];
}
/**
* 특정어업수역 ~Ⅳ 폴리곤 (WGS84 변환 캐시)
*/
const ZONE_POLYGONS: { id: FishingZoneId; name: string; allowed: string[]; geojson: Feature<MultiPolygon> }[] = [
{ id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) },
{ id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) },
{ id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) },
{ id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) },
];
/**
* 특정어업수역 폴리곤 기반 수역 분류
*/
export function classifyFishingZone(lat: number, lng: number): FishingZoneInfo {
const pt = point([lng, lat]);
for (const z of ZONE_POLYGONS) {
if (booleanPointInPolygon(pt, z.geojson)) {
return { zone: z.id, name: z.name, allowed: z.allowed };
}
}
return { zone: 'OUTSIDE', name: '수역 외', allowed: [] };
}
/**
* 업종별 허가 기간 (월/일)
*/
const PERMIT_PERIODS: Record<string, { periods: [number, number, number, number][]; label: string }> = {
PT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
OT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
GN: { periods: [[2,1, 6,1], [9,1, 12,31]], label: '2/1~6/1, 9/1~12/31' },
PS: { periods: [[1,1, 12,31]], label: '연중' },
FC: { periods: [[1,1, 12,31]], label: '연중' },
};
function isInPermitPeriod(permitCode: string, date: Date): boolean {
const pp = PERMIT_PERIODS[permitCode];
if (!pp) return false;
const m = date.getMonth() + 1;
const d = date.getDate();
const dayOfYear = m * 100 + d;
return pp.periods.some(([m1, d1, m2, d2]) => dayOfYear >= m1 * 100 + d1 && dayOfYear <= m2 * 100 + d2);
}
/**
* AIS 신호 기반 중국 어선 조업 분석 (보고서 4장 기반)
*
* 트롤(PT/OT): 2~5kn, Lawn-mowing 지그재그, 방향변화 <30°
* 자망(GN): 0~2kn 정지·재방문, AIS OFF 빈번
* 선망(PS): 8~10kn→3kn 급전환, 원형 궤적
* 운반선(FC): 환적 패턴 (0.5NM 접근 + 2kn이하 + 30분)
*/
export function analyzeFishing(ship: Ship): FishingAnalysis {
const speed = ship.speed;
const status = ship.status?.toLowerCase() || '';
const isAnchored = status.includes('anchor') || status.includes('moor');
// 방향 변화율 (trail 기반)
let headingVariance = 0;
if (ship.trail && ship.trail.length >= 3) {
const pts = ship.trail.slice(-6);
const headings: number[] = [];
for (let i = 1; i < pts.length; i++) {
const dlng = pts[i][1] - pts[i - 1][1];
const dlat = pts[i][0] - pts[i - 1][0];
headings.push(Math.atan2(dlng, dlat) * 180 / Math.PI);
}
if (headings.length >= 2) {
const diffs = headings.slice(1).map((h, i) => {
let d = Math.abs(h - headings[i]);
if (d > 180) d = 360 - d;
return d;
});
headingVariance = diffs.reduce((a, b) => a + b, 0) / diffs.length;
}
}
let gearType: FishingGearType = 'unknown';
let isOperating = false;
let operatingStatusKo = '이동 중';
let confidence = 0.3;
// === 패턴 매칭 (보고서 4.1~4.3 기준) ===
if (isAnchored || speed > 12) {
// 정박 또는 고속 이동 → 비조업
gearType = 'unknown';
isOperating = false;
operatingStatusKo = isAnchored ? '정박 중' : '고속 이동';
confidence = 0.8;
} else if (speed >= 8 && speed <= 10 && headingVariance > 30) {
// 선망(PS) 포위 단계: 8~10kn + 원형 궤적
gearType = 'purse_seine';
isOperating = true;
operatingStatusKo = '선망 포위 중 (8~10kn 원형)';
confidence = 0.94;
} else if (speed <= 3 && headingVariance > 40) {
// 선망(PS) 죔줄 단계: 3kn 이하 + 이전 고속→저속 급전환
gearType = 'purse_seine';
isOperating = true;
operatingStatusKo = '선망 죔줄 조임 중 (<3kn)';
confidence = 0.85;
} else if (speed >= 2 && speed <= 5 && headingVariance < 30) {
// 트롤(PT/OT): 2~5kn + 완만한 방향 (Lawn-mowing)
gearType = headingVariance < 15 ? 'trawl_pair' : 'trawl_single';
isOperating = true;
operatingStatusKo = `트롤 예인 중 (${speed.toFixed(1)}kn 지그재그)`;
confidence = headingVariance < 15 ? 0.92 : 0.89;
} else if (speed < 2) {
// 0~2kn 극저속 → 조업 중 or 정박 대기
if (isAnchored || speed < 0.3) {
// 완전 정지/정박 → 비조업 (대기 중)
gearType = 'unknown';
isOperating = false;
operatingStatusKo = speed < 0.3 ? '정지/대기 중' : '정박 중';
confidence = 0.7;
} else if (speed >= 0.3 && speed < 1 && headingVariance < 8) {
// 극저속 + 방향 변화 거의 없음 → 안강망 (조류 이용 수동 어구)
gearType = 'stow_net';
isOperating = true;
operatingStatusKo = '안강망 조업 추정 (조류 이용)';
confidence = 0.65;
} else if (speed >= 0.3 && speed < 2) {
// 저속 이동 → 자망 투망/양망
gearType = 'gillnet';
isOperating = true;
operatingStatusKo = speed < 1 ? '자망 투하/대기 중' : '자망 양망 중 (극저속)';
confidence = 0.72;
}
} else if (speed >= 5 && speed < 8) {
// 중속 이동 → 조업지 이동 또는 운반선
gearType = 'carrier';
isOperating = false;
operatingStatusKo = '이동 중 (조업지 이동 추정)';
confidence = 0.5;
}
// === 위반 위험도 판별 (보고서 5장 기반) ===
const now = new Date();
const meta = GEAR_META[gearType];
let riskLevel: FishingAnalysis['riskLevel'] = 'LOW';
let riskReason = '정상 범위';
// 휴어기 체크
if (meta.permitCode !== '-' && !isInPermitPeriod(meta.permitCode, now)) {
riskLevel = 'CRITICAL';
riskReason = `휴어기 조업 의심 (${meta.permitCode} 허가기간: ${PERMIT_PERIODS[meta.permitCode]?.label})`;
}
// AIS 신호 오래된 경우 (다크베셀 의심)
const aisAge = Date.now() - ship.lastSeen;
if (aisAge > 6 * 3600_000) {
riskLevel = 'HIGH';
riskReason = `AIS 공백 ${Math.round(aisAge / 3600_000)}시간 — 다크베셀 의심`;
}
// 자망 + 조업 중 → AIS 차단 가능성 높음
if (gearType === 'gillnet' && isOperating) {
if (riskLevel === 'LOW') {
riskLevel = 'MEDIUM';
riskReason = '유자망 조업 중 — AIS 차단 주의 업종';
}
}
return {
gearType,
gearTypeKo: meta.ko,
permitCode: meta.permitCode,
gbCode: meta.gbCode,
isOperating,
operatingStatusKo,
confidence,
riskLevel,
riskReason,
};
}
/**
* 중국 어선 조업 통계 집계
*/
export function aggregateFishingStats(ships: Ship[]) {
// 중국 어선(fishing 카테고리)만 대상
const chineseFishing = ships.filter(s => s.flag === 'CN' && s.category === 'fishing');
const results = chineseFishing.map(s => ({ ship: s, analysis: analyzeFishing(s) }));
const operating = results.filter(r => r.analysis.isOperating);
const byGear: Record<FishingGearType, number> = {
trawl_pair: 0, trawl_single: 0, gillnet: 0, stow_net: 0, purse_seine: 0, carrier: 0, unknown: 0,
};
for (const r of operating) {
byGear[r.analysis.gearType]++;
}
const critical = results.filter(r => r.analysis.riskLevel === 'CRITICAL').length;
const high = results.filter(r => r.analysis.riskLevel === 'HIGH').length;
return {
total: chineseFishing.length,
operating: operating.length,
idle: chineseFishing.length - operating.length,
byGear,
critical,
high,
details: results,
};
}