signalk-compass-calibrator 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1111 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { calibrate, compileCalibrationProfile, correctionForCompiledProfile } = require('./lib/calibration')
6
+ const { wrap360Rad, wrap180Deg, radToDeg } = require('./lib/angles')
7
+ const { DEFAULT_METRICS, PrometheusHistoryProvider } = require('./lib/prometheus-history-provider')
8
+ const { createProfileStore } = require('./lib/profile-store')
9
+ const { buildSchema } = require('./lib/plugin-schema')
10
+
11
+ const PLUGIN_ID = 'compass-calibrator'
12
+ const PUBLISH_SOURCE = 'signalk-compass-calibrator'
13
+ const PUBLISH_PATH = 'navigation.headingMagnetic'
14
+ const DISCOVERY_RESOLUTION_SECONDS = 30
15
+ const FINE_CALIBRATION_RESOLUTION_SECONDS = 1
16
+ const MAX_COARSE_SCAN_RESOLUTION_SECONDS = 60
17
+ const SEGMENT_BOUNDARY_PADDING_SECONDS = 60
18
+ const STABLE_COG_WINDOW_SECONDS = 15
19
+ const STABLE_COG_MIN_DURATION_SECONDS = 30
20
+ const STABLE_COG_MERGE_GAP_SECONDS = 5
21
+ const HEADING_COVERAGE_BIN_DEG = 10
22
+
23
+ const DEFAULT_OPTIONS = {
24
+ enabled: true,
25
+ prometheus: {
26
+ baseUrl: 'http://victoriametrics:8428',
27
+ type: 'victoriametrics',
28
+ auth: {
29
+ type: 'basic',
30
+ username: '',
31
+ password: ''
32
+ }
33
+ },
34
+ context: 'vessels.self',
35
+ metrics: DEFAULT_METRICS,
36
+ sources: {
37
+ heading: '',
38
+ cog: '',
39
+ sog: '',
40
+ variation: ''
41
+ },
42
+ filters: {
43
+ minSog: 1.5,
44
+ maxCogRate: 1,
45
+ maxSampleGapSeconds: 2,
46
+ minSegmentDuration: 30,
47
+ minSamplesPerBin: 10
48
+ },
49
+ calibration: {
50
+ binSize: 10,
51
+ smoothing: true,
52
+ interpolation: 'linear-circular'
53
+ },
54
+ publishing: {
55
+ enabled: true,
56
+ source: PUBLISH_SOURCE,
57
+ path: PUBLISH_PATH,
58
+ staleAfterSeconds: 10
59
+ }
60
+ }
61
+
62
+ module.exports = function createPlugin (app) {
63
+ let options = { ...DEFAULT_OPTIONS }
64
+ let store
65
+ let unsubscribes = []
66
+ let deltaListener = null
67
+ let activeProfile = null
68
+ let activeRuntimeProfile = null
69
+ let staleTimer = null
70
+ let lastPluginStatus = null
71
+ const liveSources = new Map()
72
+ const runtime = {
73
+ status: 'inactive',
74
+ activeProfileId: null,
75
+ inputSource: null,
76
+ lastRawHeading: null,
77
+ lastCorrection: null,
78
+ lastCalibratedHeading: null,
79
+ lastInputAt: null,
80
+ lastPublishedAt: null,
81
+ warnings: []
82
+ }
83
+
84
+ const plugin = {
85
+ id: PLUGIN_ID,
86
+ name: 'Compass Calibrator',
87
+ description: 'Calibrate a selected magnetic heading source using source-aware historical COG/SOG/variation data.',
88
+ schema: buildSchema,
89
+ start,
90
+ stop,
91
+ registerWithRouter
92
+ }
93
+
94
+ return plugin
95
+
96
+ function start (pluginOptions) {
97
+ options = mergeOptions(DEFAULT_OPTIONS, pluginOptions || {})
98
+ store = createProfileStore(app, PLUGIN_ID)
99
+ store.load()
100
+ setActiveProfile(store.active())
101
+ runtime.status = statusFromState()
102
+
103
+ if (options.enabled && options.publishing.enabled) {
104
+ subscribeToHeading()
105
+ staleTimer = setInterval(checkStaleInput, 1000)
106
+ }
107
+ setPluginStatus()
108
+ }
109
+
110
+ function stop () {
111
+ for (const unsubscribe of unsubscribes) {
112
+ try {
113
+ if (typeof unsubscribe === 'function') unsubscribe()
114
+ } catch (error) {
115
+ app.debug && app.debug(`Compass calibrator unsubscribe failed: ${error.message}`)
116
+ }
117
+ }
118
+ unsubscribes = []
119
+ if (deltaListener && typeof app.removeListener === 'function') {
120
+ app.removeListener('delta', deltaListener)
121
+ }
122
+ deltaListener = null
123
+ if (staleTimer) clearInterval(staleTimer)
124
+ staleTimer = null
125
+ runtime.status = 'inactive'
126
+ activeRuntimeProfile = null
127
+ setPluginStatus()
128
+ }
129
+
130
+ function subscribeToHeading () {
131
+ const subscription = {
132
+ context: options.context || 'vessels.self',
133
+ sourcePolicy: 'all',
134
+ subscribe: [
135
+ {
136
+ path: PUBLISH_PATH,
137
+ period: 500
138
+ }
139
+ ]
140
+ }
141
+
142
+ if (app.subscriptionmanager && typeof app.subscriptionmanager.subscribe === 'function') {
143
+ app.subscriptionmanager.subscribe(
144
+ subscription,
145
+ unsubscribes,
146
+ error => {
147
+ runtime.status = 'error'
148
+ app.error && app.error(`Compass calibrator subscription failed: ${error.message || error}`)
149
+ },
150
+ handleDelta
151
+ )
152
+ return
153
+ }
154
+
155
+ if (typeof app.on === 'function') {
156
+ deltaListener = handleDelta
157
+ app.on('delta', deltaListener)
158
+ }
159
+ }
160
+
161
+ function handleDelta (delta) {
162
+ for (const update of delta.updates || []) {
163
+ const source = update.$source || update.source && update.source.label
164
+ if (!source || source === options.publishing.source) continue
165
+ recordLiveSource(source, update.values || [])
166
+
167
+ const configuredSource = getRuntimeInputSource()
168
+ if (!configuredSource || source !== configuredSource) continue
169
+
170
+ const headingValue = (update.values || []).find(value => value.path === PUBLISH_PATH)
171
+ if (!headingValue || !Number.isFinite(headingValue.value)) continue
172
+
173
+ runtime.lastInputAt = new Date().toISOString()
174
+ runtime.inputSource = source
175
+ runtime.lastRawHeading = headingValue.value
176
+ publishCorrectedHeading(source, headingValue.value)
177
+ }
178
+ }
179
+
180
+ function publishCorrectedHeading (source, rawHeadingRad) {
181
+ if (!activeRuntimeProfile && store) setActiveProfile(store.active())
182
+ if (!activeRuntimeProfile) {
183
+ runtime.status = 'noProfile'
184
+ return
185
+ }
186
+
187
+ const correctionRad = correctionForCompiledProfile(activeRuntimeProfile, rawHeadingRad)
188
+ if (!Number.isFinite(correctionRad)) {
189
+ runtime.status = 'outsideReliableRange'
190
+ return
191
+ }
192
+
193
+ const calibrated = wrap360Rad(rawHeadingRad + correctionRad)
194
+ runtime.status = 'publishing'
195
+ runtime.activeProfileId = activeRuntimeProfile.id
196
+ runtime.lastCorrection = correctionRad
197
+ runtime.lastCalibratedHeading = calibrated
198
+ runtime.lastPublishedAt = new Date().toISOString()
199
+
200
+ app.handleMessage(PLUGIN_ID, {
201
+ updates: [
202
+ {
203
+ $source: options.publishing.source,
204
+ values: [
205
+ { path: options.publishing.path, value: calibrated }
206
+ ]
207
+ }
208
+ ]
209
+ })
210
+ setPluginStatus()
211
+ }
212
+
213
+ function checkStaleInput () {
214
+ if (!runtime.lastInputAt) {
215
+ runtime.status = activeProfile ? 'missingInput' : 'noProfile'
216
+ setPluginStatus()
217
+ return
218
+ }
219
+ const ageSeconds = (Date.now() - new Date(runtime.lastInputAt).getTime()) / 1000
220
+ if (ageSeconds > options.publishing.staleAfterSeconds) {
221
+ runtime.status = 'staleInput'
222
+ setPluginStatus()
223
+ }
224
+ }
225
+
226
+ function registerWithRouter (router) {
227
+ router.get('/', (req, res) => sendPublicFile(res, 'index.html', 'text/html; charset=utf-8'))
228
+ router.get('/app.js', (req, res) => sendPublicFile(res, 'app.js', 'application/javascript; charset=utf-8'))
229
+ router.get('/styles.css', (req, res) => sendPublicFile(res, 'styles.css', 'text/css; charset=utf-8'))
230
+
231
+ router.get('/api/sources', asyncRoute(async () => ({
232
+ live: Array.from(liveSources.values()),
233
+ selected: options.sources,
234
+ context: options.context,
235
+ prometheus: {
236
+ baseUrl: options.prometheus.baseUrl,
237
+ type: options.prometheus.type,
238
+ auth: {
239
+ type: options.prometheus.auth && options.prometheus.auth.type || 'basic',
240
+ username: options.prometheus.auth && options.prometheus.auth.username || ''
241
+ }
242
+ },
243
+ metrics: options.metrics
244
+ })))
245
+
246
+ router.post('/api/discover', asyncRoute(async req => {
247
+ const body = await readBody(req)
248
+ const provider = makeProvider(body)
249
+ const range = body.range || { from: body.from, to: body.to }
250
+ const paths = body.paths || [
251
+ 'navigation.headingMagnetic',
252
+ 'navigation.courseOverGroundTrue',
253
+ 'navigation.speedOverGround',
254
+ 'navigation.magneticVariation'
255
+ ]
256
+ const resolutionSeconds = body.resolutionSeconds || DISCOVERY_RESOLUTION_SECONDS
257
+ const discovery = await discoverHistoricalContexts(provider, paths, range, resolutionSeconds)
258
+ const recommendations = discovery.contextSummary.selectedContext
259
+ ? await buildRecommendations(provider.withContext(discovery.contextSummary.selectedContext), discovery.paths, range).catch(error => ({ error: error.message }))
260
+ : { error: 'No common context found across required paths' }
261
+ return {
262
+ selectedContext: discovery.contextSummary.selectedContext,
263
+ contextSummary: discovery.contextSummary,
264
+ paths: discovery.paths,
265
+ recommendations
266
+ }
267
+ }))
268
+
269
+ router.post('/api/calibrate', asyncRoute(async req => {
270
+ const body = await readBody(req)
271
+ const provider = makeProvider(body)
272
+ const range = body.range || { from: body.from, to: body.to }
273
+ const sources = { ...options.sources, ...(body.sources || {}) }
274
+ assertSources(sources)
275
+ const filters = { ...options.filters, ...(body.filters || {}) }
276
+ const { series, segments } = await fetchCalibrationSeries(provider, sources, range, filters)
277
+ filters.segments = calibrationSegmentsFromNavigationSegments(segments)
278
+ .filter(segment => segment.quality !== 'rejected')
279
+ .map(segment => ({
280
+ from: segment.from,
281
+ to: segment.to,
282
+ minSog: segment.minSog,
283
+ maxCogRate: segment.maxCogRate,
284
+ quality: segment.quality,
285
+ reason: segment.reason || null
286
+ }))
287
+ const profile = calibrate(series, {
288
+ id: body.id,
289
+ range,
290
+ sources,
291
+ filters,
292
+ calibration: { ...options.calibration, ...(body.calibration || {}) }
293
+ })
294
+ profile.segments = segments
295
+ return profile
296
+ }))
297
+
298
+ router.get('/api/profiles', asyncRoute(async () => ({
299
+ activeProfileId: store.active() ? store.active().id : null,
300
+ activeInputSource: store.activeInputSource(),
301
+ profiles: store.list().map(profileSummary)
302
+ })))
303
+
304
+ router.post('/api/profiles', asyncRoute(async req => {
305
+ const body = await readBody(req)
306
+ const profile = body.profile || body
307
+ if (!compileCalibrationProfile(profile)) throw httpError(400, 'Profile has no usable runtime correction table')
308
+ return store.saveProfile(profile)
309
+ }))
310
+
311
+ router.get('/api/profiles/:id', asyncRoute(async req => {
312
+ const profile = store.get(req.params.id)
313
+ if (!profile) throw httpError(404, 'Profile not found')
314
+ return profile
315
+ }))
316
+
317
+ router.post('/api/profiles/:id/activate', asyncRoute(async req => {
318
+ const candidate = store.get(req.params.id)
319
+ if (!candidate) throw httpError(404, 'Profile not found')
320
+ if (!compileCalibrationProfile(candidate)) throw httpError(400, 'Profile has no usable runtime correction table')
321
+ const profile = store.activate(req.params.id)
322
+ setActiveProfile(profile)
323
+ runtime.status = statusFromState()
324
+ setPluginStatus()
325
+ return profile
326
+ }))
327
+
328
+ router.post('/api/profiles/:id/archive', asyncRoute(async req => {
329
+ const profile = store.archive(req.params.id)
330
+ if (!profile) throw httpError(404, 'Profile not found')
331
+ if (activeProfile && activeProfile.id === req.params.id) setActiveProfile(null)
332
+ runtime.status = statusFromState()
333
+ setPluginStatus()
334
+ return profile
335
+ }))
336
+
337
+ router.post('/api/profiles/:id/reject', asyncRoute(async req => {
338
+ const profile = store.reject(req.params.id)
339
+ if (!profile) throw httpError(404, 'Profile not found')
340
+ if (activeProfile && activeProfile.id === req.params.id) setActiveProfile(null)
341
+ runtime.status = statusFromState()
342
+ setPluginStatus()
343
+ return profile
344
+ }))
345
+
346
+ router.delete('/api/profiles/:id', asyncRoute(async req => {
347
+ const deleted = store.delete(req.params.id)
348
+ if (!deleted) throw httpError(404, 'Profile not found')
349
+ if (activeProfile && activeProfile.id === req.params.id) setActiveProfile(null)
350
+ runtime.status = statusFromState()
351
+ setPluginStatus()
352
+ return { deleted: true }
353
+ }))
354
+
355
+ router.get('/api/runtime', asyncRoute(async () => ({
356
+ ...runtime,
357
+ activeProfile: activeProfile ? profileSummary(activeProfile) : null,
358
+ activeInputSource: getRuntimeInputSource(),
359
+ profiles: store.list().map(profileSummary),
360
+ liveSources: Array.from(liveSources.values()),
361
+ lastRawHeadingDeg: runtime.lastRawHeading === null ? null : radToDeg(runtime.lastRawHeading),
362
+ lastCorrectionDeg: runtime.lastCorrection === null ? null : radToDeg(runtime.lastCorrection),
363
+ lastCalibratedHeadingDeg: runtime.lastCalibratedHeading === null ? null : radToDeg(runtime.lastCalibratedHeading)
364
+ })))
365
+
366
+ router.post('/api/runtime/config', asyncRoute(async req => {
367
+ const body = await readBody(req)
368
+ const profileId = body.profileId || null
369
+ const inputSource = body.inputSource || null
370
+ if (!profileId) throw httpError(400, 'Missing calibration table')
371
+ if (!inputSource) throw httpError(400, 'Missing Signal K input source')
372
+ const profile = store.get(profileId)
373
+ if (!profile) throw httpError(404, 'Profile not found')
374
+ if (!compileCalibrationProfile(profile)) throw httpError(400, 'Profile has no usable runtime correction table')
375
+ store.configureRuntime(profileId, inputSource)
376
+ setActiveProfile(profile)
377
+ runtime.status = statusFromState()
378
+ setPluginStatus()
379
+ return {
380
+ ...runtime,
381
+ activeProfile: profileSummary(profile),
382
+ activeInputSource: getRuntimeInputSource()
383
+ }
384
+ }))
385
+
386
+ router.post('/api/runtime/disable', asyncRoute(async () => {
387
+ store.configureRuntime(null, null)
388
+ setActiveProfile(null)
389
+ runtime.status = statusFromState()
390
+ setPluginStatus()
391
+ return {
392
+ ...runtime,
393
+ activeProfile: null,
394
+ activeInputSource: null
395
+ }
396
+ }))
397
+ }
398
+
399
+ function makeProvider (body = {}) {
400
+ return new PrometheusHistoryProvider({
401
+ baseUrl: body.baseUrl || body.prometheus && body.prometheus.baseUrl || options.prometheus.baseUrl,
402
+ context: body.context || options.context,
403
+ metrics: { ...options.metrics, ...(body.metrics || {}) },
404
+ auth: body.auth || body.prometheus && body.prometheus.auth || options.prometheus.auth
405
+ })
406
+ }
407
+
408
+ async function fetchCalibrationSeries (provider, sources, range, filters = {}) {
409
+ const coarseSegments = await findUsefulSegments(provider, sources, range, filters)
410
+ const segments = []
411
+ const heading = []
412
+ const cog = []
413
+ const sog = []
414
+ const variation = []
415
+
416
+ for (const segment of coarseSegments) {
417
+ if (segment.quality === 'rejected') {
418
+ segments.push(segment)
419
+ continue
420
+ }
421
+ const [segmentHeading, segmentCog, segmentSog, segmentVariation] = await Promise.all([
422
+ provider.getSeriesChunked('navigation.headingMagnetic', sources.heading, segment, FINE_CALIBRATION_RESOLUTION_SECONDS),
423
+ provider.getSeriesChunked('navigation.courseOverGroundTrue', sources.cog, segment, FINE_CALIBRATION_RESOLUTION_SECONDS),
424
+ provider.getSeriesChunked('navigation.speedOverGround', sources.sog, segment, FINE_CALIBRATION_RESOLUTION_SECONDS),
425
+ provider.getSeriesChunked('navigation.magneticVariation', sources.variation, segment, FINE_CALIBRATION_RESOLUTION_SECONDS)
426
+ ])
427
+ const navigationSegment = analyzeSegmentSamples(segment, segmentSog, segmentCog, filters, FINE_CALIBRATION_RESOLUTION_SECONDS, 'fine')
428
+ navigationSegment.stableSegments = buildStableCogSegments(navigationSegment, segmentSog, segmentCog, segmentHeading, filters)
429
+ navigationSegment.headingBins = mergeHeadingBins(navigationSegment.stableSegments.map(stable => stable.headingBins))
430
+ navigationSegment.quality = navigationSegment.stableSegments.length > 0
431
+ ? navigationSegment.quality
432
+ : 'rejected'
433
+ navigationSegment.reason = navigationSegment.stableSegments.length > 0
434
+ ? navigationSegment.reason
435
+ : 'no stable COG sub-segment found'
436
+ navigationSegment.stats.stableSegmentCount = navigationSegment.stableSegments.length
437
+ navigationSegment.stats.acceptedSamples = navigationSegment.stableSegments.reduce((sum, stable) => sum + Number(stable.stats && stable.stats.samples && stable.stats.samples.heading || 0), 0)
438
+ segments.push(navigationSegment)
439
+ for (const stableSegment of navigationSegment.stableSegments) {
440
+ heading.push(...samplesInRange(segmentHeading, stableSegment))
441
+ cog.push(...samplesInRange(segmentCog, stableSegment))
442
+ sog.push(...samplesInRange(segmentSog, stableSegment))
443
+ variation.push(...samplesInRange(segmentVariation, stableSegment))
444
+ }
445
+ }
446
+
447
+ return {
448
+ series: {
449
+ heading: dedupeSamples(heading),
450
+ cog: dedupeSamples(cog),
451
+ sog: dedupeSamples(sog),
452
+ variation: dedupeSamples(variation)
453
+ },
454
+ segments
455
+ }
456
+ }
457
+
458
+ async function findUsefulSegments (provider, sources, range, filters = {}) {
459
+ const from = new Date(range.from).getTime()
460
+ const to = new Date(range.to).getTime()
461
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw httpError(400, 'Invalid calibration range')
462
+
463
+ const durationSeconds = Math.max((to - from) / 1000, 1)
464
+ const coarseResolution = Math.max(DISCOVERY_RESOLUTION_SECONDS, Math.min(MAX_COARSE_SCAN_RESOLUTION_SECONDS, Math.ceil(durationSeconds / 3000)))
465
+ const minSog = Number(filters.minSog || DEFAULT_OPTIONS.filters.minSog)
466
+ const movementDetectionSog = Math.max(0.3, Math.min(minSog, 0.8))
467
+ const minSegmentDuration = Number(filters.minSegmentDuration || DEFAULT_OPTIONS.filters.minSegmentDuration)
468
+ const coarseSog = await provider.getSeriesChunked('navigation.speedOverGround', sources.sog, range, coarseResolution, 10000)
469
+ if (coarseSog.length === 0) return [analyzeSegmentSamples(range, [], [], filters, coarseResolution, 'coarse')]
470
+
471
+ const paddingMs = coarseResolution * 2000
472
+ const segments = []
473
+ let start = null
474
+ let last = null
475
+ for (const sample of coarseSog) {
476
+ if (sample.value >= movementDetectionSog) {
477
+ if (start === null) start = sample.t
478
+ last = sample.t
479
+ } else if (start !== null) {
480
+ addSegment(segments, start, last, from, to, paddingMs, minSegmentDuration)
481
+ start = null
482
+ last = null
483
+ }
484
+ }
485
+ if (start !== null) addSegment(segments, start, last, from, to, paddingMs, minSegmentDuration)
486
+ if (segments.length === 0) {
487
+ return [{
488
+ from: new Date(from).toISOString(),
489
+ to: new Date(to).toISOString(),
490
+ quality: 'rejected',
491
+ reason: 'no SOG movement found',
492
+ stats: {
493
+ durationSeconds: Math.round(durationSeconds),
494
+ analysisPass: 'coarse',
495
+ analysisResolutionSeconds: coarseResolution,
496
+ samples: {
497
+ sog: coarseSog.length,
498
+ cog: 0
499
+ }
500
+ }
501
+ }]
502
+ }
503
+
504
+ const merged = mergeSegments(segments, coarseResolution * 2000)
505
+ const refined = []
506
+ for (const segment of merged) {
507
+ refined.push(await refineSegmentBoundaries(provider, sources, segment, range, movementDetectionSog, minSegmentDuration))
508
+ }
509
+ return refined
510
+ }
511
+
512
+ async function refineSegmentBoundaries (provider, sources, segment, fullRange, movementDetectionSog, minSegmentDuration) {
513
+ const samples = await provider.getSeriesChunked(
514
+ 'navigation.speedOverGround',
515
+ sources.sog,
516
+ segment,
517
+ FINE_CALIBRATION_RESOLUTION_SECONDS,
518
+ 10000
519
+ ).catch(() => [])
520
+ const moving = samples.filter(sample => Number.isFinite(sample.value) && sample.value >= movementDetectionSog)
521
+ if (moving.length === 0) {
522
+ return {
523
+ ...segment,
524
+ coarseFrom: segment.from,
525
+ coarseTo: segment.to,
526
+ quality: 'rejected',
527
+ reason: 'no fine SOG movement found',
528
+ stats: { samples: { sog: samples.length, cog: 0 } }
529
+ }
530
+ }
531
+
532
+ const rangeFrom = new Date(fullRange.from).getTime()
533
+ const rangeTo = new Date(fullRange.to).getTime()
534
+ const movementFrom = moving[0].t
535
+ const movementTo = moving[moving.length - 1].t
536
+ const paddingMs = SEGMENT_BOUNDARY_PADDING_SECONDS * 1000
537
+ const refinedFrom = Math.max(rangeFrom, movementFrom - paddingMs)
538
+ const refinedTo = Math.min(rangeTo, movementTo + paddingMs)
539
+ const durationSeconds = (refinedTo - refinedFrom) / 1000
540
+
541
+ return {
542
+ ...segment,
543
+ coarseFrom: segment.from,
544
+ coarseTo: segment.to,
545
+ movementFrom: new Date(movementFrom).toISOString(),
546
+ movementTo: new Date(movementTo).toISOString(),
547
+ from: new Date(refinedFrom).toISOString(),
548
+ to: new Date(refinedTo).toISOString(),
549
+ boundaryResolutionSeconds: FINE_CALIBRATION_RESOLUTION_SECONDS,
550
+ boundaryPaddingSeconds: SEGMENT_BOUNDARY_PADDING_SECONDS,
551
+ quality: durationSeconds < minSegmentDuration ? 'rejected' : 'candidate',
552
+ reason: durationSeconds < minSegmentDuration ? 'too short after boundary refinement' : null,
553
+ stats: {
554
+ durationSeconds: Math.round(durationSeconds),
555
+ samples: {
556
+ sog: samples.length,
557
+ cog: 0
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ function analyzeSegmentSamples (segment, sog, cog, filters = {}, resolutionSeconds = 1, pass = 'fine') {
564
+ const speeds = sog.map(sample => sample.value).filter(value => Number.isFinite(value)).sort((a, b) => a - b)
565
+ const cogRates = cogRatesDegPerSecond(cog).sort((a, b) => a - b)
566
+ const minSog = round1(clamp(percentile(speeds.filter(value => value > 0.2), pass === 'fine' ? 0.20 : 0.25) || filters.minSog || DEFAULT_OPTIONS.filters.minSog, 0.8, 3))
567
+ const localCogRate = round1(clamp(percentile(cogRates, pass === 'fine' ? 0.90 : 0.75) || filters.maxCogRate || DEFAULT_OPTIONS.filters.maxCogRate, 0.5, 6))
568
+ const medianCogRate = percentile(cogRates, 0.50) || 0
569
+ const p90CogRate = percentile(cogRates, 0.90) || 0
570
+ const durationSeconds = (new Date(segment.to).getTime() - new Date(segment.from).getTime()) / 1000
571
+ const quality = durationSeconds < Number(filters.minSegmentDuration || DEFAULT_OPTIONS.filters.minSegmentDuration) || speeds.length < 3 || cog.length < 3
572
+ ? 'rejected'
573
+ : p90CogRate > 4
574
+ ? 'weak'
575
+ : 'good'
576
+
577
+ return {
578
+ ...segment,
579
+ minSog,
580
+ maxCogRate: localCogRate,
581
+ quality,
582
+ reason: quality === 'rejected'
583
+ ? 'too short or sparse'
584
+ : p90CogRate > localCogRate
585
+ ? 'high course variation; filtering at local threshold'
586
+ : null,
587
+ stats: {
588
+ durationSeconds: Math.round(durationSeconds),
589
+ analysisPass: pass,
590
+ analysisResolutionSeconds: Number(resolutionSeconds),
591
+ sogMedian: round1(percentile(speeds, 0.50) || 0),
592
+ cogRateMedian: round1(medianCogRate),
593
+ cogRateP90: round1(p90CogRate),
594
+ samples: {
595
+ sog: sog.length,
596
+ cog: cog.length
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ function buildStableCogSegments (navigationSegment, sog, cog, heading, filters = {}) {
603
+ const minSog = Number(navigationSegment.minSog || filters.minSog || DEFAULT_OPTIONS.filters.minSog)
604
+ const threshold = stableCogRateThreshold(cog)
605
+ const sogByTime = new Map(sog.map(sample => [sample.t, sample.value]))
606
+ const states = []
607
+ const rateWindow = []
608
+ let windowSum = 0
609
+
610
+ const sortedCog = dedupeSamples(cog).sort((a, b) => a.t - b.t)
611
+ for (let index = 1; index < sortedCog.length; index += 1) {
612
+ const previous = sortedCog[index - 1]
613
+ const current = sortedCog[index]
614
+ const dt = (current.t - previous.t) / 1000
615
+ if (dt <= 0) continue
616
+ const rate = Math.abs(wrap180Deg(radToDeg(current.value - previous.value))) / dt
617
+ if (!Number.isFinite(rate)) continue
618
+ rateWindow.push({ t: current.t, rate })
619
+ windowSum += rate
620
+ while (rateWindow.length && current.t - rateWindow[0].t > STABLE_COG_WINDOW_SECONDS * 1000) {
621
+ windowSum -= rateWindow.shift().rate
622
+ }
623
+ const smoothedRate = rateWindow.length ? windowSum / rateWindow.length : rate
624
+ const speed = sogByTime.get(current.t)
625
+ states.push({
626
+ from: previous.t,
627
+ to: current.t,
628
+ stable: Number.isFinite(speed) && speed >= minSog && smoothedRate <= threshold,
629
+ smoothedRate
630
+ })
631
+ }
632
+
633
+ const rawSegments = segmentsFromStates(states, STABLE_COG_MIN_DURATION_SECONDS)
634
+ const merged = mergeSegments(
635
+ rawSegments.map(segment => ({
636
+ from: new Date(segment.from).toISOString(),
637
+ to: new Date(segment.to).toISOString()
638
+ })),
639
+ STABLE_COG_MERGE_GAP_SECONDS * 1000
640
+ )
641
+
642
+ return merged.map((segment, index) => {
643
+ const stableSog = samplesInRange(sog, segment)
644
+ const stableCog = samplesInRange(cog, segment)
645
+ const stableHeading = samplesInRange(heading, segment)
646
+ const analyzed = analyzeSegmentSamples(segment, stableSog, stableCog, {
647
+ ...filters,
648
+ minSog,
649
+ maxCogRate: threshold
650
+ }, FINE_CALIBRATION_RESOLUTION_SECONDS, 'stable')
651
+ const maxCogRate = round1(clamp(threshold * 1.2, 0.5, 2.5))
652
+ return {
653
+ ...analyzed,
654
+ id: `${navigationSegment.from}-${index + 1}`,
655
+ parentFrom: navigationSegment.from,
656
+ parentTo: navigationSegment.to,
657
+ maxCogRate,
658
+ stableCogThreshold: round1(threshold),
659
+ stableWindowSeconds: STABLE_COG_WINDOW_SECONDS,
660
+ headingBins: headingBinCounts(stableHeading, HEADING_COVERAGE_BIN_DEG),
661
+ stats: {
662
+ ...analyzed.stats,
663
+ samples: {
664
+ ...analyzed.stats.samples,
665
+ heading: stableHeading.length
666
+ }
667
+ }
668
+ }
669
+ }).filter(segment => segment.quality !== 'rejected')
670
+ }
671
+
672
+ function assertSources (sources) {
673
+ for (const key of ['heading', 'cog', 'sog', 'variation']) {
674
+ if (!sources[key]) throw httpError(400, `Missing ${key} source`)
675
+ }
676
+ }
677
+
678
+ function getRuntimeInputSource () {
679
+ return store && store.activeInputSource() || null
680
+ }
681
+
682
+ function setActiveProfile (profile) {
683
+ activeProfile = profile || null
684
+ activeRuntimeProfile = activeProfile ? compileCalibrationProfile(activeProfile) : null
685
+ runtime.activeProfileId = activeRuntimeProfile ? activeRuntimeProfile.id : null
686
+ runtime.inputSource = getRuntimeInputSource()
687
+ }
688
+
689
+ function recordLiveSource (source, values) {
690
+ const current = liveSources.get(source) || {
691
+ source,
692
+ paths: {},
693
+ firstSeen: new Date().toISOString()
694
+ }
695
+ current.lastSeen = new Date().toISOString()
696
+ for (const value of values) {
697
+ current.paths[value.path] = {
698
+ latestValue: value.value,
699
+ lastSeen: current.lastSeen
700
+ }
701
+ }
702
+ liveSources.set(source, current)
703
+ }
704
+
705
+ function statusFromState () {
706
+ if (!options.enabled || !options.publishing.enabled) return 'inactive'
707
+ if (!activeRuntimeProfile) return 'noProfile'
708
+ if (!getRuntimeInputSource()) return 'missingInput'
709
+ return 'active'
710
+ }
711
+
712
+ function setPluginStatus () {
713
+ if (typeof app.setPluginStatus === 'function') {
714
+ const statusText = `Compass calibrator: ${runtime.status}`
715
+ if (statusText !== lastPluginStatus) {
716
+ app.setPluginStatus(statusText)
717
+ lastPluginStatus = statusText
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ function asyncRoute (handler) {
724
+ return (req, res) => {
725
+ Promise.resolve(handler(req, res))
726
+ .then(payload => sendJson(res, payload))
727
+ .catch(error => sendJson(res, { error: error.message || String(error) }, error.statusCode || 500))
728
+ }
729
+ }
730
+
731
+ function sendJson (res, payload, statusCode = 200) {
732
+ res.statusCode = statusCode
733
+ if (typeof res.setHeader === 'function') res.setHeader('content-type', 'application/json; charset=utf-8')
734
+ res.end(JSON.stringify(payload))
735
+ }
736
+
737
+ function readBody (req) {
738
+ if (req.body !== undefined) return Promise.resolve(req.body || {})
739
+ return new Promise((resolve, reject) => {
740
+ let body = ''
741
+ req.on('data', chunk => {
742
+ body += chunk
743
+ })
744
+ req.on('end', () => {
745
+ if (!body) return resolve({})
746
+ try {
747
+ resolve(JSON.parse(body))
748
+ } catch (error) {
749
+ reject(httpError(400, 'Invalid JSON body'))
750
+ }
751
+ })
752
+ req.on('error', reject)
753
+ })
754
+ }
755
+
756
+ function sendPublicFile (res, filename, contentType) {
757
+ const filePath = path.join(__dirname, 'public', filename)
758
+ res.statusCode = 200
759
+ if (typeof res.setHeader === 'function') res.setHeader('content-type', contentType)
760
+ fs.createReadStream(filePath).on('error', () => {
761
+ res.statusCode = 404
762
+ res.end('Not found')
763
+ }).pipe(res)
764
+ }
765
+
766
+ function httpError (statusCode, message) {
767
+ const error = new Error(message)
768
+ error.statusCode = statusCode
769
+ return error
770
+ }
771
+
772
+ function mergeOptions (base, override) {
773
+ if (!override || typeof override !== 'object') return structuredCloneSafe(base)
774
+ const result = Array.isArray(base) ? [...base] : { ...base }
775
+ for (const [key, value] of Object.entries(override)) {
776
+ if (value && typeof value === 'object' && !Array.isArray(value) && base[key] && typeof base[key] === 'object') {
777
+ result[key] = mergeOptions(base[key], value)
778
+ } else {
779
+ result[key] = value
780
+ }
781
+ }
782
+ return result
783
+ }
784
+
785
+ function structuredCloneSafe (value) {
786
+ return JSON.parse(JSON.stringify(value))
787
+ }
788
+
789
+ async function discoverHistoricalContexts (provider, paths, range, resolutionSeconds) {
790
+ const rows = []
791
+ const errors = []
792
+ const pathContexts = new Map(paths.map(path => [path, new Set()]))
793
+ const expected = expectedSampleCount(range, resolutionSeconds)
794
+
795
+ for (const path of paths) {
796
+ const metric = provider.metricForPath(path)
797
+ let result
798
+ try {
799
+ result = await provider.queryRange(metric, range, resolutionSeconds)
800
+ } catch (error) {
801
+ errors.push({ path, metric, error: error.message })
802
+ continue
803
+ }
804
+
805
+ for (const series of result) {
806
+ const labels = series.metric || {}
807
+ const context = labels.context || ''
808
+ if (!context) continue
809
+ const samples = normalizePrometheusValues(series.values)
810
+ if (!samples.length) continue
811
+ pathContexts.get(path).add(context)
812
+ const latest = samples[samples.length - 1]
813
+ rows.push({
814
+ path,
815
+ metric,
816
+ context,
817
+ source: labels.source || '',
818
+ sampleCount: samples.length,
819
+ coveragePercent: expected ? Math.min(100, Math.round(samples.length / expected * 1000) / 10) : 0,
820
+ firstSample: new Date(samples[0].t).toISOString(),
821
+ lastSample: new Date(latest.t).toISOString(),
822
+ latestValue: latest.value
823
+ })
824
+ }
825
+ }
826
+
827
+ const allContexts = unique(rows.map(row => row.context).filter(Boolean))
828
+ const commonContexts = allContexts.filter(context => paths.every(path => pathContexts.get(path).has(context)))
829
+ const selectedContext = commonContexts[0] || allContexts[0] || null
830
+ const status = commonContexts.length === 1
831
+ ? 'ok'
832
+ : commonContexts.length === 0
833
+ ? 'error'
834
+ : 'warning'
835
+ const details = allContexts.map(context => ({
836
+ context,
837
+ paths: paths.map(path => ({
838
+ path,
839
+ sources: sortCoverageRows(rows.filter(row => row.context === context && row.path === path))
840
+ }))
841
+ }))
842
+
843
+ return {
844
+ contextSummary: {
845
+ status,
846
+ contexts: commonContexts,
847
+ allContexts,
848
+ selectedContext,
849
+ errors,
850
+ details
851
+ },
852
+ paths: rowsByPathForContext(rows, paths, selectedContext)
853
+ }
854
+ }
855
+
856
+ function rowsByPathForContext (rows, paths, context) {
857
+ const result = {}
858
+ for (const path of paths) {
859
+ result[path] = context ? sortCoverageRows(rows.filter(row => row.context === context && row.path === path)) : []
860
+ }
861
+ return result
862
+ }
863
+
864
+ function sortCoverageRows (rows) {
865
+ return [...rows].sort((a, b) => {
866
+ const scoreA = Number(a.coveragePercent || 0) * 1000000 + Number(a.sampleCount || 0)
867
+ const scoreB = Number(b.coveragePercent || 0) * 1000000 + Number(b.sampleCount || 0)
868
+ return scoreB - scoreA
869
+ })
870
+ }
871
+
872
+ function normalizePrometheusValues (values) {
873
+ return (values || [])
874
+ .map(([timestamp, value]) => ({
875
+ t: Number(timestamp) * 1000,
876
+ value: Number(value)
877
+ }))
878
+ .filter(sample => Number.isFinite(sample.t) && Number.isFinite(sample.value))
879
+ .sort((a, b) => a.t - b.t)
880
+ }
881
+
882
+ function expectedSampleCount (range, resolutionSeconds) {
883
+ const from = new Date(range.from).getTime()
884
+ const to = new Date(range.to).getTime()
885
+ const resolution = Number(resolutionSeconds || 1)
886
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from || !Number.isFinite(resolution) || resolution <= 0) return 0
887
+ return Math.max(Math.floor((to - from) / 1000 / resolution) + 1, 1)
888
+ }
889
+
890
+ function unique (values) {
891
+ return Array.from(new Set(values))
892
+ }
893
+
894
+ function addSegment (segments, start, end, rangeFrom, rangeTo, paddingMs, minDurationSeconds) {
895
+ if (end - start < minDurationSeconds * 1000) return
896
+ segments.push({
897
+ from: new Date(Math.max(rangeFrom, start - paddingMs)).toISOString(),
898
+ to: new Date(Math.min(rangeTo, end + paddingMs)).toISOString()
899
+ })
900
+ }
901
+
902
+ function mergeSegments (segments, mergeGapMs) {
903
+ const sorted = segments
904
+ .map(segment => ({
905
+ from: new Date(segment.from).getTime(),
906
+ to: new Date(segment.to).getTime()
907
+ }))
908
+ .filter(segment => Number.isFinite(segment.from) && Number.isFinite(segment.to) && segment.to >= segment.from)
909
+ .sort((a, b) => a.from - b.from)
910
+
911
+ const merged = []
912
+ for (const segment of sorted) {
913
+ const previous = merged[merged.length - 1]
914
+ if (previous && segment.from - previous.to <= mergeGapMs) {
915
+ previous.to = Math.max(previous.to, segment.to)
916
+ } else {
917
+ merged.push({ ...segment })
918
+ }
919
+ }
920
+
921
+ return merged.map(segment => ({
922
+ from: new Date(segment.from).toISOString(),
923
+ to: new Date(segment.to).toISOString()
924
+ }))
925
+ }
926
+
927
+ function segmentsFromStates (states, minDurationSeconds) {
928
+ const segments = []
929
+ let start = null
930
+ let last = null
931
+ for (const state of states) {
932
+ if (state.stable) {
933
+ if (start === null) start = state.from
934
+ last = state.to
935
+ } else if (start !== null) {
936
+ if (last - start >= minDurationSeconds * 1000) segments.push({ from: start, to: last })
937
+ start = null
938
+ last = null
939
+ }
940
+ }
941
+ if (start !== null && last - start >= minDurationSeconds * 1000) segments.push({ from: start, to: last })
942
+ return segments
943
+ }
944
+
945
+ function dedupeSamples (samples) {
946
+ const byTime = new Map()
947
+ for (const sample of samples) byTime.set(sample.t, sample)
948
+ return Array.from(byTime.values()).sort((a, b) => a.t - b.t)
949
+ }
950
+
951
+ function cogRatesDegPerSecond (samples) {
952
+ const rates = []
953
+ const sorted = dedupeSamples(samples).sort((a, b) => a.t - b.t)
954
+ for (let index = 1; index < sorted.length; index += 1) {
955
+ const dt = (sorted[index].t - sorted[index - 1].t) / 1000
956
+ if (dt <= 0) continue
957
+ const rate = Math.abs(wrap180Deg(radToDeg(sorted[index].value - sorted[index - 1].value))) / dt
958
+ if (Number.isFinite(rate) && rate > 0) rates.push(rate)
959
+ }
960
+ return rates
961
+ }
962
+
963
+ function stableCogRateThreshold (samples) {
964
+ const rates = cogRatesDegPerSecond(samples).sort((a, b) => a - b)
965
+ const median = percentile(rates, 0.50)
966
+ if (!Number.isFinite(median)) return 1.5
967
+ return clamp(median * 1.5, 0.5, 2)
968
+ }
969
+
970
+ function samplesInRange (samples, range) {
971
+ const from = new Date(range.from).getTime()
972
+ const to = new Date(range.to).getTime()
973
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return []
974
+ return samples.filter(sample => sample.t >= from && sample.t <= to)
975
+ }
976
+
977
+ function headingBinCounts (headingSamples, binSizeDeg) {
978
+ const binCount = Math.ceil(360 / binSizeDeg)
979
+ const bins = Array.from({ length: binCount }, (_, index) => ({
980
+ fromDeg: index * binSizeDeg,
981
+ toDeg: Math.min((index + 1) * binSizeDeg, 360),
982
+ samples: 0
983
+ }))
984
+ for (const sample of headingSamples) {
985
+ if (!Number.isFinite(sample.value)) continue
986
+ const headingDeg = radToDeg(wrap360Rad(sample.value))
987
+ const index = Math.min(Math.floor(headingDeg / binSizeDeg), binCount - 1)
988
+ bins[index].samples += 1
989
+ }
990
+ return bins
991
+ }
992
+
993
+ function mergeHeadingBins (binsList) {
994
+ const merged = headingBinCounts([], HEADING_COVERAGE_BIN_DEG)
995
+ for (const bins of binsList) {
996
+ if (!Array.isArray(bins)) continue
997
+ for (let index = 0; index < Math.min(merged.length, bins.length); index += 1) {
998
+ merged[index].samples += Number(bins[index].samples || 0)
999
+ }
1000
+ }
1001
+ return merged
1002
+ }
1003
+
1004
+ function calibrationSegmentsFromNavigationSegments (segments) {
1005
+ const flat = []
1006
+ for (const segment of segments || []) {
1007
+ if (Array.isArray(segment.stableSegments) && segment.stableSegments.length > 0) {
1008
+ flat.push(...segment.stableSegments)
1009
+ } else {
1010
+ flat.push(segment)
1011
+ }
1012
+ }
1013
+ return flat
1014
+ }
1015
+
1016
+ async function buildRecommendations (provider, discovered, range) {
1017
+ const sources = {
1018
+ heading: bestDiscoveredSource(discovered['navigation.headingMagnetic']),
1019
+ cog: bestDiscoveredSource(discovered['navigation.courseOverGroundTrue']),
1020
+ sog: bestDiscoveredSource(discovered['navigation.speedOverGround']),
1021
+ variation: bestDiscoveredSource(discovered['navigation.magneticVariation'])
1022
+ }
1023
+ const filters = {
1024
+ minSog: DEFAULT_OPTIONS.filters.minSog,
1025
+ maxCogRate: DEFAULT_OPTIONS.filters.maxCogRate,
1026
+ minSamplesPerBin: DEFAULT_OPTIONS.filters.minSamplesPerBin
1027
+ }
1028
+ const calibration = {
1029
+ binSize: DEFAULT_OPTIONS.calibration.binSize
1030
+ }
1031
+
1032
+ const from = new Date(range.from).getTime()
1033
+ const to = new Date(range.to).getTime()
1034
+ const durationSeconds = Math.max((to - from) / 1000, 1)
1035
+ const coarseResolution = Math.max(30, Math.ceil(durationSeconds / 2500))
1036
+ let movingSampleCount = 0
1037
+
1038
+ if (sources.sog) {
1039
+ const sog = await provider.getSeriesChunked('navigation.speedOverGround', sources.sog, range, coarseResolution, 10000)
1040
+ const speeds = sog.map(sample => sample.value).filter(value => Number.isFinite(value) && value > 0.2).sort((a, b) => a - b)
1041
+ movingSampleCount = speeds.length
1042
+ if (speeds.length > 0) {
1043
+ filters.minSog = round1(clamp(percentile(speeds, 0.35), 0.8, 3))
1044
+ }
1045
+ }
1046
+
1047
+ if (sources.cog) {
1048
+ const cog = await provider.getSeriesChunked('navigation.courseOverGroundTrue', sources.cog, range, coarseResolution, 10000)
1049
+ const usefulRates = cogRatesDegPerSecond(cog).sort((a, b) => a - b)
1050
+ if (usefulRates.length > 0) {
1051
+ filters.maxCogRate = round1(clamp(percentile(usefulRates, 0.65), 0.5, 3))
1052
+ }
1053
+ }
1054
+
1055
+ const headingSamples = discovered['navigation.headingMagnetic'] || []
1056
+ const totalHeadingSamples = headingSamples.reduce((sum, source) => sum + Number(source.sampleCount || 0), 0)
1057
+ const estimatedSamples = Math.max(totalHeadingSamples, movingSampleCount)
1058
+ if (estimatedSamples < 500) {
1059
+ calibration.binSize = 30
1060
+ filters.minSamplesPerBin = 5
1061
+ } else if (estimatedSamples < 1500) {
1062
+ calibration.binSize = 15
1063
+ filters.minSamplesPerBin = 8
1064
+ } else {
1065
+ calibration.binSize = 10
1066
+ filters.minSamplesPerBin = 10
1067
+ }
1068
+
1069
+ return {
1070
+ sources,
1071
+ filters,
1072
+ calibration
1073
+ }
1074
+ }
1075
+
1076
+ function bestDiscoveredSource (sources = []) {
1077
+ const best = [...sources].sort((a, b) => {
1078
+ const scoreA = Number(a.coveragePercent || 0) * 1000000 + Number(a.sampleCount || 0)
1079
+ const scoreB = Number(b.coveragePercent || 0) * 1000000 + Number(b.sampleCount || 0)
1080
+ return scoreB - scoreA
1081
+ })[0]
1082
+ return best ? best.source : ''
1083
+ }
1084
+
1085
+ function percentile (sortedValues, fraction) {
1086
+ if (!sortedValues.length) return null
1087
+ const index = Math.min(sortedValues.length - 1, Math.max(0, Math.floor((sortedValues.length - 1) * fraction)))
1088
+ return sortedValues[index]
1089
+ }
1090
+
1091
+ function clamp (value, min, max) {
1092
+ return Math.min(max, Math.max(min, value))
1093
+ }
1094
+
1095
+ function round1 (value) {
1096
+ return Math.round(value * 10) / 10
1097
+ }
1098
+
1099
+ function profileSummary (profile) {
1100
+ return {
1101
+ id: profile.id,
1102
+ createdAt: profile.createdAt,
1103
+ savedAt: profile.savedAt || null,
1104
+ displayName: profile.displayName || profile.savedAt || profile.createdAt,
1105
+ state: profile.state,
1106
+ range: profile.range,
1107
+ sources: profile.sources,
1108
+ quality: profile.quality,
1109
+ warnings: profile.warnings || []
1110
+ }
1111
+ }