Files
kapteins-daagbok/client/src/services/nmea/nmeaChangeDetection.ts
T
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00

217 lines
7.6 KiB
TypeScript

import { formatAppDecimal } from '../../utils/numberFormat.js'
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
function formatNmeaDecimal(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
events.push(event)
}
export function detectNmeaChanges(
points: NmeaTimePoint[],
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
): NmeaChangeEvent[] {
const events: NmeaChangeEvent[] = []
if (points.length < 2) return events
let lastCourse: number | undefined
let lastWindDir: number | undefined
let lastWindSpeed: number | undefined
let lastPressure: number | undefined
let lastDepth: number | undefined
let lastWaterTemp: number | undefined
let lastFix: boolean | undefined
let engineRunning = false
let autopilot: boolean | undefined
let underWay = false
let stoppedSince: number | null = null
let lastSog: number | undefined
for (const p of points) {
const course = p.cog ?? p.hdt ?? p.hdm
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
pushUnique(events, {
type: 'course',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_course',
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
data: p
}, config.dedupeWindowMs)
}
}
if (course != null) lastCourse = course
if (p.windDir != null && lastWindDir != null) {
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_wind',
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
data: p
}, config.dedupeWindowMs)
} else if (
p.windSpeedKnots != null &&
lastWindSpeed != null &&
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.windDir != null) lastWindDir = p.windDir
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
if (p.pressureHpa != null && lastPressure != null) {
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
pushUnique(events, {
type: 'pressure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.pressureHpa != null) lastPressure = p.pressureHpa
if (p.depthM != null && lastDepth != null) {
const delta = Math.abs(p.depthM - lastDepth)
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
pushUnique(events, {
type: 'depth',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.depthM != null) lastDepth = p.depthM
if (p.rpm != null) {
const running = p.rpm >= config.rpmRunning
const idle = p.rpm <= config.rpmIdle
if (running && !engineRunning) {
pushUnique(events, {
type: 'engine_start',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_start',
summaryParams: { rpm: Math.round(p.rpm) },
data: p
}, config.dedupeWindowMs)
engineRunning = true
} else if (idle && engineRunning) {
pushUnique(events, {
type: 'engine_stop',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_stop',
data: p
}, config.dedupeWindowMs)
engineRunning = false
}
}
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
pushUnique(events, {
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
data: p
}, config.dedupeWindowMs)
}
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
pushUnique(events, {
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
data: p
}, config.dedupeWindowMs)
}
if (p.fixValid != null) lastFix = p.fixValid
if (p.waterTempC != null && lastWaterTemp != null) {
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
pushUnique(events, {
type: 'water_temp',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
const sog = p.sog ?? 0
if (sog >= config.sogUnderWayKn && !underWay) {
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'departure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_departure',
data: p
}, config.dedupeWindowMs)
}
underWay = true
stoppedSince = null
}
if (sog <= config.sogStoppedKn && underWay) {
underWay = false
stoppedSince = p.timestamp
}
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'anchor',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_anchor',
data: p
}, config.dedupeWindowMs)
stoppedSince = null
}
}
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
pushUnique(events, {
type: 'speed',
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
data: p
}, config.dedupeWindowMs)
}
lastSog = sog
}
return events.sort((a, b) => a.timestamp - b.timestamp)
}