signalk-polar-performance-plugin 1.3.3 → 2.0.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/plugin/SI.js DELETED
@@ -1,21 +0,0 @@
1
- class SI {
2
- /**
3
- * Utility class for unit conversions.
4
- */
5
- static toKnots(metersPerSecond) {
6
- return metersPerSecond * 1.94384;
7
- }
8
-
9
- static fromKnots(knots) {
10
- return knots / 1.94384;
11
- }
12
-
13
- static toDegrees(radians) {
14
- return radians * (180 / Math.PI);
15
- }
16
-
17
- static fromDegrees(degrees) {
18
- return degrees * (Math.PI / 180);
19
- }
20
- }
21
- module.exports = SI;
@@ -1,152 +0,0 @@
1
- 'use strict'
2
-
3
- const SI = require('../SI')
4
- const { createCanonicalResource } = require('./canonical')
5
-
6
- function parseNumber(value) {
7
- if (typeof value !== 'string') return Number.NaN
8
- const normalized = value.replace(/^"|"$/g, '').trim()
9
- if (!normalized) return Number.NaN
10
- return Number(normalized)
11
- }
12
-
13
- class ExpeditionImporter {
14
- constructor() {
15
- this.id = 'expedition'
16
- this.name = 'Expedition'
17
- this.description = 'Expedition polar text with one TWS row followed by repeated TWA/boat-speed pairs and optional inline beat/run target points.'
18
- this.defaultSource = 'expedition'
19
- }
20
-
21
- parse(content) {
22
- const lines = content
23
- .replace(/^\uFEFF/, '')
24
- .split(/\r?\n/)
25
- .map(line => line.trim())
26
- .filter(Boolean)
27
-
28
- if (lines.length === 0) {
29
- throw new Error('Import text is empty')
30
- }
31
-
32
- let sailnumber = null
33
- let startIndex = 0
34
- if (lines[0].startsWith('!')) {
35
- sailnumber = lines[0].slice(1).trim() || null
36
- startIndex = 1
37
- }
38
-
39
- const rowData = []
40
- const angleFrequency = new Map()
41
-
42
- for (const rawLine of lines.slice(startIndex)) {
43
- const cells = rawLine.split(/\t+/).map(cell => cell.trim()).filter(cell => cell.length > 0)
44
- if (cells.length < 3) continue
45
-
46
- const twsKnots = parseNumber(cells[0])
47
- if (!Number.isFinite(twsKnots) || twsKnots <= 0) {
48
- throw new Error(`Invalid Expedition TWS value '${cells[0]}'`)
49
- }
50
-
51
- if ((cells.length - 1) % 2 !== 0) {
52
- throw new Error(`Expedition row for TWS ${cells[0]} has an incomplete TWA/BSP pair`)
53
- }
54
-
55
- const pairs = []
56
- for (let index = 1; index < cells.length; index += 2) {
57
- const angleDeg = parseNumber(cells[index])
58
- const speedKnots = parseNumber(cells[index + 1])
59
- if (!Number.isFinite(angleDeg) || angleDeg < 0 || angleDeg > 180) {
60
- throw new Error(`Invalid Expedition TWA value '${cells[index]}'`)
61
- }
62
- if (!Number.isFinite(speedKnots) || speedKnots < 0) {
63
- throw new Error(`Invalid Expedition boat speed value '${cells[index + 1]}'`)
64
- }
65
-
66
- const angleKey = angleDeg.toFixed(6)
67
- angleFrequency.set(angleKey, (angleFrequency.get(angleKey) || 0) + 1)
68
- pairs.push({
69
- angleDeg,
70
- angleRad: SI.fromDegrees(angleDeg),
71
- speed: SI.fromKnots(speedKnots)
72
- })
73
- }
74
-
75
- rowData.push({
76
- tws: SI.fromKnots(twsKnots),
77
- pairs
78
- })
79
- }
80
-
81
- if (rowData.length === 0) {
82
- throw new Error('No Expedition polar rows were found')
83
- }
84
-
85
- const commonAngleKeys = new Set(
86
- Array.from(angleFrequency.entries())
87
- .filter(([, count]) => count > 1)
88
- .map(([key]) => key)
89
- )
90
-
91
- const twaAxis = Array.from(commonAngleKeys)
92
- .map(key => SI.fromDegrees(Number(key)))
93
- .sort((left, right) => left - right)
94
-
95
- if (twaAxis.length === 0) {
96
- throw new Error('No repeated Expedition TWA grid was detected')
97
- }
98
-
99
- const boatSpeedMatrix = []
100
- const derivedRows = []
101
-
102
- rowData.forEach((row) => {
103
- const pairByKey = new Map(row.pairs.map(pair => [pair.angleDeg.toFixed(6), pair]))
104
- const uncommonPairs = row.pairs.filter(pair => !commonAngleKeys.has(pair.angleDeg.toFixed(6)) && pair.speed > 0)
105
- const beatPair = uncommonPairs.length ? uncommonPairs[0] : null
106
- const runPair = uncommonPairs.length > 1 ? uncommonPairs[uncommonPairs.length - 1] : null
107
-
108
- boatSpeedMatrix.push(
109
- twaAxis.map((angleRad) => {
110
- const pair = pairByKey.get(SI.toDegrees(angleRad).toFixed(6))
111
- return pair ? pair.speed : 0
112
- })
113
- )
114
-
115
- if (beatPair || runPair) {
116
- derivedRows.push({
117
- tws: row.tws,
118
- ...(beatPair ? {
119
- beat: {
120
- twa: beatPair.angleRad,
121
- tbs: beatPair.speed,
122
- vmg: beatPair.speed * Math.abs(Math.cos(beatPair.angleRad))
123
- }
124
- } : {}),
125
- ...(runPair ? {
126
- run: {
127
- twa: runPair.angleRad,
128
- tbs: runPair.speed,
129
- vmg: runPair.speed * Math.abs(Math.cos(runPair.angleRad))
130
- }
131
- } : {})
132
- })
133
- }
134
- })
135
-
136
- return {
137
- resource: {
138
- ...createCanonicalResource({
139
- axes: {
140
- tws: rowData.map(row => row.tws),
141
- twa: twaAxis
142
- },
143
- values: { boatSpeedMatrix },
144
- ...(derivedRows.length ? { derived: { rows: derivedRows } } : {})
145
- }),
146
- ...(sailnumber ? { sailnumber } : {})
147
- }
148
- }
149
- }
150
- }
151
-
152
- module.exports = ExpeditionImporter
@@ -1,21 +0,0 @@
1
- 'use strict'
2
-
3
- class FormatRegistry {
4
- constructor(importers = []) {
5
- this.importers = new Map(importers.map(importer => [importer.id, importer]))
6
- }
7
-
8
- get(id) {
9
- return this.importers.get(id)
10
- }
11
-
12
- list() {
13
- return Array.from(this.importers.values()).map(importer => ({
14
- id: importer.id,
15
- name: importer.name,
16
- description: importer.description
17
- }))
18
- }
19
- }
20
-
21
- module.exports = FormatRegistry
@@ -1,199 +0,0 @@
1
- 'use strict'
2
-
3
- const FormatRegistry = require('./FormatRegistry')
4
- const SourceRegistry = require('./SourceRegistry')
5
- const OrcSource = require('./OrcSource')
6
- const JieterImporter = require('./JieterImporter')
7
- const ExpeditionImporter = require('./ExpeditionImporter')
8
- const { applyMetadata, validateCanonicalPolarResourceBody } = require('./canonical')
9
-
10
- class ImportError extends Error {
11
- constructor(status, message) {
12
- super(message)
13
- this.status = status
14
- }
15
- }
16
-
17
- function autoId(value, fallback = 'imported-polar') {
18
- const slug = String(value || '')
19
- .trim()
20
- .toLowerCase()
21
- .replace(/[^a-z0-9._-]+/g, '-')
22
- .replace(/-+/g, '-')
23
- .replace(/^-|-$/g, '')
24
- return slug || fallback
25
- }
26
-
27
- function timestampIdPart(now = Date.now()) {
28
- const date = now instanceof Date ? now : new Date(now)
29
- const pad = value => String(value).padStart(2, '0')
30
- return [
31
- date.getUTCFullYear(),
32
- pad(date.getUTCMonth() + 1),
33
- pad(date.getUTCDate()),
34
- 't',
35
- pad(date.getUTCHours()),
36
- pad(date.getUTCMinutes()),
37
- pad(date.getUTCSeconds()),
38
- 'z'
39
- ].join('')
40
- }
41
-
42
- function createTimestampedId(baseValue, fallback, exists, now) {
43
- const baseId = autoId(baseValue, fallback)
44
- const timestampedBaseId = `${baseId}-${timestampIdPart(now())}`
45
- let id = timestampedBaseId
46
- let suffix = 2
47
- while (exists(id)) {
48
- id = `${timestampedBaseId}-${suffix}`
49
- suffix += 1
50
- }
51
- return id
52
- }
53
-
54
- function validateRequestedId(id) {
55
- if (typeof id !== 'string' || !id.trim()) {
56
- throw new ImportError(400, "'id' must be a non-empty string when provided")
57
- }
58
- if (!/^[A-Za-z0-9._-]+$/.test(id.trim())) {
59
- throw new ImportError(400, "'id' may contain only letters, numbers, '.', '_' and '-' characters")
60
- }
61
- return id.trim()
62
- }
63
-
64
- class ImportService {
65
- constructor(store, options = {}) {
66
- this.store = store
67
- this.now = options.now || (() => Date.now())
68
- this.registry = new FormatRegistry([
69
- new JieterImporter(),
70
- new ExpeditionImporter()
71
- ])
72
- this.sourceRegistry = new SourceRegistry([
73
- new OrcSource({
74
- dataDir: store.dataDir,
75
- fetcher: options.fetcher,
76
- now: this.now
77
- })
78
- ])
79
- }
80
-
81
- listFormats() {
82
- return this.registry.list()
83
- }
84
-
85
- listSources() {
86
- return this.sourceRegistry.list().map(descriptor => ({ ...descriptor }))
87
- }
88
-
89
- importText(formatId, body) {
90
- if (!body || typeof body !== 'object') {
91
- throw new ImportError(400, 'Expected a JSON object')
92
- }
93
-
94
- if (typeof body.content !== 'string' || !body.content.trim()) {
95
- throw new ImportError(400, "'content' is required")
96
- }
97
-
98
- const importer = this.registry.get(formatId)
99
- if (!importer) {
100
- throw new ImportError(400, `Unsupported import format: ${formatId}`)
101
- }
102
-
103
- let parsed
104
- try {
105
- parsed = importer.parse(body.content)
106
- } catch (error) {
107
- throw new ImportError(400, error.message)
108
- }
109
-
110
- const resource = applyMetadata(
111
- parsed.resource,
112
- { ...body, source: body.source || importer.defaultSource },
113
- body.name || importer.name
114
- )
115
-
116
- const validationError = validateCanonicalPolarResourceBody(resource)
117
- if (validationError) {
118
- throw new ImportError(400, validationError)
119
- }
120
-
121
- const id = createTimestampedId(
122
- resource.sailnumber || resource.name || formatId,
123
- `${formatId}-polar`,
124
- (candidate) => this.store.exists(candidate),
125
- this.now
126
- )
127
-
128
- this.store.saveCanonical(id, resource)
129
- return { id, resource: { ...resource, id } }
130
- }
131
-
132
- async searchSource(sourceId, query) {
133
- const source = this.sourceRegistry.get(sourceId)
134
- if (!source) {
135
- throw new ImportError(404, `Unsupported import source: ${sourceId}`)
136
- }
137
-
138
- try {
139
- return await source.search(query)
140
- } catch (error) {
141
- if (Number.isInteger(error.status)) {
142
- throw new ImportError(error.status, error.message)
143
- }
144
- throw error
145
- }
146
- }
147
-
148
- async importSource(sourceId, externalId, body) {
149
- const source = this.sourceRegistry.get(sourceId)
150
- if (!source) {
151
- throw new ImportError(404, `Unsupported import source: ${sourceId}`)
152
- }
153
-
154
- if (body != null && typeof body !== 'object') {
155
- throw new ImportError(400, 'Expected a JSON object')
156
- }
157
-
158
- if (body && Object.keys(body).length > 0) {
159
- throw new ImportError(400, 'External source imports do not accept metadata overrides')
160
- }
161
-
162
- let parsed
163
- try {
164
- parsed = await source.importByExternalId(externalId)
165
- } catch (error) {
166
- if (Number.isInteger(error.status)) {
167
- throw new ImportError(error.status, error.message)
168
- }
169
- throw error
170
- }
171
-
172
- const resource = applyMetadata(
173
- parsed.resource,
174
- { source: parsed.resource.source || source.defaultSource },
175
- parsed.fallbackName || source.name
176
- )
177
-
178
- const validationError = validateCanonicalPolarResourceBody(resource)
179
- if (validationError) {
180
- throw new ImportError(400, validationError)
181
- }
182
-
183
- const id = (() => {
184
- const baseId = autoId(externalId || resource.name || resource.sailnumber, `${sourceId}-polar`)
185
- let candidate = baseId
186
- let suffix = 2
187
- while (this.store.exists(candidate)) {
188
- candidate = `${baseId}-${suffix}`
189
- suffix += 1
190
- }
191
- return candidate
192
- })()
193
-
194
- this.store.saveCanonical(id, resource)
195
- return { id, resource: { ...resource, id } }
196
- }
197
- }
198
-
199
- module.exports = { ImportService, ImportError, autoId, timestampIdPart, createTimestampedId }
@@ -1,18 +0,0 @@
1
- 'use strict'
2
-
3
- const { parseMatrixPolarText } = require('./matrixText')
4
-
5
- class JieterImporter {
6
- constructor() {
7
- this.id = 'jieter'
8
- this.name = 'Jieter'
9
- this.description = 'Semicolon-delimited Jieter/ORC table with TWS header, TWA rows, and optional beat/run target rows.'
10
- this.defaultSource = 'jieter'
11
- }
12
-
13
- parse(content) {
14
- return parseMatrixPolarText(content, { preferredDelimiters: [';'] })
15
- }
16
- }
17
-
18
- module.exports = JieterImporter