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/CHANGELOG.md +14 -0
- package/README.md +78 -0
- package/index.js +1111 -0
- package/lib/angles.js +70 -0
- package/lib/calibration.js +360 -0
- package/lib/plugin-schema.js +14 -0
- package/lib/profile-store.js +150 -0
- package/lib/prometheus-history-provider.js +310 -0
- package/package.json +50 -0
- package/public/app.js +1062 -0
- package/public/icon.svg +12 -0
- package/public/index.html +179 -0
- package/public/remoteEntry.js +54 -0
- package/public/screenshot.png +0 -0
- package/public/styles.css +527 -0
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
|
+
}
|