Skip to content

Commit 9d8ef27

Browse files
authored
feat: add null-preserving intOrNull/floatOrNull/transformOrNull/magneticVariationOrNull (#57)
An NMEA 0183 null field (IEC 61162-1 §7.2.3.4) signals "sensor working, value not available" and must not be conflated with a legitimate zero. The existing int/float/transform/magneticVariation collapse both into the same numeric 0, which surfaces as real bugs downstream — e.g. SignalK/nmea0183-signalk#192 where an empty RMC magnetic-variation field reports as 0° instead of null, causing a 13° course error for one reporter. Add four null-preserving variants that return null for empty / whitespace / null / undefined / non-numeric input, and a genuine 0 for "0". Existing functions are unchanged for back-compat.
1 parent a9ed489 commit 9d8ef27

7 files changed

Lines changed: 347 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Changelog
22

3+
## 1.1.0
4+
5+
### Non-breaking additions
6+
7+
- **`intOrNull(n)` / `floatOrNull(n)`** — null-preserving variants of
8+
`int` / `float`. Return `null` for empty / whitespace / `null` /
9+
`undefined` / non-numeric input, instead of silently coercing to `0`.
10+
`intOrNull('0')` still returns `0`, so legitimate zero measurements
11+
stay distinguishable from "not available".
12+
- **`transformOrNull(value, from, to)`** — null-short-circuiting unit
13+
conversion. Identical to `transform` for real input; returns `null`
14+
when `value` doesn't parse. Unsupported unit pairs still throw.
15+
- **`magneticVariationOrNull(degrees, pole)`** — null-preserving
16+
magnetic variation. Returns `null` when degrees or pole is missing or
17+
unparseable (including lowercase / unknown pole letters), rather than
18+
throwing or returning `0`.
19+
20+
Motivation: IEC 61162-1 §7.2.3.4 defines a null NMEA field as "sensor
21+
working, value not available" — distinct from a legitimate zero. Legacy
22+
`int` / `float` collapse both into `0`, which has caused bugs like
23+
SignalK/nmea0183-signalk#192 (magnetic variation reported as 0° from an
24+
empty RMC field, causing a 13° error in one reporter's case). Use the
25+
`*OrNull` family when passing through to Signal K, which maps `null` in
26+
a delta value onto the same semantic.
27+
28+
Existing `int` / `float` / `transform` / `magneticVariation` are
29+
unchanged for back-compat.
30+
331
## 1.0.0
432

533
First stable release. The package is now authored in TypeScript with strict

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,31 @@ doesn't parse. Accepts `unknown` so `int(null)`, `int(undefined)`,
162162
Same idea for `parseFloat`. Accepts numbers or numeric strings. Returns
163163
`0.0` on parse failure.
164164

165+
#### `intOrNull(n) => number | null` / `floatOrNull(n) => number | null`
166+
167+
Null-preserving variants of `int` / `float`. An NMEA 0183 null field
168+
(IEC 61162-1 §7.2.3.4) signals "sensor working, value not available"
169+
and must not be confused with a legitimate zero. `intOrNull('')`,
170+
`intOrNull(null)`, `intOrNull('abc')` all return `null`; `intOrNull('0')`
171+
returns `0`. Prefer these over `int`/`float` when the caller wants to
172+
pass the not-available semantic through to Signal K (which treats `null`
173+
the same way).
174+
175+
#### `transformOrNull(value, from, to) => number | null`
176+
177+
Null-short-circuiting unit conversion. Identical to `transform` for real
178+
input; returns `null` when `value` is empty / `null` / `undefined` /
179+
non-numeric. Unsupported unit pairs still throw — the null short-circuit
180+
runs first, so a missing field with an unknown unit pair is still
181+
`null`, not an error.
182+
183+
#### `magneticVariationOrNull(degrees, pole) => number | null`
184+
185+
Null-preserving magnetic variation. Returns `null` when degrees or pole
186+
is missing or unparseable (including unknown / lowercase pole letters),
187+
rather than throwing or silently returning `0`. `0` with a valid pole
188+
returns `0`, not `null`.
189+
165190
#### `zero(n) => string`
166191

167192
Width-2 left-pad for integer date/time components. `zero(2) === '02'`,

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@signalk/nmea0183-utilities",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"description": "Various utilities for transforming NMEA0183 units into SI units for use in SK.",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/index.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,74 @@ export function float(n: unknown): number {
345345
return Number.isNaN(parsed) ? 0.0 : parsed
346346
}
347347

348+
// Null-preserving numeric parsers. An NMEA 0183 null field (IEC 61162-1
349+
// §7.2.3.4) signals "sensor working, value not available" and must not be
350+
// conflated with a legitimate zero. `int`/`float` above coerce to 0 for
351+
// back-compat; prefer these when the caller wants to preserve the
352+
// not-available semantic end-to-end.
353+
//
354+
// intOrNull('') -> null
355+
// intOrNull('42') -> 42
356+
// intOrNull('abc') -> null
357+
export function intOrNull(n: unknown): number | null {
358+
const parsed = parseInt(n as string, 10)
359+
return Number.isNaN(parsed) ? null : parsed
360+
}
361+
362+
export function floatOrNull(n: unknown): number | null {
363+
const parsed = parseFloat(n as string)
364+
return Number.isNaN(parsed) ? null : parsed
365+
}
366+
367+
// Null-short-circuiting unit conversion. Lets callers write
368+
// transformOrNull(parts[8], 'deg', 'rad')
369+
// for an optional NMEA field without a surrounding emptiness check.
370+
// Unsupported conversion pairs still throw (same contract as `transform`).
371+
export function transformOrNull(
372+
value: unknown,
373+
inputFormat: UnitFormat,
374+
outputFormat: UnitFormat
375+
): number | null {
376+
const numeric = floatOrNull(value)
377+
if (numeric === null) {
378+
return null
379+
}
380+
if (inputFormat === outputFormat) {
381+
return numeric
382+
}
383+
const converter = CONVERSIONS[inputFormat + ':' + outputFormat]
384+
if (!converter) {
385+
throw new Error(
386+
'unsupported conversion: ' + inputFormat + ' -> ' + outputFormat
387+
)
388+
}
389+
return converter(numeric)
390+
}
391+
392+
// Null-preserving magnetic variation. Returns null when either the
393+
// degrees field or the pole letter is missing or unparseable, rather
394+
// than throwing or (via the old `float` path) silently returning 0.
395+
// Valid pole letters still enforce the `Pole` contract; an unknown
396+
// pole given alongside numeric degrees is treated as "not available"
397+
// rather than fatal, matching how callers already treat the whole
398+
// field as optional when the direction indicator is empty.
399+
export function magneticVariationOrNull(
400+
degrees: unknown,
401+
pole: unknown
402+
): number | null {
403+
const deg = floatOrNull(degrees)
404+
if (deg === null) {
405+
return null
406+
}
407+
if (pole === 'N' || pole === 'E') {
408+
return deg
409+
}
410+
if (pole === 'S' || pole === 'W') {
411+
return -deg
412+
}
413+
return null
414+
}
415+
348416
// Default export aggregate so ESM consumers using
349417
// `import utils from '@signalk/nmea0183-utilities'` get the same bag
350418
// that `const utils = require(...)` returns in CJS.
@@ -360,5 +428,9 @@ export default {
360428
isValidPosition,
361429
zero,
362430
int,
363-
float
431+
float,
432+
intOrNull,
433+
floatOrNull,
434+
transformOrNull,
435+
magneticVariationOrNull
364436
}

test/default_export.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ describe('default export', function () {
1818
expect(utils.zero).to.equal(named.zero)
1919
expect(utils.int).to.equal(named.int)
2020
expect(utils.float).to.equal(named.float)
21+
expect(utils.intOrNull).to.equal(named.intOrNull)
22+
expect(utils.floatOrNull).to.equal(named.floatOrNull)
23+
expect(utils.transformOrNull).to.equal(named.transformOrNull)
24+
expect(utils.magneticVariationOrNull).to.equal(
25+
named.magneticVariationOrNull
26+
)
2127
done()
2228
})
2329

0 commit comments

Comments
 (0)