You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
432 lines
18 KiB
432 lines
18 KiB
|
3 weeks ago
|
/**
|
||
|
|
* 冲突检测工具:时间冲突、空间冲突、频谱冲突
|
||
|
|
* - 时间:航线时间窗重叠、无法按时到达、提前到达、盘旋时间不足、资源占用缓冲
|
||
|
|
* - 空间:航迹最小间隔、摆放平台距离过小、禁限区入侵(考虑高度带)
|
||
|
|
* - 频谱:同频/邻频 + 时间 + 带宽 + 地理范围叠加判定,支持频谱资源台账
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** 冲突类型 */
|
||
|
|
export const CONFLICT_TYPE = {
|
||
|
|
TIME: 'time',
|
||
|
|
SPACE: 'space',
|
||
|
|
SPECTRUM: 'spectrum'
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 默认冲突配置(可由界面配置覆盖) */
|
||
|
|
export const defaultConflictConfig = {
|
||
|
|
// 时间
|
||
|
|
timeWindowOverlapMinutes: 0, // 时间窗重叠判定:两航线时间窗重叠即报(0=任意重叠)
|
||
|
|
resourceBufferMinutes: 0, // 资源占用前后缓冲(分钟)
|
||
|
|
// 空间
|
||
|
|
minTrackSeparationMeters: 5000, // 推演中两平台航迹最小间隔(米)
|
||
|
|
minPlatformPlacementMeters: 3000, // 摆放平台图标最小间距(米)
|
||
|
|
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
|
||
|
|
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
|
||
|
|
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
|
||
|
|
// 频谱
|
||
|
|
adjacentFreqGuardMHz: 5, // 邻频保护间隔(MHz),两频段中心差小于此视为邻频冲突
|
||
|
|
spectrumConflictGeoOverlap: true // 频谱冲突是否要求地理范围叠加
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 计算两点地表距离(米),近似球面
|
||
|
|
*/
|
||
|
|
export function distanceMeters(lng1, lat1, alt1, lng2, lat2, alt2) {
|
||
|
|
const R = 6371000 // 地球半径米
|
||
|
|
const toRad = x => (x * Math.PI) / 180
|
||
|
|
const φ1 = toRad(lat1)
|
||
|
|
const φ2 = toRad(lat2)
|
||
|
|
const Δφ = toRad(lat2 - lat1)
|
||
|
|
const Δλ = toRad(lng2 - lng1)
|
||
|
|
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2
|
||
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||
|
|
const horizontal = R * c
|
||
|
|
const dAlt = (alt2 != null ? Number(alt2) : 0) - (alt1 != null ? Number(alt1) : 0)
|
||
|
|
return Math.sqrt(horizontal * horizontal + dAlt * dAlt)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 两段时间段是否重叠 [s1,e1] 与 [s2,e2](分钟数)
|
||
|
|
*/
|
||
|
|
export function timeRangesOverlap(s1, e1, s2, e2, bufferMinutes = 0) {
|
||
|
|
const a1 = s1 - bufferMinutes
|
||
|
|
const b1 = e1 + bufferMinutes
|
||
|
|
const a2 = s2 - bufferMinutes
|
||
|
|
const b2 = e2 + bufferMinutes
|
||
|
|
return !(b1 < a2 || b2 < a1)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 时间冲突:航线时间窗重叠
|
||
|
|
* routes: [{ id, name, waypoints: [{ startTime }] }],waypoints 需含 startTime(如 K+00:10)
|
||
|
|
* waypointStartTimeToMinutes: (startTimeStr) => number
|
||
|
|
*/
|
||
|
|
export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, config = {}) {
|
||
|
|
const buffer = config.resourceBufferMinutes != null ? config.resourceBufferMinutes : defaultConflictConfig.resourceBufferMinutes
|
||
|
|
const list = []
|
||
|
|
const timeRanges = routes.map(r => {
|
||
|
|
if (!r.waypoints || r.waypoints.length === 0) return { routeId: r.id, routeName: r.name || `航线${r.id}`, min: 0, max: 0 }
|
||
|
|
const minutes = r.waypoints.map(w => waypointStartTimeToMinutes(w.startTime)).filter(m => Number.isFinite(m))
|
||
|
|
const min = minutes.length ? Math.min(...minutes) : 0
|
||
|
|
const max = minutes.length ? Math.max(...minutes) : 0
|
||
|
|
return { routeId: r.id, routeName: r.name || `航线${r.id}`, min, max }
|
||
|
|
})
|
||
|
|
for (let i = 0; i < timeRanges.length; i++) {
|
||
|
|
for (let j = i + 1; j < timeRanges.length; j++) {
|
||
|
|
const a = timeRanges[i]
|
||
|
|
const b = timeRanges[j]
|
||
|
|
if (a.min === a.max && b.min === b.max) continue
|
||
|
|
if (timeRangesOverlap(a.min, a.max, b.min, b.max, buffer)) {
|
||
|
|
list.push({
|
||
|
|
type: CONFLICT_TYPE.TIME,
|
||
|
|
subType: 'time_window_overlap',
|
||
|
|
title: '航线时间窗重叠',
|
||
|
|
routeIds: [a.routeId, b.routeId],
|
||
|
|
routeNames: [a.routeName, b.routeName],
|
||
|
|
time: `K+${formatMinutes(a.min)} ~ K+${formatMinutes(a.max)} 与 K+${formatMinutes(b.min)} ~ K+${formatMinutes(b.max)} 重叠`,
|
||
|
|
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。'
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatMinutes(m) {
|
||
|
|
const h = Math.floor(Math.abs(m) / 60)
|
||
|
|
const min = Math.floor(Math.abs(m) % 60)
|
||
|
|
const sign = m >= 0 ? '+' : '-'
|
||
|
|
return `${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 空间冲突:推演航迹最小间隔
|
||
|
|
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null
|
||
|
|
* routeIds: 参与推演的航线 id 列表
|
||
|
|
* minMinutes, maxMinutes: 推演时间范围(相对 K 分钟)
|
||
|
|
*/
|
||
|
|
export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, config = {}) {
|
||
|
|
const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters
|
||
|
|
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes
|
||
|
|
const list = []
|
||
|
|
const sampleTimes = []
|
||
|
|
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t)
|
||
|
|
for (let i = 0; i < routeIds.length; i++) {
|
||
|
|
for (let j = i + 1; j < routeIds.length; j++) {
|
||
|
|
const ridA = routeIds[i]
|
||
|
|
const ridB = routeIds[j]
|
||
|
|
for (const t of sampleTimes) {
|
||
|
|
const posA = getPositionAtMinutesFromK(ridA, t)
|
||
|
|
const posB = getPositionAtMinutesFromK(ridB, t)
|
||
|
|
if (!posA || !posB || posA.lng == null || posB.lng == null) continue
|
||
|
|
const d = distanceMeters(posA.lng, posA.lat, posA.alt, posB.lng, posB.lat, posB.alt)
|
||
|
|
if (d < minSep) {
|
||
|
|
list.push({
|
||
|
|
type: CONFLICT_TYPE.SPACE,
|
||
|
|
subType: 'track_separation',
|
||
|
|
title: '航迹间隔过小',
|
||
|
|
routeIds: [ridA, ridB],
|
||
|
|
time: `约 K+${formatMinutes(t)}`,
|
||
|
|
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
|
||
|
|
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
|
||
|
|
severity: 'high',
|
||
|
|
positionLng: (posA.lng + posB.lng) / 2,
|
||
|
|
positionLat: (posA.lat + posB.lat) / 2,
|
||
|
|
positionAlt: (posA.alt + posB.alt) / 2,
|
||
|
|
minutesFromK: t,
|
||
|
|
distanceMeters: d
|
||
|
|
})
|
||
|
|
break // 同一对航线只报一次(取首次发生时刻)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 空间冲突:摆放平台图标距离过小
|
||
|
|
* platformIcons: [{ id, lng, lat, name? }]
|
||
|
|
*/
|
||
|
|
export function detectPlatformPlacementTooClose(platformIcons, config = {}) {
|
||
|
|
const minDist = config.minPlatformPlacementMeters != null ? config.minPlatformPlacementMeters : defaultConflictConfig.minPlatformPlacementMeters
|
||
|
|
const list = []
|
||
|
|
for (let i = 0; i < platformIcons.length; i++) {
|
||
|
|
for (let j = i + 1; j < platformIcons.length; j++) {
|
||
|
|
const a = platformIcons[i]
|
||
|
|
const b = platformIcons[j]
|
||
|
|
const lng1 = Number(a.lng)
|
||
|
|
const lat1 = Number(a.lat)
|
||
|
|
const lng2 = Number(b.lng)
|
||
|
|
const lat2 = Number(b.lat)
|
||
|
|
if (!Number.isFinite(lng1) || !Number.isFinite(lat1) || !Number.isFinite(lng2) || !Number.isFinite(lat2)) continue
|
||
|
|
const d = distanceMeters(lng1, lat1, 0, lng2, lat2, 0)
|
||
|
|
if (d < minDist) {
|
||
|
|
list.push({
|
||
|
|
type: CONFLICT_TYPE.SPACE,
|
||
|
|
subType: 'platform_placement',
|
||
|
|
title: '平台摆放距离过小',
|
||
|
|
routeIds: [],
|
||
|
|
routeNames: [a.name || `平台${a.id}`, b.name || `平台${b.id}`],
|
||
|
|
position: `经度 ${((lng1 + lng2) / 2).toFixed(5)}°, 纬度 ${((lat1 + lat2) / 2).toFixed(5)}°`,
|
||
|
|
suggestion: `两平台距离约 ${(d / 1000).toFixed(1)} km,小于最小摆放间隔 ${minDist / 1000} km。建议移动其中一方或增大最小间隔配置。`,
|
||
|
|
severity: 'medium',
|
||
|
|
positionLng: (lng1 + lng2) / 2,
|
||
|
|
positionLat: (lat1 + lat2) / 2,
|
||
|
|
positionAlt: 0,
|
||
|
|
distanceMeters: d
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 点是否在多边形内(二维,不考虑高度)
|
||
|
|
* points: [{ lng, lat }] 或 [{ x, y }],顺时针或逆时针
|
||
|
|
*/
|
||
|
|
export function pointInPolygon(lng, lat, points) {
|
||
|
|
if (!points || points.length < 3) return false
|
||
|
|
const x = Number(lng)
|
||
|
|
const y = Number(lat)
|
||
|
|
let inside = false
|
||
|
|
const n = points.length
|
||
|
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||
|
|
const pi = points[i]
|
||
|
|
const pj = points[j]
|
||
|
|
const xi = pi.lng != null ? Number(pi.lng) : Number(pi.x)
|
||
|
|
const yi = pi.lat != null ? Number(pi.lat) : Number(pi.y)
|
||
|
|
const xj = pj.lng != null ? Number(pj.lng) : Number(pj.x)
|
||
|
|
const yj = pj.lat != null ? Number(pj.lat) : Number(pj.y)
|
||
|
|
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside
|
||
|
|
}
|
||
|
|
return inside
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 空间冲突:禁限区入侵(考虑高度带)
|
||
|
|
* 每条航线在推演时间内采样位置,若落入禁限区多边形且高度在 [altMin, altMax] 内则报
|
||
|
|
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } }
|
||
|
|
* restrictedZones: [{ name, points: [{lng,lat}], altMin?, altMax? }],points 可从 frontend_drawings 中 type 为 polygon 且 name 含禁限关键词的实体解析
|
||
|
|
*/
|
||
|
|
export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, restrictedZones, config = {}) {
|
||
|
|
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes
|
||
|
|
const useAlt = config.useRestrictedAltitudeBand !== false
|
||
|
|
const list = []
|
||
|
|
if (!restrictedZones || restrictedZones.length === 0) return list
|
||
|
|
for (const rid of routeIds) {
|
||
|
|
for (let t = minMinutes; t <= maxMinutes; t += step) {
|
||
|
|
const pos = getPositionAtMinutesFromK(rid, t)
|
||
|
|
if (!pos || pos.lng == null) continue
|
||
|
|
const alt = pos.alt != null ? Number(pos.alt) : 0
|
||
|
|
for (const zone of restrictedZones) {
|
||
|
|
const points = zone.points || zone.positions
|
||
|
|
if (!points || points.length < 3) continue
|
||
|
|
const inPoly = pointInPolygon(pos.lng, pos.lat, points)
|
||
|
|
if (!inPoly) continue
|
||
|
|
const altMin = zone.altMin != null ? Number(zone.altMin) : -Infinity
|
||
|
|
const altMax = zone.altMax != null ? Number(zone.altMax) : Infinity
|
||
|
|
const inAlt = !useAlt || (alt >= altMin && alt <= altMax)
|
||
|
|
if (inAlt) {
|
||
|
|
list.push({
|
||
|
|
type: CONFLICT_TYPE.SPACE,
|
||
|
|
subType: 'restricted_zone',
|
||
|
|
title: '禁限区入侵',
|
||
|
|
routeIds: [rid],
|
||
|
|
zoneName: zone.name || '禁限区',
|
||
|
|
time: `约 K+${formatMinutes(t)}`,
|
||
|
|
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`,
|
||
|
|
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`,
|
||
|
|
severity: 'high',
|
||
|
|
positionLng: pos.lng,
|
||
|
|
positionLat: pos.lat,
|
||
|
|
positionAlt: alt,
|
||
|
|
minutesFromK: t
|
||
|
|
})
|
||
|
|
break // 该航线该时刻只报一个区
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 从 frontend_drawings 的 entities 中解析禁限区(polygon/rectangle/circle/sector)
|
||
|
|
* entity: { type, label/name, points/positions, data: { altMin, altMax } }
|
||
|
|
*/
|
||
|
|
export function parseRestrictedZonesFromDrawings(entities, keywords = defaultConflictConfig.restrictedZoneNameKeywords) {
|
||
|
|
const zones = []
|
||
|
|
if (!entities || !Array.isArray(entities)) return zones
|
||
|
|
const nameMatches = (name) => {
|
||
|
|
const n = (name || '').toString()
|
||
|
|
return keywords.some(kw => n.includes(kw))
|
||
|
|
}
|
||
|
|
for (const e of entities) {
|
||
|
|
const name = (e.label || e.name || (e.data && e.data.name) || '').toString()
|
||
|
|
if (!nameMatches(name)) continue
|
||
|
|
let points = e.points || (e.data && e.data.points) || (e.positions && e.positions.map(p => {
|
||
|
|
if (p.lng != null) return { lng: p.lng, lat: p.lat }
|
||
|
|
if (p.x != null) return { lng: p.x, lat: p.y }
|
||
|
|
return null
|
||
|
|
}).filter(Boolean))
|
||
|
|
if (!points && e.data) {
|
||
|
|
if (e.type === 'circle' && e.data.center) {
|
||
|
|
const c = e.data.center
|
||
|
|
const r = (e.data.radius || 0) / 111320 // 约米转度
|
||
|
|
const lng = c.lng != null ? c.lng : c.x
|
||
|
|
const lat = c.lat != null ? c.lat : c.y
|
||
|
|
const n = 32
|
||
|
|
points = []
|
||
|
|
for (let i = 0; i <= n; i++) {
|
||
|
|
const angle = (i / n) * 2 * Math.PI
|
||
|
|
points.push({ lng: lng + r * Math.cos(angle), lat: lat + r * Math.sin(angle) })
|
||
|
|
}
|
||
|
|
} else if (e.type === 'rectangle' && (e.data.bounds || e.data.coordinates || (e.data.points && e.data.points.length >= 2))) {
|
||
|
|
const b = e.data.bounds || e.data.coordinates || {}
|
||
|
|
const west = b.west ?? b.minLng
|
||
|
|
const south = b.south ?? b.minLat
|
||
|
|
const east = b.east ?? b.maxLng
|
||
|
|
const north = b.north ?? b.maxLat
|
||
|
|
if (e.data.points && e.data.points.length >= 2) {
|
||
|
|
points = e.data.points
|
||
|
|
} else if (west != null && south != null && east != null && north != null) {
|
||
|
|
points = [
|
||
|
|
{ lng: west, lat: south },
|
||
|
|
{ lng: east, lat: south },
|
||
|
|
{ lng: east, lat: north },
|
||
|
|
{ lng: west, lat: north }
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!points || points.length < 3) continue
|
||
|
|
const altMin = e.data && e.data.altMin != null ? e.data.altMin : undefined
|
||
|
|
const altMax = e.data && e.data.altMax != null ? e.data.altMax : undefined
|
||
|
|
zones.push({ name, points, altMin, altMax })
|
||
|
|
}
|
||
|
|
return zones
|
||
|
|
}
|
||
|
|
|
||
|
|
// --------------- 频谱冲突与台账 ---------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 频谱资源台账项:同频/邻频 + 时间 + 带宽 + 地理范围
|
||
|
|
*/
|
||
|
|
export function createSpectrumLedgerEntry({ id, name, freqMHz, bandwidthMHz, startTimeMinutes, endTimeMinutes, geo }) {
|
||
|
|
return {
|
||
|
|
id: id || `spec_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||
|
|
name: name || '',
|
||
|
|
freqMHz: Number(freqMHz) || 0,
|
||
|
|
bandwidthMHz: Number(bandwidthMHz) || 0,
|
||
|
|
startTimeMinutes: Number(startTimeMinutes) || 0,
|
||
|
|
endTimeMinutes: Number(endTimeMinutes) || 0,
|
||
|
|
geo: geo || null // { type: 'bbox', minLng, minLat, maxLng, maxLat } 或 { type: 'polygon', points: [{lng,lat}] }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 两频段是否同频或邻频(中心频率差在带宽一半或 guard 内)
|
||
|
|
*/
|
||
|
|
export function isSameOrAdjacentFreq(freq1, bw1, freq2, bw2, guardMHz = defaultConflictConfig.adjacentFreqGuardMHz) {
|
||
|
|
const c1 = freq1
|
||
|
|
const c2 = freq2
|
||
|
|
const half = (bw1 + bw2) / 2
|
||
|
|
const gap = Math.abs(c1 - c2)
|
||
|
|
if (gap <= 1e-6) return true // 同频
|
||
|
|
if (gap <= half || gap <= guardMHz) return true // 重叠或邻频
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 两地理范围是否重叠(bbox 或 polygon 简化:bbox 与 bbox)
|
||
|
|
*/
|
||
|
|
export function geoOverlap(geo1, geo2) {
|
||
|
|
if (!geo1 || !geo2) return true // 未设范围视为全局重叠
|
||
|
|
const toBbox = (g) => {
|
||
|
|
if (g.type === 'bbox') return g
|
||
|
|
if (g.type === 'polygon' && g.points && g.points.length) {
|
||
|
|
const lngs = g.points.map(p => p.lng != null ? p.lng : p.x)
|
||
|
|
const lats = g.points.map(p => p.lat != null ? p.lat : p.y)
|
||
|
|
return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs), minLat: Math.min(...lats), maxLat: Math.max(...lats) }
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
const b1 = toBbox(geo1)
|
||
|
|
const b2 = toBbox(geo2)
|
||
|
|
if (!b1 || !b2) return true
|
||
|
|
const minLng1 = b1.minLng ?? b1.west
|
||
|
|
const maxLng1 = b1.maxLng ?? b1.east
|
||
|
|
const minLat1 = b1.minLat ?? b1.south
|
||
|
|
const maxLat1 = b1.maxLat ?? b1.north
|
||
|
|
const minLng2 = b2.minLng ?? b2.west
|
||
|
|
const maxLng2 = b2.maxLng ?? b2.east
|
||
|
|
const minLat2 = b2.minLat ?? b2.south
|
||
|
|
const maxLat2 = b2.maxLat ?? b2.north
|
||
|
|
return !(maxLng1 < minLng2 || maxLng2 < minLng1 || maxLat1 < minLat2 || maxLat2 < minLat1)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 频谱冲突检测:台账内两两判定 同频/邻频 + 时间重叠 + 地理重叠
|
||
|
|
* spectrumLedger: createSpectrumLedgerEntry 数组
|
||
|
|
*/
|
||
|
|
export function detectSpectrumConflicts(spectrumLedger, config = {}) {
|
||
|
|
const guard = config.adjacentFreqGuardMHz != null ? config.adjacentFreqGuardMHz : defaultConflictConfig.adjacentFreqGuardMHz
|
||
|
|
const checkGeo = config.spectrumConflictGeoOverlap !== false
|
||
|
|
const list = []
|
||
|
|
for (let i = 0; i < spectrumLedger.length; i++) {
|
||
|
|
for (let j = i + 1; j < spectrumLedger.length; j++) {
|
||
|
|
const a = spectrumLedger[i]
|
||
|
|
const b = spectrumLedger[j]
|
||
|
|
if (!isSameOrAdjacentFreq(a.freqMHz, a.bandwidthMHz, b.freqMHz, b.bandwidthMHz, guard)) continue
|
||
|
|
if (!timeRangesOverlap(a.startTimeMinutes, a.endTimeMinutes, b.startTimeMinutes, b.endTimeMinutes, 0)) continue
|
||
|
|
if (checkGeo && !geoOverlap(a.geo, b.geo)) continue
|
||
|
|
list.push({
|
||
|
|
type: CONFLICT_TYPE.SPECTRUM,
|
||
|
|
subType: 'spectrum_overlap',
|
||
|
|
title: '频谱冲突',
|
||
|
|
routeIds: [],
|
||
|
|
routeNames: [a.name || `频谱${a.id}`, b.name || `频谱${b.id}`],
|
||
|
|
time: `时间重叠:K+${formatMinutes(a.startTimeMinutes)}~K+${formatMinutes(a.endTimeMinutes)} 与 K+${formatMinutes(b.startTimeMinutes)}~K+${formatMinutes(b.endTimeMinutes)}`,
|
||
|
|
position: checkGeo && a.geo ? `地理范围重叠` : '时间与频段重叠',
|
||
|
|
suggestion: `同频/邻频且时间与地理范围重叠。建议错开使用时间、调整频点或带宽、或缩小地理范围。`,
|
||
|
|
severity: 'high',
|
||
|
|
spectrumIds: [a.id, b.id]
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 合并并规范化冲突列表,统一 id、severity、category 等供 UI 展示
|
||
|
|
*/
|
||
|
|
export function normalizeConflictList(conflictsByType, startId = 1) {
|
||
|
|
const list = []
|
||
|
|
let id = startId
|
||
|
|
for (const c of conflictsByType) {
|
||
|
|
list.push({
|
||
|
|
id: id++,
|
||
|
|
type: c.type || CONFLICT_TYPE.TIME,
|
||
|
|
subType: c.subType || '',
|
||
|
|
title: c.title || '冲突',
|
||
|
|
routeName: c.routeNames && c.routeNames.length ? c.routeNames.join('、') : c.routeName,
|
||
|
|
routeIds: c.routeIds || [],
|
||
|
|
fromWaypoint: c.fromWaypoint,
|
||
|
|
toWaypoint: c.toWaypoint,
|
||
|
|
time: c.time,
|
||
|
|
position: c.position,
|
||
|
|
suggestion: c.suggestion,
|
||
|
|
severity: c.severity || 'high',
|
||
|
|
positionLng: c.positionLng,
|
||
|
|
positionLat: c.positionLat,
|
||
|
|
positionAlt: c.positionAlt,
|
||
|
|
minutesFromK: c.minutesFromK,
|
||
|
|
holdCenter: c.holdCenter,
|
||
|
|
...c
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return list
|
||
|
|
}
|