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/lib/angles.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ const TAU = Math.PI * 2
4
+
5
+ function wrap360Rad (value) {
6
+ const wrapped = value % TAU
7
+ return wrapped < 0 ? wrapped + TAU : wrapped
8
+ }
9
+
10
+ function wrap180Rad (value) {
11
+ const wrapped = (value + Math.PI) % TAU
12
+ return (wrapped < 0 ? wrapped + TAU : wrapped) - Math.PI
13
+ }
14
+
15
+ function wrap360Deg (value) {
16
+ const wrapped = value % 360
17
+ return wrapped < 0 ? wrapped + 360 : wrapped
18
+ }
19
+
20
+ function wrap180Deg (value) {
21
+ const wrapped = (value + 180) % 360
22
+ return (wrapped < 0 ? wrapped + 360 : wrapped) - 180
23
+ }
24
+
25
+ function radToDeg (value) {
26
+ return value * 180 / Math.PI
27
+ }
28
+
29
+ function degToRad (value) {
30
+ return value * Math.PI / 180
31
+ }
32
+
33
+ function circularMeanDeg (values) {
34
+ if (!values.length) return null
35
+ let sin = 0
36
+ let cos = 0
37
+ for (const value of values) {
38
+ const radians = degToRad(value)
39
+ sin += Math.sin(radians)
40
+ cos += Math.cos(radians)
41
+ }
42
+ if (sin === 0 && cos === 0) return 0
43
+ return wrap180Deg(radToDeg(Math.atan2(sin / values.length, cos / values.length)))
44
+ }
45
+
46
+ function angularStddevDeg (values, meanDeg = circularMeanDeg(values)) {
47
+ if (!values.length || meanDeg === null) return null
48
+ const variance = values.reduce((sum, value) => {
49
+ const diff = wrap180Deg(value - meanDeg)
50
+ return sum + diff * diff
51
+ }, 0) / values.length
52
+ return Math.sqrt(variance)
53
+ }
54
+
55
+ function interpolateCircularDeg (fromDeg, toDeg, fraction) {
56
+ return wrap180Deg(fromDeg + wrap180Deg(toDeg - fromDeg) * fraction)
57
+ }
58
+
59
+ module.exports = {
60
+ TAU,
61
+ wrap360Rad,
62
+ wrap180Rad,
63
+ wrap360Deg,
64
+ wrap180Deg,
65
+ radToDeg,
66
+ degToRad,
67
+ circularMeanDeg,
68
+ angularStddevDeg,
69
+ interpolateCircularDeg
70
+ }
@@ -0,0 +1,360 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ wrap360Rad,
5
+ wrap180Rad,
6
+ wrap180Deg,
7
+ radToDeg,
8
+ degToRad,
9
+ circularMeanDeg,
10
+ angularStddevDeg,
11
+ interpolateCircularDeg
12
+ } = require('./angles')
13
+
14
+ const DEFAULT_FILTERS = {
15
+ minSog: 1.5,
16
+ maxCogRate: 1,
17
+ maxSampleGapSeconds: 2,
18
+ minSamplesPerBin: 10,
19
+ binSize: 10
20
+ }
21
+
22
+ function normalizeTimestamp (value) {
23
+ if (value instanceof Date) return value.getTime()
24
+ if (typeof value === 'string') return new Date(value).getTime()
25
+ if (typeof value !== 'number') return NaN
26
+ return value < 100000000000 ? value * 1000 : value
27
+ }
28
+
29
+ function normalizeSeries (series) {
30
+ return (series || [])
31
+ .map(sample => ({
32
+ t: normalizeTimestamp(sample.t ?? sample.ts ?? sample.time ?? sample.timestamp),
33
+ value: Number(sample.value)
34
+ }))
35
+ .filter(sample => Number.isFinite(sample.t) && Number.isFinite(sample.value))
36
+ .sort((a, b) => a.t - b.t)
37
+ }
38
+
39
+ function nearestSample (series, targetTime, maxGapMs) {
40
+ if (!series.length) return null
41
+ let lo = 0
42
+ let hi = series.length - 1
43
+ while (lo < hi) {
44
+ const mid = Math.floor((lo + hi) / 2)
45
+ if (series[mid].t < targetTime) lo = mid + 1
46
+ else hi = mid
47
+ }
48
+
49
+ const candidates = [series[lo]]
50
+ if (lo > 0) candidates.push(series[lo - 1])
51
+ let best = null
52
+ for (const candidate of candidates) {
53
+ if (!candidate) continue
54
+ const gap = Math.abs(candidate.t - targetTime)
55
+ if (gap <= maxGapMs && (!best || gap < best.gap)) best = { ...candidate, gap }
56
+ }
57
+ return best
58
+ }
59
+
60
+ function buildAlignedSamples (input, filters = {}) {
61
+ const effective = { ...DEFAULT_FILTERS, ...filters }
62
+ const maxGapMs = effective.maxSampleGapSeconds * 1000
63
+ const heading = normalizeSeries(input.heading)
64
+ const cog = normalizeSeries(input.cog)
65
+ const sog = normalizeSeries(input.sog)
66
+ const variation = normalizeSeries(input.variation)
67
+ const aligned = []
68
+ const rejected = {
69
+ missing: 0,
70
+ slow: 0,
71
+ unstableCog: 0
72
+ }
73
+
74
+ let previousAccepted = null
75
+ let previousSegmentIndex = null
76
+ for (const headingSample of heading) {
77
+ const localFilters = filtersForTime(headingSample.t, effective)
78
+ if (!localFilters) {
79
+ previousAccepted = null
80
+ previousSegmentIndex = null
81
+ rejected.missing += 1
82
+ continue
83
+ }
84
+ if (previousSegmentIndex !== null && localFilters.segmentIndex !== previousSegmentIndex) {
85
+ previousAccepted = null
86
+ }
87
+ const cogSample = nearestSample(cog, headingSample.t, maxGapMs)
88
+ const sogSample = nearestSample(sog, headingSample.t, maxGapMs)
89
+ const variationSample = nearestSample(variation, headingSample.t, maxGapMs)
90
+ if (!cogSample || !sogSample || !variationSample) {
91
+ rejected.missing += 1
92
+ continue
93
+ }
94
+ if (sogSample.value < localFilters.minSog) {
95
+ rejected.slow += 1
96
+ continue
97
+ }
98
+
99
+ if (previousAccepted) {
100
+ const dt = Math.max((headingSample.t - previousAccepted.t) / 1000, 0.001)
101
+ const cogRateDeg = Math.abs(wrap180Deg(radToDeg(cogSample.value - previousAccepted.cogRad))) / dt
102
+ if (cogRateDeg > localFilters.maxCogRate) {
103
+ rejected.unstableCog += 1
104
+ continue
105
+ }
106
+ }
107
+
108
+ const rawHeadingDeg = radToDeg(wrap360Rad(headingSample.value))
109
+ const headingTrueDeg = rawHeadingDeg + radToDeg(variationSample.value)
110
+ const cogTrueDeg = radToDeg(cogSample.value)
111
+ const errorDeg = wrap180Deg(headingTrueDeg - cogTrueDeg)
112
+ const correctionDeg = wrap180Deg(-errorDeg)
113
+ const sample = {
114
+ t: headingSample.t,
115
+ headingRad: wrap360Rad(headingSample.value),
116
+ headingDeg: rawHeadingDeg,
117
+ cogRad: cogSample.value,
118
+ cogDeg: radToDeg(cogSample.value),
119
+ sog: sogSample.value,
120
+ variationRad: variationSample.value,
121
+ variationDeg: radToDeg(variationSample.value),
122
+ errorDeg,
123
+ correctionDeg
124
+ }
125
+ aligned.push(sample)
126
+ previousAccepted = sample
127
+ previousSegmentIndex = localFilters.segmentIndex
128
+ }
129
+
130
+ return { samples: aligned, rejected }
131
+ }
132
+
133
+ function filtersForTime (time, filters) {
134
+ if (!Array.isArray(filters.segments) || filters.segments.length === 0) return filters
135
+ for (let index = 0; index < filters.segments.length; index += 1) {
136
+ const segment = filters.segments[index]
137
+ if (segment.quality === 'rejected') continue
138
+ const from = normalizeTimestamp(segment.from)
139
+ const to = normalizeTimestamp(segment.to)
140
+ if (time >= from && time <= to) {
141
+ return {
142
+ ...filters,
143
+ minSog: Number(segment.minSog ?? filters.minSog),
144
+ maxCogRate: Number(segment.maxCogRate ?? filters.maxCogRate),
145
+ segmentIndex: index
146
+ }
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ function buildBins (samples, filters = {}) {
153
+ const effective = { ...DEFAULT_FILTERS, ...filters }
154
+ const binSize = Number(effective.binSize) || DEFAULT_FILTERS.binSize
155
+ const binCount = Math.ceil(360 / binSize)
156
+ const minSamples = Number(effective.minSamplesPerBin) || DEFAULT_FILTERS.minSamplesPerBin
157
+ const bins = Array.from({ length: binCount }, (_, index) => ({
158
+ headingDeg: index * binSize,
159
+ fromDeg: index * binSize,
160
+ toDeg: Math.min((index + 1) * binSize, 360),
161
+ samples: 0,
162
+ quality: 'missing',
163
+ meanErrorDeg: null,
164
+ stddevDeg: null,
165
+ correctionDeg: null,
166
+ interpolated: false,
167
+ _errors: [],
168
+ _corrections: []
169
+ }))
170
+
171
+ for (const sample of samples) {
172
+ const index = Math.min(Math.floor(sample.headingDeg / binSize), binCount - 1)
173
+ bins[index].samples += 1
174
+ bins[index]._errors.push(sample.errorDeg)
175
+ bins[index]._corrections.push(sample.correctionDeg)
176
+ }
177
+
178
+ for (const bin of bins) {
179
+ if (bin.samples > 0) {
180
+ bin.meanErrorDeg = circularMeanDeg(bin._errors)
181
+ bin.stddevDeg = angularStddevDeg(bin._errors, bin.meanErrorDeg)
182
+ bin.correctionDeg = circularMeanDeg(bin._corrections)
183
+ bin.quality = bin.samples >= minSamples ? 'good' : 'weak'
184
+ }
185
+ }
186
+
187
+ interpolateMissingBins(bins)
188
+ return bins.map(({ _errors, _corrections, ...bin }) => bin)
189
+ }
190
+
191
+ function interpolateMissingBins (bins) {
192
+ const reliable = bins
193
+ .map((bin, index) => ({ ...bin, index }))
194
+ .filter(bin => bin.quality === 'good' && Number.isFinite(bin.correctionDeg))
195
+
196
+ if (reliable.length < 2) return
197
+
198
+ for (let index = 0; index < bins.length; index += 1) {
199
+ const bin = bins[index]
200
+ if (bin.quality === 'good') continue
201
+
202
+ let previous = null
203
+ let next = null
204
+ for (let offset = 1; offset <= bins.length; offset += 1) {
205
+ const candidate = reliable.find(item => item.index === (index - offset + bins.length) % bins.length)
206
+ if (candidate) {
207
+ previous = candidate
208
+ break
209
+ }
210
+ }
211
+ for (let offset = 1; offset <= bins.length; offset += 1) {
212
+ const candidate = reliable.find(item => item.index === (index + offset) % bins.length)
213
+ if (candidate) {
214
+ next = candidate
215
+ break
216
+ }
217
+ }
218
+
219
+ if (!previous || !next || previous.index === next.index) continue
220
+ const distanceToNext = (next.index - previous.index + bins.length) % bins.length
221
+ const distanceFromPrevious = (index - previous.index + bins.length) % bins.length
222
+ const fraction = distanceFromPrevious / distanceToNext
223
+ bin.correctionDeg = interpolateCircularDeg(previous.correctionDeg, next.correctionDeg, fraction)
224
+ bin.interpolated = true
225
+ }
226
+ }
227
+
228
+ function buildQuality (samples, bins) {
229
+ const goodBins = bins.filter(bin => bin.quality === 'good')
230
+ const errors = samples.map(sample => sample.errorDeg)
231
+ const meanErrorDeg = circularMeanDeg(errors)
232
+ const stddevDeg = angularStddevDeg(errors, meanErrorDeg)
233
+ const coverageDeg = goodBins.reduce((sum, bin) => sum + (bin.toDeg - bin.fromDeg), 0)
234
+ const interpolatedBinCount = bins.filter(bin => bin.interpolated).length
235
+
236
+ return {
237
+ sampleCount: samples.length,
238
+ usableBinCount: goodBins.length,
239
+ coverageDeg,
240
+ meanErrorDeg,
241
+ stddevDeg,
242
+ interpolatedBinCount
243
+ }
244
+ }
245
+
246
+ function buildWarnings (profile, filters = {}) {
247
+ const effective = { ...DEFAULT_FILTERS, ...filters }
248
+ const warnings = []
249
+ if (profile.quality.sampleCount < effective.minSamplesPerBin * 4) {
250
+ warnings.push('Too few accepted samples for a robust calibration.')
251
+ }
252
+ if (profile.quality.coverageDeg < 180) {
253
+ warnings.push('Angular coverage is poor; many headings rely on interpolation or have no support.')
254
+ }
255
+ if (profile.quality.usableBinCount < Math.ceil(360 / effective.binSize / 2)) {
256
+ warnings.push('Fewer than half of the heading bins are reliable.')
257
+ }
258
+ if (profile.quality.stddevDeg !== null && profile.quality.stddevDeg > 8) {
259
+ warnings.push('The calibration error has a high standard deviation.')
260
+ }
261
+ if (profile.sources && profile.sources.cog && profile.sources.sog && profile.sources.cog !== profile.sources.sog) {
262
+ warnings.push('COG and SOG sources differ.')
263
+ }
264
+ return warnings
265
+ }
266
+
267
+ function calibrate (input, options = {}) {
268
+ const filters = { ...DEFAULT_FILTERS, ...(options.filters || {}), ...(options.calibration || {}) }
269
+ const { samples, rejected } = buildAlignedSamples(input, filters)
270
+ const correctionTable = buildBins(samples, filters)
271
+ const profile = {
272
+ id: options.id || `profile-${new Date().toISOString().replace(/[:.]/g, '-')}`,
273
+ createdAt: new Date().toISOString(),
274
+ state: options.state || 'candidate',
275
+ range: options.range || null,
276
+ sources: options.sources || {},
277
+ filters,
278
+ quality: buildQuality(samples, correctionTable),
279
+ rejected,
280
+ correctionTable
281
+ }
282
+ profile.warnings = buildWarnings(profile, filters)
283
+ return profile
284
+ }
285
+
286
+ function correctionForHeadingRad (profile, headingRad) {
287
+ return correctionForCompiledProfile(compileCalibrationProfile(profile), headingRad)
288
+ }
289
+
290
+ function compileCalibrationProfile (profile) {
291
+ if (!profile || !Array.isArray(profile.correctionTable) || profile.correctionTable.length === 0) return null
292
+
293
+ const bins = []
294
+ for (const bin of profile.correctionTable) {
295
+ if (!Number.isFinite(bin.headingDeg) || !Number.isFinite(bin.correctionDeg) || bin.quality === 'rejected') continue
296
+ bins.push({
297
+ headingRad: wrap360Rad(degToRad(bin.headingDeg)),
298
+ correctionRad: wrap180Rad(degToRad(bin.correctionDeg))
299
+ })
300
+ }
301
+ bins.sort((a, b) => a.headingRad - b.headingRad)
302
+ if (bins.length === 0) return null
303
+
304
+ const headingsRad = new Float64Array(bins.length)
305
+ const correctionsRad = new Float64Array(bins.length)
306
+ for (let index = 0; index < bins.length; index += 1) {
307
+ headingsRad[index] = bins[index].headingRad
308
+ correctionsRad[index] = bins[index].correctionRad
309
+ }
310
+
311
+ return {
312
+ id: profile.id,
313
+ source: profile.sources && profile.sources.heading || null,
314
+ headingsRad,
315
+ correctionsRad
316
+ }
317
+ }
318
+
319
+ function correctionForCompiledProfile (compiled, headingRad) {
320
+ if (!compiled || !compiled.headingsRad || !compiled.correctionsRad) return null
321
+
322
+ const headings = compiled.headingsRad
323
+ const corrections = compiled.correctionsRad
324
+ const count = headings.length
325
+ if (count === 0) return null
326
+ if (count === 1) return corrections[0]
327
+
328
+ const heading = wrap360Rad(headingRad)
329
+ let low = 0
330
+ let high = count
331
+ while (low < high) {
332
+ const middle = (low + high) >> 1
333
+ if (headings[middle] < heading) low = middle + 1
334
+ else high = middle
335
+ }
336
+
337
+ const wrapsAfterLastBin = low === count
338
+ const upperIndex = wrapsAfterLastBin ? 0 : low
339
+ const lowerIndex = upperIndex === 0 ? count - 1 : upperIndex - 1
340
+ let lowerHeading = headings[lowerIndex]
341
+ let upperHeading = headings[upperIndex]
342
+ let normalizedHeading = heading
343
+
344
+ if (wrapsAfterLastBin) upperHeading += Math.PI * 2
345
+ if (low === 0) lowerHeading -= Math.PI * 2
346
+ if (normalizedHeading < lowerHeading) normalizedHeading += Math.PI * 2
347
+
348
+ const span = upperHeading - lowerHeading
349
+ const fraction = span > 0 ? (normalizedHeading - lowerHeading) / span : 0
350
+ return wrap180Rad(corrections[lowerIndex] + wrap180Rad(corrections[upperIndex] - corrections[lowerIndex]) * fraction)
351
+ }
352
+
353
+ module.exports = {
354
+ DEFAULT_FILTERS,
355
+ calibrate,
356
+ buildAlignedSamples,
357
+ compileCalibrationProfile,
358
+ correctionForCompiledProfile,
359
+ correctionForHeadingRad
360
+ }
@@ -0,0 +1,14 @@
1
+ 'use strict'
2
+
3
+ function buildSchema () {
4
+ return {
5
+ type: 'object',
6
+ title: 'Compass Calibrator',
7
+ description: 'This plugin is configured from the Compass Calibrator web app in the Signal K admin interface. Open Webapps > Compass Calibrator to discover historical sources, run calibration, manage saved tables, and activate or deactivate runtime publishing.',
8
+ properties: {}
9
+ }
10
+ }
11
+
12
+ module.exports = {
13
+ buildSchema
14
+ }
@@ -0,0 +1,150 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ class ProfileStore {
7
+ constructor (filePath) {
8
+ this.filePath = filePath
9
+ this.state = {
10
+ profiles: [],
11
+ activeProfileId: null,
12
+ activeInputSource: null,
13
+ lastCalibrationReport: null
14
+ }
15
+ }
16
+
17
+ load () {
18
+ try {
19
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'))
20
+ this.state = {
21
+ profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [],
22
+ activeProfileId: parsed.activeProfileId || null,
23
+ activeInputSource: parsed.activeInputSource || null,
24
+ lastCalibrationReport: parsed.lastCalibrationReport || null
25
+ }
26
+ } catch (error) {
27
+ if (error.code !== 'ENOENT') throw error
28
+ }
29
+ return this.state
30
+ }
31
+
32
+ save () {
33
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
34
+ const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
35
+ fs.writeFileSync(tmpPath, `${JSON.stringify(this.state, null, 2)}\n`)
36
+ fs.renameSync(tmpPath, this.filePath)
37
+ }
38
+
39
+ list () {
40
+ return this.state.profiles
41
+ }
42
+
43
+ get (id) {
44
+ return this.state.profiles.find(profile => profile.id === id) || null
45
+ }
46
+
47
+ upsert (profile) {
48
+ const index = this.state.profiles.findIndex(item => item.id === profile.id)
49
+ if (index === -1) this.state.profiles.push(profile)
50
+ else this.state.profiles[index] = profile
51
+ this.state.lastCalibrationReport = profile
52
+ this.save()
53
+ return profile
54
+ }
55
+
56
+ saveProfile (profile) {
57
+ const now = new Date().toISOString()
58
+ const saved = {
59
+ ...profile,
60
+ state: 'saved',
61
+ savedAt: profile.savedAt || now,
62
+ displayName: profile.displayName || now.replace('T', ' ').slice(0, 19)
63
+ }
64
+ return this.upsert(saved)
65
+ }
66
+
67
+ activate (id) {
68
+ const activated = this.get(id)
69
+ if (!activated) return null
70
+ this.state.activeProfileId = id
71
+ this.save()
72
+ return activated
73
+ }
74
+
75
+ configureRuntime (profileId, inputSource) {
76
+ if (profileId && !this.get(profileId)) return null
77
+ this.state.activeProfileId = profileId || null
78
+ this.state.activeInputSource = inputSource || null
79
+ this.save()
80
+ return {
81
+ activeProfileId: this.state.activeProfileId,
82
+ activeInputSource: this.state.activeInputSource
83
+ }
84
+ }
85
+
86
+ activeInputSource () {
87
+ return this.state.activeInputSource || null
88
+ }
89
+
90
+ archive (id) {
91
+ const profile = this.get(id)
92
+ if (!profile) return null
93
+ const archived = { ...profile, state: 'archived', archivedAt: new Date().toISOString() }
94
+ const index = this.state.profiles.findIndex(item => item.id === archived.id)
95
+ if (index === -1) this.state.profiles.push(archived)
96
+ else this.state.profiles[index] = archived
97
+ if (this.state.activeProfileId === id) {
98
+ this.state.activeProfileId = null
99
+ this.state.activeInputSource = null
100
+ }
101
+ this.save()
102
+ return archived
103
+ }
104
+
105
+ reject (id) {
106
+ const profile = this.get(id)
107
+ if (!profile) return null
108
+ const rejected = { ...profile, state: 'rejected', rejectedAt: new Date().toISOString() }
109
+ const index = this.state.profiles.findIndex(item => item.id === rejected.id)
110
+ if (index === -1) this.state.profiles.push(rejected)
111
+ else this.state.profiles[index] = rejected
112
+ if (this.state.activeProfileId === id) {
113
+ this.state.activeProfileId = null
114
+ this.state.activeInputSource = null
115
+ }
116
+ this.save()
117
+ return rejected
118
+ }
119
+
120
+ delete (id) {
121
+ const before = this.state.profiles.length
122
+ this.state.profiles = this.state.profiles.filter(profile => profile.id !== id)
123
+ if (this.state.activeProfileId === id) {
124
+ this.state.activeProfileId = null
125
+ this.state.activeInputSource = null
126
+ }
127
+ this.save()
128
+ return this.state.profiles.length !== before
129
+ }
130
+
131
+ active () {
132
+ if (!this.state.activeProfileId) return null
133
+ return this.get(this.state.activeProfileId)
134
+ }
135
+ }
136
+
137
+ function createProfileStore (app, pluginId) {
138
+ let root
139
+ if (app && typeof app.getDataDirPath === 'function') {
140
+ root = app.getDataDirPath()
141
+ } else {
142
+ root = path.join(process.cwd(), 'data')
143
+ }
144
+ return new ProfileStore(path.join(root, pluginId, 'profiles.json'))
145
+ }
146
+
147
+ module.exports = {
148
+ ProfileStore,
149
+ createProfileStore
150
+ }