From aa0e042e30ee336384cf04356ed4230e4765ed3c Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Wed, 8 Jul 2026 13:45:39 +0100 Subject: [PATCH 1/3] DEV-854: add Nordic PPK2 (Power Profiler Kit II) instrument driver Ports the PPK2 usage of the legacy Python production test app (ASM_ProductionTest.py / ppk2_api) to the SDK for the web console's production power-consumption tests. - src/instruments/ppk2/Ppk2.ts: Web Serial transport (USB 0x1915/0xC00A, 9600 baud CDC ACM). Single continuous read loop routed by phase: metadata blob (GET_META_DATA -> ASCII until 'END'), 100 kS/s sample stream (AVERAGE_START/STOP), discard otherwise. Source/ampere meter modes, source voltage (REGULATOR_SET), DUT power toggle. Keeps no sample history; delivers calibrated microamp batches via onSamples. - src/instruments/ppk2/ppk2Codec.ts: pure codec layer - source-voltage encoding, metadata/calibration-modifier parsing (preserving the 'R value of exactly 0 is ignored' quirk), and Ppk2SampleDecoder: exact port of ppk2_api's calibration polynomial + range-transition spike filter with partial-word remainder carry (chunk-split invariant). Bounded aggregation helpers: RunningStats, MinMaxDownsampler. - Tests are fixture-driven against the original Python implementation: tests/instruments/ppk2/fixtures/generate_fixtures.py runs the installed ppk2_api offline (serial mocked) and dumps ground-truth vectors; vitest asserts numerical identity (1e-12 relative) plus chunk-split invariance and decoder reset behavior. - Version 0.1.7 -> 0.1.8. Compile/lint/test clean (182 tests). NOT yet validated against PPK2 hardware - see DEV-854 for the bench checklist. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/index.ts | 28 + src/instruments/ppk2/Ppk2.ts | 356 +++++++++ src/instruments/ppk2/constants.ts | 57 ++ src/instruments/ppk2/ppk2Codec.ts | 392 ++++++++++ tests/instruments/ppk2/codec.test.ts | 89 +++ tests/instruments/ppk2/decoder.test.ts | 123 +++ .../ppk2/fixtures/generate_fixtures.py | 198 +++++ .../ppk2/fixtures/ppk2-fixtures.json | 704 ++++++++++++++++++ 11 files changed, 1951 insertions(+), 3 deletions(-) create mode 100644 src/instruments/ppk2/Ppk2.ts create mode 100644 src/instruments/ppk2/constants.ts create mode 100644 src/instruments/ppk2/ppk2Codec.ts create mode 100644 tests/instruments/ppk2/codec.test.ts create mode 100644 tests/instruments/ppk2/decoder.test.ts create mode 100644 tests/instruments/ppk2/fixtures/generate_fixtures.py create mode 100644 tests/instruments/ppk2/fixtures/ppk2-fixtures.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cd41a..1f3096d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- Nordic Power Profiler Kit II (PPK2) instrument driver (`src/instruments/ppk2/`, DEV-854): `Ppk2` Web Serial transport (source/ampere meter, source voltage, DUT power, 100 kS/s measurement stream) and a pure codec layer (`Ppk2SampleDecoder`, `parsePpk2Metadata`, `convertSourceVoltage`, `RunningStats`, `MinMaxDownsampler`). The decoder is an exact port of the Python `ppk2_api` calibration polynomial + spike filter, verified against fixtures generated by the Python reference (`tests/instruments/ppk2/`). Used by the device console's production power-consumption tests. - Live stream statistics: `StreamStatsTracker` (core), `VerisenseBleDevice.getStreamStats()`, a throttled `streamStats` event, and per-sub-stream `SensorBase.getStreamContributions()` for windowed throughput and sample-gap-derived packet loss. ### Fixed diff --git a/package-lock.json b/package-lock.json index 337862c..d71c941 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "BSD-3-Clause", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 8f955f3..58ce377 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", diff --git a/src/index.ts b/src/index.ts index c7be34d..2d44520 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,3 +256,31 @@ export { SensorMAX32674 } from './devices/verisense/sensors/SensorMAX32674.js'; export type { MAX32674Sample } from './devices/verisense/sensors/SensorMAX32674.js'; export { SensorMLX90632 } from './devices/verisense/sensors/SensorMLX90632.js'; export type { MLX90632Sample } from './devices/verisense/sensors/SensorMLX90632.js'; + +// Instruments — Nordic Power Profiler Kit II (PPK2) +export { Ppk2 } from './instruments/ppk2/Ppk2.js'; +export type { Ppk2Options } from './instruments/ppk2/Ppk2.js'; +export { + Ppk2SampleDecoder, + parsePpk2Metadata, + convertSourceVoltage, + clampSourceVoltageMv, + clonePpk2Modifiers, + DEFAULT_PPK2_MODIFIERS, + RunningStats, + MinMaxDownsampler, +} from './instruments/ppk2/ppk2Codec.js'; +export type { + Ppk2Modifiers, + Ppk2RangeTable, + Ppk2SampleBatch, + MinMaxBin, +} from './instruments/ppk2/ppk2Codec.js'; +export { + PPK2_CMD, + PPK2_USB_VENDOR_ID, + PPK2_USB_PRODUCT_ID, + PPK2_BAUD_RATE, + PPK2_SAMPLES_PER_SECOND, +} from './instruments/ppk2/constants.js'; +export type { Ppk2Command } from './instruments/ppk2/constants.js'; diff --git a/src/instruments/ppk2/Ppk2.ts b/src/instruments/ppk2/Ppk2.ts new file mode 100644 index 0000000..ab775cb --- /dev/null +++ b/src/instruments/ppk2/Ppk2.ts @@ -0,0 +1,356 @@ +/** + * Nordic Power Profiler Kit II (PPK2) Web Serial driver. + * + * The PPK2 is used as a battery substitute (source-meter mode) and/or an + * inline ammeter, streaming calibrated current samples at 100 kS/s. This + * driver owns a single continuous read loop and routes incoming bytes by + * phase: the ASCII metadata blob after GET_META_DATA, the binary sample + * stream between AVERAGE_START/AVERAGE_STOP, and discard otherwise (the + * device sends nothing else; all remaining commands are fire-and-forget). + * + * The driver keeps no sample history — `onSamples` delivers calibrated + * microamp batches and bounded aggregation (RunningStats/MinMaxDownsampler) + * is the caller's job. Keep the callback cheap: at 100 kS/s (400 KB/s) any + * per-chunk rendering must be deferred (e.g. requestAnimationFrame). + */ + +import { PPK2_BAUD_RATE, PPK2_CMD, PPK2_USB_PRODUCT_ID, PPK2_USB_VENDOR_ID } from './constants.js'; +import { + clampSourceVoltageMv, + clonePpk2Modifiers, + convertSourceVoltage, + DEFAULT_PPK2_MODIFIERS, + parsePpk2Metadata, + Ppk2SampleDecoder, + type Ppk2Modifiers, + type Ppk2SampleBatch, +} from './ppk2Codec.js'; + +export interface Ppk2Options { + /** Calibrated sample batches while measuring. Must be cheap — no rendering. */ + onSamples?: (batch: Ppk2SampleBatch) => void; + /** Fired when the link drops unexpectedly (e.g. USB cable pulled). */ + onDisconnected?: () => void; + onError?: (error: Error) => void; +} + +type Ppk2Phase = 'idle' | 'meta' | 'stream'; + +const METADATA_TIMEOUT_MS = 2000; +const METADATA_ATTEMPTS = 2; + +export class Ppk2 { + /** Web Serial picker filter matching the PPK2's USB identity. */ + static readonly USB_FILTER: SerialPortFilter = { + usbVendorId: PPK2_USB_VENDOR_ID, + usbProductId: PPK2_USB_PRODUCT_ID, + }; + + private readonly opts: Ppk2Options; + + private port: SerialPort | null = null; + private abortCtrl: AbortController | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private readLoopTask: Promise | null = null; + + private phase: Ppk2Phase = 'idle'; + private metaDecoder = new TextDecoder(); + private metaText = ''; + private metaWaiter: { resolve: (text: string) => void; reject: (e: Error) => void } | null = null; + + private decoder = new Ppk2SampleDecoder(DEFAULT_PPK2_MODIFIERS, 0); + private _modifiers: Ppk2Modifiers | null = null; + private _currentVddMv: number | null = null; + private _measuring = false; + private _dutPowerOn = false; + + constructor(opts: Ppk2Options = {}) { + this.opts = opts; + } + + get isConnected(): boolean { + return this.port !== null; + } + + get isMeasuring(): boolean { + return this._measuring; + } + + get isDutPowerOn(): boolean { + return this._dutPowerOn; + } + + /** Calibration modifiers read during connect(), or null before then. */ + get modifiers(): Ppk2Modifiers | null { + return this._modifiers ? clonePpk2Modifiers(this._modifiers) : null; + } + + get currentVddMv(): number | null { + return this._currentVddMv; + } + + /** + * Open the PPK2 serial port (browser picker filtered to the PPK2 USB IDs + * unless a port is supplied) and read the device's calibration metadata. + */ + async connect(opts: { port?: SerialPort | null } = {}): Promise { + if (!('serial' in navigator)) { + throw new Error('Web Serial not supported. Use Chrome/Edge on HTTPS or http://localhost.'); + } + if (this.port) { + throw new Error('PPK2 already connected'); + } + + const serial = (navigator as unknown as { serial: Serial }).serial; + const port = opts.port ?? (await serial.requestPort({ filters: [Ppk2.USB_FILTER] })); + + await port.open({ + baudRate: PPK2_BAUD_RATE, + // ~2.5 s of stream headroom so a busy main thread cannot overflow the + // 400 KB/s measurement stream. + bufferSize: 1 << 20, + }); + + this.port = port; + this.phase = 'idle'; + this.abortCtrl = new AbortController(); + this.startReadLoop(this.abortCtrl.signal); + + try { + await this.getMetadata(); + } catch (e) { + await this.disconnect(); + throw e; + } + } + + /** + * Best-effort teardown: stop measuring, remove DUT power, then close the + * port. Idempotent; write failures are swallowed (the device may already + * be gone). + */ + async disconnect(): Promise { + const port = this.port; + if (!port) return; + + try { + if (this._measuring) await this.stopMeasuring(); + } catch { + /* ignore */ + } + try { + if (this._dutPowerOn) await this.toggleDutPower(false); + } catch { + /* ignore */ + } + + await this.teardownLink('user'); + } + + /** Request + parse the calibration metadata blob (retries once on timeout). */ + async getMetadata(): Promise { + let lastError: Error = new Error('PPK2 metadata read failed'); + for (let attempt = 0; attempt < METADATA_ATTEMPTS; attempt++) { + try { + const text = await this.requestMetadataOnce(); + const modifiers = parsePpk2Metadata(text); + this._modifiers = modifiers; + this.decoder.setModifiers(modifiers); + return clonePpk2Modifiers(modifiers); + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + if (!this.port) break; + } + } + throw lastError; + } + + /** Source-meter mode: the PPK2 supplies power to the DUT and measures it. */ + async useSourceMeter(): Promise { + await this.write([PPK2_CMD.SET_POWER_MODE, PPK2_CMD.AVG_NUM_SET]); // 0x11 0x02 + } + + /** Ampere-meter mode: the PPK2 measures current from an external supply. */ + async useAmpereMeter(): Promise { + await this.write([PPK2_CMD.SET_POWER_MODE, PPK2_CMD.TRIGGER_SET]); // 0x11 0x01 + } + + /** Set the source (or expected input) voltage; required before measuring. */ + async setSourceVoltage(mV: number): Promise { + const clamped = clampSourceVoltageMv(mV); + const [b1, b2] = convertSourceVoltage(clamped); + await this.write([PPK2_CMD.REGULATOR_SET, b1, b2]); + this._currentVddMv = clamped; + this.decoder.setSourceVoltage(clamped); + } + + async toggleDutPower(on: boolean): Promise { + await this.write([PPK2_CMD.DEVICE_RUNNING_SET, on ? 0x01 : 0x00]); + this._dutPowerOn = on; + } + + /** Start the continuous 100 kS/s measurement stream. */ + async startMeasuring(): Promise { + if (this._currentVddMv === null) { + throw new Error('Source/input voltage not set — call setSourceVoltage() first'); + } + this.decoder.reset(); + this.phase = 'stream'; + this._measuring = true; + await this.write([PPK2_CMD.AVERAGE_START]); + } + + /** Stop the measurement stream; straggler bytes are discarded. */ + async stopMeasuring(): Promise { + this._measuring = false; + this.phase = 'idle'; + await this.write([PPK2_CMD.AVERAGE_STOP]); + } + + // --- internals --- + + private async requestMetadataOnce(): Promise { + if (!this.port) throw new Error('PPK2 not connected'); + if (this.metaWaiter) throw new Error('PPK2 metadata read already in progress'); + + this.metaText = ''; + this.metaDecoder = new TextDecoder(); + this.phase = 'meta'; + + const waiter = new Promise((resolve, reject) => { + this.metaWaiter = { resolve, reject }; + }); + const timer = setTimeout(() => { + this.settleMetaWaiter(null, new Error('Timed out waiting for PPK2 metadata')); + }, METADATA_TIMEOUT_MS); + + try { + await this.write([PPK2_CMD.GET_META_DATA]); + return await waiter; + } finally { + clearTimeout(timer); + if (this.phase === 'meta') this.phase = 'idle'; + } + } + + private settleMetaWaiter(text: string | null, error?: Error): void { + const waiter = this.metaWaiter; + if (!waiter) return; + this.metaWaiter = null; + if (text !== null) waiter.resolve(text); + else waiter.reject(error ?? new Error('PPK2 metadata read failed')); + } + + private async write(bytes: number[]): Promise { + const writable = this.port?.writable; + if (!writable) throw new Error('PPK2 not connected'); + const writer = writable.getWriter(); + try { + await writer.write(new Uint8Array(bytes)); + } finally { + writer.releaseLock(); + } + } + + private startReadLoop(signal: AbortSignal): void { + const port = this.port!; + this.readLoopTask = (async () => { + let reader: ReadableStreamDefaultReader | null = null; + try { + const readable = port.readable; + if (!readable) return; + reader = readable.getReader(); + this.reader = reader; + + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) break; + if (value?.length) this.handleChunk(value); + } + } catch (e) { + if (!signal.aborted) { + const err = e instanceof Error ? e : new Error(String(e)); + this.opts.onError?.(err); + } + } finally { + try { + reader?.releaseLock?.(); + } catch { + /* ignore */ + } + if (this.reader === reader) this.reader = null; + this.readLoopTask = null; + if (!signal.aborted) { + // Unexpected link loss (cable pulled / device reset). + void this.teardownLink('link-lost'); + this.opts.onDisconnected?.(); + } + } + })(); + } + + private handleChunk(chunk: Uint8Array): void { + switch (this.phase) { + case 'meta': { + this.metaText += this.metaDecoder.decode(chunk, { stream: true }); + if (this.metaText.includes('END')) { + const text = this.metaText; + this.metaText = ''; + this.phase = 'idle'; + this.settleMetaWaiter(text); + } + break; + } + case 'stream': { + const batch = this.decoder.feed(chunk); + if (batch.microAmps.length > 0) this.opts.onSamples?.(batch); + break; + } + default: + // Stragglers after AVERAGE_STOP (or unsolicited noise): discard. + break; + } + } + + /** Abort the reader and close the port; safe to call multiple times. */ + private async teardownLink(reason: 'user' | 'link-lost'): Promise { + const port = this.port; + this.port = null; + this.phase = 'idle'; + this._measuring = false; + this._dutPowerOn = false; + this._currentVddMv = null; + this.settleMetaWaiter(null, new Error(`PPK2 disconnected (${reason})`)); + + try { + this.abortCtrl?.abort(); + } catch { + /* ignore */ + } + this.abortCtrl = null; + + const reader = this.reader; + this.reader = null; + try { + await reader?.cancel(); + } catch { + /* ignore */ + } + try { + reader?.releaseLock?.(); + } catch { + /* ignore */ + } + try { + const task = this.readLoopTask; + if (task) await Promise.race([task, new Promise((r) => setTimeout(r, 750))]); + } catch { + /* ignore */ + } + try { + await port?.close(); + } catch { + /* ignore */ + } + } +} diff --git a/src/instruments/ppk2/constants.ts b/src/instruments/ppk2/constants.ts new file mode 100644 index 0000000..cd83011 --- /dev/null +++ b/src/instruments/ppk2/constants.ts @@ -0,0 +1,57 @@ +/** + * Nordic Power Profiler Kit II (PPK2) — USB serial protocol constants. + * + * The PPK2 enumerates as a USB CDC ACM serial device (Nordic Semiconductor + * VID 0x1915, PID 0xC00A). Commands are raw byte sequences with no framing; + * the device only ever transmits (a) an ASCII metadata blob in response to + * GET_META_DATA and (b) a continuous stream of 4-byte little-endian sample + * words between AVERAGE_START and AVERAGE_STOP. + * + * Ported from the reference implementation `ppk2_api` (Python), which itself + * mirrors Nordic's pc-nrfconnect-ppk application. + */ + +export const PPK2_USB_VENDOR_ID = 0x1915; +export const PPK2_USB_PRODUCT_ID = 0xc00a; + +/** CDC ACM — the baud rate is nominal, any value works. */ +export const PPK2_BAUD_RATE = 9600; + +/** Fixed hardware sampling rate of the current-measurement stream. */ +export const PPK2_SAMPLES_PER_SECOND = 100_000; + +/** Bytes per sample word in the measurement stream. */ +export const PPK2_SAMPLE_BYTES = 4; + +/** Serial command opcodes. */ +export const PPK2_CMD = Object.freeze({ + NO_OP: 0x00, + TRIGGER_SET: 0x01, + AVG_NUM_SET: 0x02, + TRIGGER_WINDOW_SET: 0x03, + TRIGGER_INTERVAL_SET: 0x04, + TRIGGER_SINGLE_SET: 0x05, + AVERAGE_START: 0x06, + AVERAGE_STOP: 0x07, + RANGE_SET: 0x08, + LCD_SET: 0x09, + TRIGGER_STOP: 0x0a, + DEVICE_RUNNING_SET: 0x0c, + REGULATOR_SET: 0x0d, + SWITCH_POINT_DOWN: 0x0e, + SWITCH_POINT_UP: 0x0f, + /** Shared opcode: [0x11, 0x01] = ampere meter, [0x11, 0x02] = source meter. */ + SET_POWER_MODE: 0x11, + RES_USER_SET: 0x12, + SPIKE_FILTERING_ON: 0x15, + SPIKE_FILTERING_OFF: 0x16, + GET_META_DATA: 0x19, + RESET: 0x20, + SET_USER_GAINS: 0x25, +} as const); + +export type Ppk2Command = (typeof PPK2_CMD)[keyof typeof PPK2_CMD]; + +/** Source/input voltage limits accepted by the regulator (mV). */ +export const PPK2_VDD_MIN_MV = 800; +export const PPK2_VDD_MAX_MV = 5000; diff --git a/src/instruments/ppk2/ppk2Codec.ts b/src/instruments/ppk2/ppk2Codec.ts new file mode 100644 index 0000000..a53eb57 --- /dev/null +++ b/src/instruments/ppk2/ppk2Codec.ts @@ -0,0 +1,392 @@ +/** + * PPK2 codec — pure, transport-free logic for the Nordic Power Profiler Kit II: + * source-voltage command encoding, GET_META_DATA response parsing, and the + * streaming sample decoder (raw 4-byte words -> calibrated microamps). + * + * Faithful TypeScript port of the `ppk2_api` Python reference implementation + * (`ppk2_api.py`), including its calibration polynomial and the rolling-average + * spike filter applied around measurement-range transitions. Unit tests compare + * this module's output against fixtures generated by the Python original. + */ + +import { PPK2_SAMPLE_BYTES, PPK2_VDD_MAX_MV, PPK2_VDD_MIN_MV } from './constants.js'; + +/** Per-range calibration table (5 measurement ranges, index 0-4). */ +export type Ppk2RangeTable = [number, number, number, number, number]; + +/** Calibration modifiers reported by the PPK2 metadata blob. */ +export interface Ppk2Modifiers { + /** "Calibrated" flag as reported by the device (string, e.g. "0"/"1"), or null. */ + calibrated: string | null; + /** Hardware revision string, or null. */ + hw: string | null; + /** "IA" metadata value, or null. */ + ia: string | null; + r: Ppk2RangeTable; + gs: Ppk2RangeTable; + gi: Ppk2RangeTable; + o: Ppk2RangeTable; + s: Ppk2RangeTable; + i: Ppk2RangeTable; + ug: Ppk2RangeTable; +} + +/** Fallback modifiers for an uncalibrated device (matches ppk2_api defaults). */ +export const DEFAULT_PPK2_MODIFIERS: Readonly = Object.freeze({ + calibrated: null, + hw: null, + ia: null, + r: [1031.64, 101.65, 10.15, 0.94, 0.043] as Ppk2RangeTable, + gs: [1, 1, 1, 1, 1] as Ppk2RangeTable, + gi: [1, 1, 1, 1, 1] as Ppk2RangeTable, + o: [0, 0, 0, 0, 0] as Ppk2RangeTable, + s: [0, 0, 0, 0, 0] as Ppk2RangeTable, + i: [0, 0, 0, 0, 0] as Ppk2RangeTable, + ug: [1, 1, 1, 1, 1] as Ppk2RangeTable, +}); + +export function clonePpk2Modifiers(m: Readonly): Ppk2Modifiers { + return { + calibrated: m.calibrated, + hw: m.hw, + ia: m.ia, + r: [...m.r], + gs: [...m.gs], + gi: [...m.gi], + o: [...m.o], + s: [...m.s], + i: [...m.i], + ug: [...m.ug], + }; +} + +/** Clamp a requested source/input voltage to the regulator's supported span. */ +export function clampSourceVoltageMv(mV: number): number { + return Math.min(PPK2_VDD_MAX_MV, Math.max(PPK2_VDD_MIN_MV, Math.trunc(mV))); +} + +/** + * Encode a source voltage as the two REGULATOR_SET argument bytes. + * 800 mV (the minimum) encodes as [3, 32]; values scale linearly from there. + */ +export function convertSourceVoltage(mV: number): [number, number] { + const clamped = clampSourceVoltageMv(mV); + const diffToBaseline = clamped - PPK2_VDD_MIN_MV + 32; + const b1 = 3 + Math.trunc(diffToBaseline / 256); + const b2 = diffToBaseline % 256; + return [b1, b2]; +} + +const INDEXED_MODIFIER_KEYS = ['r', 'gs', 'gi', 'o', 's', 'i', 'ug'] as const; + +/** + * Parse the ASCII GET_META_DATA response ("Key: value" lines terminated by + * "END") into a modifiers table, starting from the uncalibrated defaults. + * + * Quirk preserved from ppk2_api: an `R` value of exactly 0 is ignored + * (some PPK2 units report broken zero calibration for R) so the default + * resistor value survives. Divergence from Python: a malformed line is + * skipped instead of aborting the whole parse. + */ +export function parsePpk2Metadata(text: string): Ppk2Modifiers { + const modifiers = clonePpk2Modifiers(DEFAULT_PPK2_MODIFIERS); + + for (const rawLine of text.split('\n')) { + const sep = rawLine.indexOf(': '); + if (sep <= 0) continue; + const key = rawLine.slice(0, sep).trim(); + const value = rawLine.slice(sep + 2).trim(); + + if (key === 'Calibrated') { + modifiers.calibrated = value; + continue; + } + if (key === 'HW') { + modifiers.hw = value; + continue; + } + if (key === 'IA') { + modifiers.ia = value; + continue; + } + + for (const mod of INDEXED_MODIFIER_KEYS) { + const upper = mod.toUpperCase(); + if (!key.startsWith(upper)) continue; + const index = Number(key.slice(upper.length)); + if (!Number.isInteger(index) || index < 0 || index > 4 || key !== `${upper}${index}`) { + continue; + } + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) continue; + if (mod === 'r' && parsed === 0) continue; // broken zero calibration -> keep default + modifiers[mod][index] = parsed; + break; + } + } + + return modifiers; +} + +/** One decoded batch of measurement samples. */ +export interface Ppk2SampleBatch { + /** Calibrated current samples in microamps. */ + microAmps: Float64Array; + /** Digital logic-port byte per sample (bits 24-31 of the raw word). */ + logic: Uint8Array; +} + +const ADC_MULT = 1.8 / 163840; +const SPIKE_FILTER_ALPHA = 0.18; +const SPIKE_FILTER_ALPHA5 = 0.06; +const SPIKE_FILTER_SAMPLES = 3; + +/** + * Streaming decoder for the PPK2 measurement stream. + * + * Feed it arbitrary chunks of the serial byte stream; it decodes every + * complete 4-byte little-endian word into a calibrated microamp value and + * carries any trailing partial word over to the next feed() call. Output is + * invariant to how the stream is split into chunks. + * + * Deliberate divergence from the Python reference: ppk2_api's get_samples() + * zero-pads and decodes a leading partial word when fewer than 4 bytes are + * available; this port only ever decodes complete words. + */ +export class Ppk2SampleDecoder { + private modifiers: Ppk2Modifiers; + private sourceVoltageMv: number; + + private remainder = new Uint8Array(0); + + // Spike-filter state (mirrors ppk2_api.get_adc_result). + private rollingAvg: number | null = null; + private rollingAvg4: number | null = null; + private prevRange: number | null = null; + private consecutiveRangeSamples = 0; + private afterSpike = 0; + + constructor(modifiers: Readonly, sourceVoltageMv: number) { + this.modifiers = clonePpk2Modifiers(modifiers); + this.sourceVoltageMv = sourceVoltageMv; + } + + setModifiers(modifiers: Readonly): void { + this.modifiers = clonePpk2Modifiers(modifiers); + } + + setSourceVoltage(mV: number): void { + this.sourceVoltageMv = mV; + } + + /** Clear the partial-word remainder and all spike-filter state. */ + reset(): void { + this.remainder = new Uint8Array(0); + this.rollingAvg = null; + this.rollingAvg4 = null; + this.prevRange = null; + this.consecutiveRangeSamples = 0; + this.afterSpike = 0; + } + + /** Decode a chunk of the serial stream into calibrated samples. */ + feed(chunk: Uint8Array): Ppk2SampleBatch { + let buf: Uint8Array; + if (this.remainder.length > 0) { + buf = new Uint8Array(this.remainder.length + chunk.length); + buf.set(this.remainder, 0); + buf.set(chunk, this.remainder.length); + } else { + buf = chunk; + } + + const wordCount = Math.floor(buf.length / PPK2_SAMPLE_BYTES); + const microAmps = new Float64Array(wordCount); + const logic = new Uint8Array(wordCount); + + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + for (let w = 0; w < wordCount; w++) { + const word = view.getUint32(w * PPK2_SAMPLE_BYTES, true); + const adcResult = (word & 0x3fff) * 4; + const range = Math.min((word >>> 14) & 0x7, 4); + microAmps[w] = this.calibrate(range, adcResult) * 1e6; + logic[w] = (word >>> 24) & 0xff; + } + + const consumed = wordCount * PPK2_SAMPLE_BYTES; + // slice() (not subarray) so the remainder does not pin the incoming chunk. + this.remainder = buf.slice(consumed); + + return { microAmps, logic }; + } + + /** + * Calibration polynomial + spike filter, ported verbatim from + * ppk2_api.get_adc_result. Returns amps (not microamps). + */ + private calibrate(range: number, adcResult: number): number { + const m = this.modifiers; + const resultWithoutGain = (adcResult - m.o[range]) * (ADC_MULT / m.r[range]); + let adc = + m.ug[range] * + (resultWithoutGain * (m.gs[range] * resultWithoutGain + m.gi[range]) + + (m.s[range] * (this.sourceVoltageMv / 1000) + m.i[range])); + + const prevRollingAvg = this.rollingAvg; + const prevRollingAvg4 = this.rollingAvg4; + + this.rollingAvg = + this.rollingAvg === null + ? adc + : SPIKE_FILTER_ALPHA * adc + (1 - SPIKE_FILTER_ALPHA) * this.rollingAvg; + this.rollingAvg4 = + this.rollingAvg4 === null + ? adc + : SPIKE_FILTER_ALPHA5 * adc + (1 - SPIKE_FILTER_ALPHA5) * this.rollingAvg4; + + if (this.prevRange === null) { + this.prevRange = range; + } + + if (this.prevRange !== range || this.afterSpike > 0) { + if (this.prevRange !== range) { + this.consecutiveRangeSamples = 0; + this.afterSpike = SPIKE_FILTER_SAMPLES; + } else { + this.consecutiveRangeSamples += 1; + } + + if (range === 4) { + if (this.consecutiveRangeSamples < 2) { + this.rollingAvg = prevRollingAvg; + this.rollingAvg4 = prevRollingAvg4; + } + adc = this.rollingAvg4 as number; + } else { + adc = this.rollingAvg as number; + } + + this.afterSpike -= 1; + } + + this.prevRange = range; + return adc; + } +} + +/** Incremental mean/min/max over a stream of sample batches. Bounded memory. */ +export class RunningStats { + count = 0; + min = Number.POSITIVE_INFINITY; + max = Number.NEGATIVE_INFINITY; + private sum = 0; + + add(samples: ArrayLike): void { + for (let i = 0; i < samples.length; i++) { + const v = samples[i]; + this.sum += v; + if (v < this.min) this.min = v; + if (v > this.max) this.max = v; + } + this.count += samples.length; + } + + get mean(): number { + return this.count === 0 ? NaN : this.sum / this.count; + } + + reset(): void { + this.count = 0; + this.sum = 0; + this.min = Number.POSITIVE_INFINITY; + this.max = Number.NEGATIVE_INFINITY; + } +} + +/** One downsampled bin. */ +export interface MinMaxBin { + min: number; + max: number; + mean: number; +} + +/** + * Peak-preserving downsampler: folds a 100 kS/s stream into fixed-size bins + * of {min, max, mean}, optionally keeping only the most recent `maxBins` + * (ring buffer) so live-view memory stays bounded. + */ +export class MinMaxDownsampler { + readonly binSize: number; + private readonly maxBins: number; + + private bins: MinMaxBin[] = []; + private head = 0; // ring start index once capped + private curCount = 0; + private curSum = 0; + private curMin = Number.POSITIVE_INFINITY; + private curMax = Number.NEGATIVE_INFINITY; + private total = 0; + + constructor(binSize: number, maxBins = Number.POSITIVE_INFINITY) { + if (!Number.isInteger(binSize) || binSize < 1) { + throw new Error('binSize must be a positive integer'); + } + this.binSize = binSize; + this.maxBins = maxBins; + } + + /** Total number of samples pushed (including those in the partial bin). */ + get totalSamples(): number { + return this.total; + } + + get binCount(): number { + return this.bins.length; + } + + push(samples: ArrayLike): void { + for (let i = 0; i < samples.length; i++) { + const v = samples[i]; + this.curSum += v; + if (v < this.curMin) this.curMin = v; + if (v > this.curMax) this.curMax = v; + this.curCount += 1; + if (this.curCount === this.binSize) this.commitBin(); + } + this.total += samples.length; + } + + private commitBin(): void { + const bin: MinMaxBin = { + min: this.curMin, + max: this.curMax, + mean: this.curSum / this.curCount, + }; + if (this.bins.length < this.maxBins) { + this.bins.push(bin); + } else { + this.bins[this.head] = bin; + this.head = (this.head + 1) % this.bins.length; + } + this.curCount = 0; + this.curSum = 0; + this.curMin = Number.POSITIVE_INFINITY; + this.curMax = Number.NEGATIVE_INFINITY; + } + + /** Completed bins in chronological order (excludes the partial bin). */ + snapshot(): MinMaxBin[] { + if (this.head === 0) return this.bins.slice(); + return [...this.bins.slice(this.head), ...this.bins.slice(0, this.head)]; + } + + reset(): void { + this.bins = []; + this.head = 0; + this.curCount = 0; + this.curSum = 0; + this.curMin = Number.POSITIVE_INFINITY; + this.curMax = Number.NEGATIVE_INFINITY; + this.total = 0; + } +} diff --git a/tests/instruments/ppk2/codec.test.ts b/tests/instruments/ppk2/codec.test.ts new file mode 100644 index 0000000..a5676b3 --- /dev/null +++ b/tests/instruments/ppk2/codec.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + convertSourceVoltage, + clampSourceVoltageMv, + parsePpk2Metadata, + DEFAULT_PPK2_MODIFIERS, +} from '../../../src/instruments/ppk2/ppk2Codec.js'; + +interface Fixture { + voltageVectors: { mv: number; bytes: [number, number] }[]; + metadata: { + text: string; + expected: { + calibrated: string | null; + hw: string | null; + ia: string | null; + r: number[]; + gs: number[]; + gi: number[]; + o: number[]; + s: number[]; + i: number[]; + ug: number[]; + }; + }; +} + +const fixture: Fixture = JSON.parse( + readFileSync(join(__dirname, 'fixtures', 'ppk2-fixtures.json'), 'utf-8'), +); + +describe('convertSourceVoltage', () => { + it('matches the Python reference for every fixture vector (incl. clamps)', () => { + for (const { mv, bytes } of fixture.voltageVectors) { + expect(convertSourceVoltage(mv), `mV=${mv}`).toEqual(bytes); + } + }); + + it('encodes the 800 mV baseline as [3, 32]', () => { + expect(convertSourceVoltage(800)).toEqual([3, 32]); + }); + + it('clamps out-of-range requests', () => { + expect(clampSourceVoltageMv(500)).toBe(800); + expect(clampSourceVoltageMv(6000)).toBe(5000); + expect(convertSourceVoltage(500)).toEqual(convertSourceVoltage(800)); + expect(convertSourceVoltage(6000)).toEqual(convertSourceVoltage(5000)); + }); +}); + +describe('parsePpk2Metadata', () => { + it('matches the Python reference parse of a realistic metadata blob', () => { + const parsed = parsePpk2Metadata(fixture.metadata.text); + const expected = fixture.metadata.expected; + expect(parsed.calibrated).toBe(expected.calibrated); + expect(parsed.hw).toBe(expected.hw); + expect(parsed.ia).toBe(expected.ia); + expect(parsed.r).toEqual(expected.r); + expect(parsed.gs).toEqual(expected.gs); + expect(parsed.gi).toEqual(expected.gi); + expect(parsed.o).toEqual(expected.o); + expect(parsed.s).toEqual(expected.s); + expect(parsed.i).toEqual(expected.i); + expect(parsed.ug).toEqual(expected.ug); + }); + + it('ignores an R value of exactly 0 (broken calibration), keeping the default', () => { + const parsed = parsePpk2Metadata('R2: 0\nEND\n'); + expect(parsed.r[2]).toBe(DEFAULT_PPK2_MODIFIERS.r[2]); + }); + + it('keeps defaults for keys missing from the blob', () => { + const parsed = parsePpk2Metadata('R0: 999.5\nEND\n'); + expect(parsed.r[0]).toBe(999.5); + expect(parsed.r[1]).toBe(DEFAULT_PPK2_MODIFIERS.r[1]); + expect(parsed.gs).toEqual([...DEFAULT_PPK2_MODIFIERS.gs]); + expect(parsed.calibrated).toBeNull(); + }); + + it('ignores unknown keys, malformed lines, and out-of-range indexes', () => { + const parsed = parsePpk2Metadata( + 'mode: 2\nVDD: 3700\nnoseparator\nR7: 5\nR12: 5\nGSX: 5\nUG1: not-a-number\nEND\n', + ); + expect(parsed.r).toEqual([...DEFAULT_PPK2_MODIFIERS.r]); + expect(parsed.ug).toEqual([...DEFAULT_PPK2_MODIFIERS.ug]); + }); +}); diff --git a/tests/instruments/ppk2/decoder.test.ts b/tests/instruments/ppk2/decoder.test.ts new file mode 100644 index 0000000..8b3fb90 --- /dev/null +++ b/tests/instruments/ppk2/decoder.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + Ppk2SampleDecoder, + DEFAULT_PPK2_MODIFIERS, + RunningStats, + MinMaxDownsampler, + type Ppk2Modifiers, +} from '../../../src/instruments/ppk2/ppk2Codec.js'; + +interface DecodeVector { + name: string; + vddMv: number; + modifiers: Ppk2Modifiers; + rawBase64: string; + expectedMicroAmps: number[]; + expectedLogic: number[]; +} + +const fixture: { decodeVectors: DecodeVector[] } = JSON.parse( + readFileSync(join(__dirname, 'fixtures', 'ppk2-fixtures.json'), 'utf-8'), +); + +function rawBytes(v: DecodeVector): Uint8Array { + return Uint8Array.from(Buffer.from(v.rawBase64, 'base64')); +} + +function feedInChunks(decoder: Ppk2SampleDecoder, raw: Uint8Array, chunkSize: number): number[] { + const out: number[] = []; + for (let off = 0; off < raw.length; off += chunkSize) { + const batch = decoder.feed(raw.subarray(off, Math.min(off + chunkSize, raw.length))); + out.push(...batch.microAmps); + } + return out; +} + +function expectClose(actual: number[], expected: number[], label: string): void { + expect(actual.length, label).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + const tol = Math.max(1e-9, Math.abs(expected[i]) * 1e-12); + expect(Math.abs(actual[i] - expected[i]), `${label}[${i}]`).toBeLessThanOrEqual(tol); + } +} + +describe('Ppk2SampleDecoder vs Python reference fixtures', () => { + for (const vector of fixture.decodeVectors) { + it(`decodes "${vector.name}" identically to ppk2_api`, () => { + const decoder = new Ppk2SampleDecoder(vector.modifiers, vector.vddMv); + const batch = decoder.feed(rawBytes(vector)); + expectClose(Array.from(batch.microAmps), vector.expectedMicroAmps, vector.name); + expect(Array.from(batch.logic)).toEqual(vector.expectedLogic); + }); + } + + it('is invariant to how the stream is split into chunks', () => { + const vector = fixture.decodeVectors.find((v) => v.name === 'calibrated_range_transitions')!; + const raw = rawBytes(vector); + for (const chunkSize of [1, 3, 4, 5, 7, 4096]) { + const decoder = new Ppk2SampleDecoder(vector.modifiers, vector.vddMv); + const out = feedInChunks(decoder, raw, chunkSize); + expectClose(out, vector.expectedMicroAmps, `chunkSize=${chunkSize}`); + } + }); + + it('reset() clears filter and remainder state (same input twice, same output)', () => { + const vector = fixture.decodeVectors.find((v) => v.name === 'calibrated_range_transitions')!; + const raw = rawBytes(vector); + const decoder = new Ppk2SampleDecoder(vector.modifiers, vector.vddMv); + + // Leave the decoder mid-word and with filter state, then reset. + decoder.feed(raw.subarray(0, 13)); + decoder.reset(); + + const out = Array.from(decoder.feed(raw).microAmps); + expectClose(out, vector.expectedMicroAmps, 'after reset'); + }); + + it('holds back a trailing partial word until completed', () => { + const decoder = new Ppk2SampleDecoder(DEFAULT_PPK2_MODIFIERS, 3700); + const word = new Uint8Array([0x10, 0x80, 0x00, 0x00]); // adc, range 2 + expect(decoder.feed(word.subarray(0, 3)).microAmps.length).toBe(0); + const batch = decoder.feed(word.subarray(3)); + expect(batch.microAmps.length).toBe(1); + }); +}); + +describe('RunningStats', () => { + it('tracks count/mean/min/max incrementally', () => { + const stats = new RunningStats(); + stats.add(Float64Array.from([1, 2, 3])); + stats.add(Float64Array.from([4])); + expect(stats.count).toBe(4); + expect(stats.mean).toBeCloseTo(2.5, 12); + expect(stats.min).toBe(1); + expect(stats.max).toBe(4); + stats.reset(); + expect(stats.count).toBe(0); + expect(Number.isNaN(stats.mean)).toBe(true); + }); +}); + +describe('MinMaxDownsampler', () => { + it('bins min/max/mean and excludes the partial bin', () => { + const ds = new MinMaxDownsampler(4); + ds.push(Float64Array.from([1, 5, 3, 3, 10, 10, 10])); + expect(ds.binCount).toBe(1); + expect(ds.snapshot()).toEqual([{ min: 1, max: 5, mean: 3 }]); + expect(ds.totalSamples).toBe(7); + ds.push(Float64Array.from([10])); + expect(ds.snapshot()).toEqual([ + { min: 1, max: 5, mean: 3 }, + { min: 10, max: 10, mean: 10 }, + ]); + }); + + it('acts as a ring buffer once maxBins is reached', () => { + const ds = new MinMaxDownsampler(1, 3); + ds.push(Float64Array.from([1, 2, 3, 4, 5])); + expect(ds.binCount).toBe(3); + expect(ds.snapshot().map((b) => b.mean)).toEqual([3, 4, 5]); + }); +}); diff --git a/tests/instruments/ppk2/fixtures/generate_fixtures.py b/tests/instruments/ppk2/fixtures/generate_fixtures.py new file mode 100644 index 0000000..b08182a --- /dev/null +++ b/tests/instruments/ppk2/fixtures/generate_fixtures.py @@ -0,0 +1,198 @@ +"""One-shot fixture generator for the TypeScript PPK2 codec tests. + +Runs the *original Python* ppk2_api implementation (no PPK2 hardware needed -- +the serial port is mocked) over synthetic inputs and dumps its outputs as the +ground truth that tests/instruments/ppk2/*.test.ts compare the TypeScript port +against. + +Usage (from this directory): + python generate_fixtures.py + +Requires the ppk2-api package (the production test PC setup already has it: +C:\\Python310\\Lib\\site-packages\\ppk2_api). +""" + +import base64 +import json +import os +from unittest import mock + +import serial # noqa: F401 (pyserial; only mocked, never opened) +from ppk2_api.ppk2_api import PPK2_API + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Plausible calibrated-device modifiers (shape matches a real metadata blob). +CALIBRATED_MODIFIERS = { + "Calibrated": "0", + "R": {"0": 1003.3506, "1": 101.5865, "2": 10.2101, "3": 0.9433, "4": 0.0422}, + "GS": {"0": 0.0, "1": 0.0, "2": 0.0001, "3": 0.0154, "4": 0.07}, + "GI": {"0": 1.0, "1": 0.9997, "2": 0.9973, "3": 0.9905, "4": 0.9388}, + "O": {"0": 112.942, "1": 75.4848, "2": 64.5762, "3": 50.4691, "4": 27.0347}, + "S": {"0": 0.000000375, "1": 0.0000039, "2": 0.0000287, "3": 0.000278, "4": 0.005151}, + "I": {"0": -0.0000000037, "1": -0.0000009, "2": 0.0000119, "3": 0.0001945, "4": 0.0175}, + "UG": {"0": 1.0, "1": 1.0, "2": 1.0004, "3": 1.0027, "4": 1.0058}, + "HW": "9173", + "IA": "56", +} + +METADATA_TEXT = ( + "Calibrated: 0\n" + "R0: 1003.3506\n" + "R1: 101.5865\n" + "R2: 0\n" # broken zero calibration -> must be ignored (default retained) + "R3: 0.9433\n" + "R4: 0.0422\n" + "GS0: 0.0\n" + "GS1: 0.0\n" + "GS2: 0.0001\n" + "GS3: 0.0154\n" + "GS4: 0.07\n" + "GI0: 1.0\n" + "GI1: 0.9997\n" + "GI2: 0.9973\n" + "GI3: 0.9905\n" + "GI4: 0.9388\n" + "O0: 112.9420\n" + "O1: 75.4848\n" + "O2: 64.5762\n" + "O3: 50.4691\n" + "O4: 27.0347\n" + "S0: 0.000000375\n" + "S1: 0.0000039\n" + "S2: 0.0000287\n" + "S3: 0.000278\n" + "S4: 0.005151\n" + "I0: -0.0000000037\n" + "I1: -0.0000009\n" + "I2: 0.0000119\n" + "I3: 0.0001945\n" + "I4: 0.0175\n" + "UG0: 1.0\n" + "UG1: 1.0\n" + "UG2: 1.0004\n" + "UG3: 1.0027\n" + "UG4: 1.0058\n" + "VDD: 3700\n" + "mode: 2\n" + "HW: 9173\n" + "IA: 56\n" + "END\n" +) + + +def make_api(modifiers=None, vdd_mv=3700): + with mock.patch.object(serial, "Serial"): + api = PPK2_API("MOCK") + if modifiers is not None: + # Deep-ish copy so each vector starts from clean state + api.modifiers = json.loads(json.dumps(modifiers)) + api.current_vdd = vdd_mv + return api + + +def word(adc14, rng, logic=0): + value = (adc14 & 0x3FFF) | ((rng & 0x7) << 14) | ((logic & 0xFF) << 24) + return value.to_bytes(4, "little") + + +def modifiers_to_arrays(modifiers): + def table(key): + return [modifiers[key][str(i)] for i in range(5)] + + return { + "calibrated": modifiers.get("Calibrated"), + "hw": modifiers.get("HW"), + "ia": modifiers.get("IA"), + "r": table("R"), + "gs": table("GS"), + "gi": table("GI"), + "o": table("O"), + "s": table("S"), + "i": table("I"), + "ug": table("UG"), + } + + +def decode_vector(name, modifiers, vdd_mv, raw): + api = make_api(modifiers, vdd_mv) + samples, raw_digital = api.get_samples(raw) + return { + "name": name, + "vddMv": vdd_mv, + "modifiers": modifiers_to_arrays(api.modifiers), + "rawBase64": base64.b64encode(raw).decode("ascii"), + "expectedMicroAmps": samples, + "expectedLogic": raw_digital, + } + + +def build_decode_vectors(): + vectors = [] + + # 1. Default (uncalibrated) modifiers, single range, ADC ramp. + raw = b"".join(word(100 + 37 * i, 2) for i in range(48)) + vectors.append(decode_vector("uncalibrated_single_range", None, 3700, raw)) + + # 2. Calibrated modifiers, range transitions engaging the spike filter, + # including range 4 (the rolling-average restore branch) and a return + # to lower ranges. + seq = ( + [(3000, 0)] * 6 + + [(2900, 1)] * 5 + + [(2800, 2)] * 5 + + [(2700, 3)] * 5 + + [(2600, 4)] * 2 # < 2 consecutive samples in range 4 + + [(2500, 3)] * 4 + + [(2400, 4)] * 8 # sustained range 4 + + [(2300, 2)] * 6 + + [(2200, 4)] * 1 # single-sample spike into range 4 + + [(2100, 2)] * 6 + ) + raw = b"".join(word(a, r) for a, r in seq) + vectors.append(decode_vector("calibrated_range_transitions", CALIBRATED_MODIFIERS, 3700, raw)) + + # 3. Logic-port bits preserved alongside the analog samples. + raw = b"".join(word(1234, 3, logic=(i * 17) & 0xFF) for i in range(32)) + vectors.append(decode_vector("logic_bits", CALIBRATED_MODIFIERS, 5000, raw)) + + # 4. Low source voltage exercises the S*(vdd/1000) term differently. + raw = b"".join(word(600 + 11 * i, 1) for i in range(24)) + vectors.append(decode_vector("uncalibrated_low_vdd", None, 800, raw)) + + return vectors + + +def build_voltage_vectors(): + api = make_api() + vectors = [] + for mv in [500, 800, 801, 1000, 1056, 2000, 3000, 3300, 3700, 4200, 5000, 6000]: + b1, b2 = api._convert_source_voltage(mv) + vectors.append({"mv": mv, "bytes": [b1, b2]}) + return vectors + + +def build_metadata_fixture(): + api = make_api() + ok = api._parse_metadata(METADATA_TEXT) + assert ok, "Python metadata parse failed" + return {"text": METADATA_TEXT, "expected": modifiers_to_arrays(api.modifiers)} + + +def main(): + fixture = { + "generatedBy": "generate_fixtures.py against ppk2_api (Python reference)", + "voltageVectors": build_voltage_vectors(), + "metadata": build_metadata_fixture(), + "decodeVectors": build_decode_vectors(), + } + out_path = os.path.join(HERE, "ppk2-fixtures.json") + with open(out_path, "w", encoding="utf-8") as f: + json.dump(fixture, f, indent=1) + print(f"Wrote {out_path}") + for v in fixture["decodeVectors"]: + print(f" {v['name']}: {len(v['expectedMicroAmps'])} samples") + + +if __name__ == "__main__": + main() diff --git a/tests/instruments/ppk2/fixtures/ppk2-fixtures.json b/tests/instruments/ppk2/fixtures/ppk2-fixtures.json new file mode 100644 index 0000000..ffd87fc --- /dev/null +++ b/tests/instruments/ppk2/fixtures/ppk2-fixtures.json @@ -0,0 +1,704 @@ +{ + "generatedBy": "generate_fixtures.py against ppk2_api (Python reference)", + "voltageVectors": [ + { + "mv": 500, + "bytes": [ + 3, + 32 + ] + }, + { + "mv": 800, + "bytes": [ + 3, + 32 + ] + }, + { + "mv": 801, + "bytes": [ + 3, + 33 + ] + }, + { + "mv": 1000, + "bytes": [ + 3, + 232 + ] + }, + { + "mv": 1056, + "bytes": [ + 4, + 32 + ] + }, + { + "mv": 2000, + "bytes": [ + 7, + 208 + ] + }, + { + "mv": 3000, + "bytes": [ + 11, + 184 + ] + }, + { + "mv": 3300, + "bytes": [ + 12, + 228 + ] + }, + { + "mv": 3700, + "bytes": [ + 14, + 116 + ] + }, + { + "mv": 4200, + "bytes": [ + 16, + 104 + ] + }, + { + "mv": 5000, + "bytes": [ + 19, + 136 + ] + }, + { + "mv": 6000, + "bytes": [ + 19, + 136 + ] + } + ], + "metadata": { + "text": "Calibrated: 0\nR0: 1003.3506\nR1: 101.5865\nR2: 0\nR3: 0.9433\nR4: 0.0422\nGS0: 0.0\nGS1: 0.0\nGS2: 0.0001\nGS3: 0.0154\nGS4: 0.07\nGI0: 1.0\nGI1: 0.9997\nGI2: 0.9973\nGI3: 0.9905\nGI4: 0.9388\nO0: 112.9420\nO1: 75.4848\nO2: 64.5762\nO3: 50.4691\nO4: 27.0347\nS0: 0.000000375\nS1: 0.0000039\nS2: 0.0000287\nS3: 0.000278\nS4: 0.005151\nI0: -0.0000000037\nI1: -0.0000009\nI2: 0.0000119\nI3: 0.0001945\nI4: 0.0175\nUG0: 1.0\nUG1: 1.0\nUG2: 1.0004\nUG3: 1.0027\nUG4: 1.0058\nVDD: 3700\nmode: 2\nHW: 9173\nIA: 56\nEND\n", + "expected": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [ + 1003.3506, + 101.5865, + 10.15, + 0.9433, + 0.0422 + ], + "gs": [ + 0.0, + 0.0, + 0.0001, + 0.0154, + 0.07 + ], + "gi": [ + 1.0, + 0.9997, + 0.9973, + 0.9905, + 0.9388 + ], + "o": [ + 112.942, + 75.4848, + 64.5762, + 50.4691, + 27.0347 + ], + "s": [ + 3.75e-07, + 3.9e-06, + 2.87e-05, + 0.000278, + 0.005151 + ], + "i": [ + -3.7e-09, + -9e-07, + 1.19e-05, + 0.0001945, + 0.0175 + ], + "ug": [ + 1.0, + 1.0, + 1.0004, + 1.0027, + 1.0058 + ] + } + }, + "decodeVectors": [ + { + "name": "uncalibrated_single_range", + "vddMv": 3700, + "modifiers": { + "calibrated": null, + "hw": null, + "ia": null, + "r": [ + 1031.64, + 101.65, + 10.15, + 0.94, + 0.043 + ], + "gs": [ + 1, + 1, + 1, + 1, + 1 + ], + "gi": [ + 1, + 1, + 1, + 1, + 1 + ], + "o": [ + 0, + 0, + 0, + 0, + 0 + ], + "s": [ + 0, + 0, + 0, + 0, + 0 + ], + "i": [ + 0, + 0, + 0, + 0, + 0 + ], + "ug": [ + 1, + 1, + 1, + 1, + 1 + ] + }, + "rawBase64": "ZIAAAImAAACugAAA04AAAPiAAAAdgQAAQoEAAGeBAACMgQAAsYEAANaBAAD7gQAAIIIAAEWCAABqggAAj4IAALSCAADZggAA/oIAACODAABIgwAAbYMAAJKDAAC3gwAA3IMAAAGEAAAmhAAAS4QAAHCEAACVhAAAuoQAAN+EAAAEhQAAKYUAAE6FAABzhQAAmIUAAL2FAADihQAAB4YAACyGAABRhgAAdoYAAJuGAADAhgAA5YYAAAqHAAAvhwAA", + "expectedMicroAmps": [ + 433.146197116234, + 593.5053101137651, + 753.9157478176817, + 914.3775102279836, + 1074.890597344671, + 1235.4550091677443, + 1396.070745697203, + 1556.7378069330468, + 1717.4561928752762, + 1878.2259035238912, + 2039.0469388788913, + 2199.9192989402777, + 2360.8429837080494, + 2521.8179931822056, + 2682.844327362748, + 2843.921986249676, + 3005.050969842989, + 3166.231278142688, + 3327.4629111487725, + 3488.7458688612423, + 3650.080151280097, + 3811.465758405338, + 3972.902690236964, + 4134.390946774976, + 4295.930528019373, + 4457.521433970157, + 4619.163664627325, + 4780.857219990879, + 4942.602100060818, + 5104.398304837143, + 5266.245834319852, + 5428.144688508948, + 5590.094867404429, + 5752.096371006296, + 5914.149199314548, + 6076.253352329185, + 6238.408830050208, + 6400.615632477617, + 6562.873759611411, + 6725.18321145159, + 6887.543987998155, + 7049.956089251105, + 7212.419515210441, + 7374.934265876162, + 7537.500341248269, + 7700.117741326763, + 7862.786466111641, + 8025.506515602904 + ], + "expectedLogic": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "calibrated_range_transitions", + "vddMv": 3700, + "modifiers": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [ + 1003.3506, + 101.5865, + 10.2101, + 0.9433, + 0.0422 + ], + "gs": [ + 0.0, + 0.0, + 0.0001, + 0.0154, + 0.07 + ], + "gi": [ + 1.0, + 0.9997, + 0.9973, + 0.9905, + 0.9388 + ], + "o": [ + 112.942, + 75.4848, + 64.5762, + 50.4691, + 27.0347 + ], + "s": [ + 3.75e-07, + 3.9e-06, + 2.87e-05, + 0.000278, + 0.005151 + ], + "i": [ + -3.7e-09, + -9e-07, + 1.19e-05, + 0.0001945, + 0.0175 + ], + "ug": [ + 1.0, + 1.0, + 1.0004, + 1.0027, + 1.0058 + ] + }, + "rawBase64": "uAsAALgLAAC4CwAAuAsAALgLAAC4CwAAVEsAAFRLAABUSwAAVEsAAFRLAADwigAA8IoAAPCKAADwigAA8IoAAIzKAACMygAAjMoAAIzKAACMygAAKAoBACgKAQDEyQAAxMkAAMTJAADEyQAAYAkBAGAJAQBgCQEAYAkBAGAJAQBgCQEAYAkBAGAJAQD8iAAA/IgAAPyIAAD8iAAA/IgAAPyIAACYCAEANIgAADSIAAA0iAAANIgAADSIAAA0iAAA", + "expectedMicroAmps": [ + 131.5428088538406, + 131.5428088538406, + 131.5428088538406, + 131.5428088538406, + 131.5428088538406, + 131.5428088538406, + 334.57579506250187, + 501.0628437536041, + 637.5822236803081, + 1259.5038433464033, + 1259.5038433464033, + 2862.9499083867026, + 4520.683361311804, + 5880.024792710388, + 12072.580202415042, + 12072.580202415042, + 29131.011739692833, + 46533.30249139866, + 60803.18090779743, + 125810.40480472519, + 125810.40480472519, + 36067.79772695207, + 36067.79772695207, + 88295.58180557229, + 93376.32997186817, + 97542.54346823078, + 116521.96050721603, + 53707.45658885421, + 53707.45658885421, + 220125.51341007874, + 2827341.7369425963, + 2827341.7369425963, + 2827341.7369425963, + 2827341.7369425963, + 2827341.7369425963, + 1640559.1662885298, + 1347045.1021293458, + 1106363.5695188148, + 9925.476515284337, + 9925.476515284337, + 9925.476515284337, + 633546.9153543299, + 505494.4054363133, + 416137.40681175713, + 342864.66793962114, + 9066.63529988992, + 9066.63529988992, + 9066.63529988992 + ], + "expectedLogic": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "logic_bits", + "vddMv": 5000, + "modifiers": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [ + 1003.3506, + 101.5865, + 10.2101, + 0.9433, + 0.0422 + ], + "gs": [ + 0.0, + 0.0, + 0.0001, + 0.0154, + 0.07 + ], + "gi": [ + 1.0, + 0.9997, + 0.9973, + 0.9905, + 0.9388 + ], + "o": [ + 112.942, + 75.4848, + 64.5762, + 50.4691, + 27.0347 + ], + "s": [ + 3.75e-07, + 3.9e-06, + 2.87e-05, + 0.000278, + 0.005151 + ], + "i": [ + -3.7e-09, + -9e-07, + 1.19e-05, + 0.0001945, + 0.0175 + ], + "ug": [ + 1.0, + 1.0, + 1.0004, + 1.0027, + 1.0058 + ] + }, + "rawBase64": "0sQAANLEABHSxAAi0sQAM9LEAETSxABV0sQAZtLEAHfSxACI0sQAmdLEAKrSxAC70sQAzNLEAN3SxADu0sQA/9LEABDSxAAh0sQAMtLEAEPSxABU0sQAZdLEAHbSxACH0sQAmNLEAKnSxAC60sQAy9LEANzSxADt0sQA/tLEAA8=", + "expectedMicroAmps": [ + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038, + 58150.68309233038 + ], + "expectedLogic": [ + 0, + 17, + 34, + 51, + 68, + 85, + 102, + 119, + 136, + 153, + 170, + 187, + 204, + 221, + 238, + 255, + 16, + 33, + 50, + 67, + 84, + 101, + 118, + 135, + 152, + 169, + 186, + 203, + 220, + 237, + 254, + 15 + ] + }, + { + "name": "uncalibrated_low_vdd", + "vddMv": 800, + "modifiers": { + "calibrated": null, + "hw": null, + "ia": null, + "r": [ + 1031.64, + 101.65, + 10.15, + 0.94, + 0.043 + ], + "gs": [ + 1, + 1, + 1, + 1, + 1 + ], + "gi": [ + 1, + 1, + 1, + 1, + 1 + ], + "o": [ + 0, + 0, + 0, + 0, + 0 + ], + "s": [ + 0, + 0, + 0, + 0, + 0 + ], + "i": [ + 0, + 0, + 0, + 0, + 0 + ], + "ug": [ + 1, + 1, + 1, + 1, + 1 + ] + }, + "rawBase64": "WEIAAGNCAABuQgAAeUIAAIRCAACPQgAAmkIAAKVCAACwQgAAu0IAAMZCAADRQgAA3EIAAOdCAADyQgAA/UIAAAhDAAATQwAAHkMAAClDAAA0QwAAP0MAAEpDAABVQwAA", + "expectedMicroAmps": [ + 259.459192671792, + 264.2172006953697, + 268.97525394885645, + 273.7333524322522, + 278.49149614555705, + 283.2496850887709, + 288.0079192618937, + 292.7661986649256, + 297.52452329786655, + 302.2828931607166, + 307.04130825347556, + 311.7997685761436, + 316.55827412872077, + 321.3168249112069, + 326.075420923602, + 330.8340621659062, + 335.5927486381194, + 340.35148034024166, + 345.11025727227303, + 349.86907943421335, + 354.62794682606267, + 359.3868594478211, + 364.14581729948856, + 368.90482038106495 + ], + "expectedLogic": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + ] +} \ No newline at end of file From 8e3397e10697508e83f940128f8bbaba1aa7f1fe Mon Sep 17 00:00:00 2001 From: marknolan <6526073+marknolan@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:46:49 +0000 Subject: [PATCH 2/3] style: auto-format source files --- .../ppk2/fixtures/ppk2-fixtures.json | 897 ++++-------------- 1 file changed, 195 insertions(+), 702 deletions(-) diff --git a/tests/instruments/ppk2/fixtures/ppk2-fixtures.json b/tests/instruments/ppk2/fixtures/ppk2-fixtures.json index ffd87fc..4193c8b 100644 --- a/tests/instruments/ppk2/fixtures/ppk2-fixtures.json +++ b/tests/instruments/ppk2/fixtures/ppk2-fixtures.json @@ -1,704 +1,197 @@ { - "generatedBy": "generate_fixtures.py against ppk2_api (Python reference)", - "voltageVectors": [ - { - "mv": 500, - "bytes": [ - 3, - 32 - ] + "generatedBy": "generate_fixtures.py against ppk2_api (Python reference)", + "voltageVectors": [ + { + "mv": 500, + "bytes": [3, 32] + }, + { + "mv": 800, + "bytes": [3, 32] + }, + { + "mv": 801, + "bytes": [3, 33] + }, + { + "mv": 1000, + "bytes": [3, 232] + }, + { + "mv": 1056, + "bytes": [4, 32] + }, + { + "mv": 2000, + "bytes": [7, 208] + }, + { + "mv": 3000, + "bytes": [11, 184] + }, + { + "mv": 3300, + "bytes": [12, 228] + }, + { + "mv": 3700, + "bytes": [14, 116] + }, + { + "mv": 4200, + "bytes": [16, 104] + }, + { + "mv": 5000, + "bytes": [19, 136] + }, + { + "mv": 6000, + "bytes": [19, 136] + } + ], + "metadata": { + "text": "Calibrated: 0\nR0: 1003.3506\nR1: 101.5865\nR2: 0\nR3: 0.9433\nR4: 0.0422\nGS0: 0.0\nGS1: 0.0\nGS2: 0.0001\nGS3: 0.0154\nGS4: 0.07\nGI0: 1.0\nGI1: 0.9997\nGI2: 0.9973\nGI3: 0.9905\nGI4: 0.9388\nO0: 112.9420\nO1: 75.4848\nO2: 64.5762\nO3: 50.4691\nO4: 27.0347\nS0: 0.000000375\nS1: 0.0000039\nS2: 0.0000287\nS3: 0.000278\nS4: 0.005151\nI0: -0.0000000037\nI1: -0.0000009\nI2: 0.0000119\nI3: 0.0001945\nI4: 0.0175\nUG0: 1.0\nUG1: 1.0\nUG2: 1.0004\nUG3: 1.0027\nUG4: 1.0058\nVDD: 3700\nmode: 2\nHW: 9173\nIA: 56\nEND\n", + "expected": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [1003.3506, 101.5865, 10.15, 0.9433, 0.0422], + "gs": [0.0, 0.0, 0.0001, 0.0154, 0.07], + "gi": [1.0, 0.9997, 0.9973, 0.9905, 0.9388], + "o": [112.942, 75.4848, 64.5762, 50.4691, 27.0347], + "s": [3.75e-7, 3.9e-6, 2.87e-5, 0.000278, 0.005151], + "i": [-3.7e-9, -9e-7, 1.19e-5, 0.0001945, 0.0175], + "ug": [1.0, 1.0, 1.0004, 1.0027, 1.0058] + } }, - { - "mv": 800, - "bytes": [ - 3, - 32 - ] - }, - { - "mv": 801, - "bytes": [ - 3, - 33 - ] - }, - { - "mv": 1000, - "bytes": [ - 3, - 232 - ] - }, - { - "mv": 1056, - "bytes": [ - 4, - 32 - ] - }, - { - "mv": 2000, - "bytes": [ - 7, - 208 - ] - }, - { - "mv": 3000, - "bytes": [ - 11, - 184 - ] - }, - { - "mv": 3300, - "bytes": [ - 12, - 228 - ] - }, - { - "mv": 3700, - "bytes": [ - 14, - 116 - ] - }, - { - "mv": 4200, - "bytes": [ - 16, - 104 - ] - }, - { - "mv": 5000, - "bytes": [ - 19, - 136 - ] - }, - { - "mv": 6000, - "bytes": [ - 19, - 136 - ] - } - ], - "metadata": { - "text": "Calibrated: 0\nR0: 1003.3506\nR1: 101.5865\nR2: 0\nR3: 0.9433\nR4: 0.0422\nGS0: 0.0\nGS1: 0.0\nGS2: 0.0001\nGS3: 0.0154\nGS4: 0.07\nGI0: 1.0\nGI1: 0.9997\nGI2: 0.9973\nGI3: 0.9905\nGI4: 0.9388\nO0: 112.9420\nO1: 75.4848\nO2: 64.5762\nO3: 50.4691\nO4: 27.0347\nS0: 0.000000375\nS1: 0.0000039\nS2: 0.0000287\nS3: 0.000278\nS4: 0.005151\nI0: -0.0000000037\nI1: -0.0000009\nI2: 0.0000119\nI3: 0.0001945\nI4: 0.0175\nUG0: 1.0\nUG1: 1.0\nUG2: 1.0004\nUG3: 1.0027\nUG4: 1.0058\nVDD: 3700\nmode: 2\nHW: 9173\nIA: 56\nEND\n", - "expected": { - "calibrated": "0", - "hw": "9173", - "ia": "56", - "r": [ - 1003.3506, - 101.5865, - 10.15, - 0.9433, - 0.0422 - ], - "gs": [ - 0.0, - 0.0, - 0.0001, - 0.0154, - 0.07 - ], - "gi": [ - 1.0, - 0.9997, - 0.9973, - 0.9905, - 0.9388 - ], - "o": [ - 112.942, - 75.4848, - 64.5762, - 50.4691, - 27.0347 - ], - "s": [ - 3.75e-07, - 3.9e-06, - 2.87e-05, - 0.000278, - 0.005151 - ], - "i": [ - -3.7e-09, - -9e-07, - 1.19e-05, - 0.0001945, - 0.0175 - ], - "ug": [ - 1.0, - 1.0, - 1.0004, - 1.0027, - 1.0058 - ] - } - }, - "decodeVectors": [ - { - "name": "uncalibrated_single_range", - "vddMv": 3700, - "modifiers": { - "calibrated": null, - "hw": null, - "ia": null, - "r": [ - 1031.64, - 101.65, - 10.15, - 0.94, - 0.043 - ], - "gs": [ - 1, - 1, - 1, - 1, - 1 - ], - "gi": [ - 1, - 1, - 1, - 1, - 1 - ], - "o": [ - 0, - 0, - 0, - 0, - 0 - ], - "s": [ - 0, - 0, - 0, - 0, - 0 - ], - "i": [ - 0, - 0, - 0, - 0, - 0 - ], - "ug": [ - 1, - 1, - 1, - 1, - 1 - ] - }, - "rawBase64": "ZIAAAImAAACugAAA04AAAPiAAAAdgQAAQoEAAGeBAACMgQAAsYEAANaBAAD7gQAAIIIAAEWCAABqggAAj4IAALSCAADZggAA/oIAACODAABIgwAAbYMAAJKDAAC3gwAA3IMAAAGEAAAmhAAAS4QAAHCEAACVhAAAuoQAAN+EAAAEhQAAKYUAAE6FAABzhQAAmIUAAL2FAADihQAAB4YAACyGAABRhgAAdoYAAJuGAADAhgAA5YYAAAqHAAAvhwAA", - "expectedMicroAmps": [ - 433.146197116234, - 593.5053101137651, - 753.9157478176817, - 914.3775102279836, - 1074.890597344671, - 1235.4550091677443, - 1396.070745697203, - 1556.7378069330468, - 1717.4561928752762, - 1878.2259035238912, - 2039.0469388788913, - 2199.9192989402777, - 2360.8429837080494, - 2521.8179931822056, - 2682.844327362748, - 2843.921986249676, - 3005.050969842989, - 3166.231278142688, - 3327.4629111487725, - 3488.7458688612423, - 3650.080151280097, - 3811.465758405338, - 3972.902690236964, - 4134.390946774976, - 4295.930528019373, - 4457.521433970157, - 4619.163664627325, - 4780.857219990879, - 4942.602100060818, - 5104.398304837143, - 5266.245834319852, - 5428.144688508948, - 5590.094867404429, - 5752.096371006296, - 5914.149199314548, - 6076.253352329185, - 6238.408830050208, - 6400.615632477617, - 6562.873759611411, - 6725.18321145159, - 6887.543987998155, - 7049.956089251105, - 7212.419515210441, - 7374.934265876162, - 7537.500341248269, - 7700.117741326763, - 7862.786466111641, - 8025.506515602904 - ], - "expectedLogic": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - }, - { - "name": "calibrated_range_transitions", - "vddMv": 3700, - "modifiers": { - "calibrated": "0", - "hw": "9173", - "ia": "56", - "r": [ - 1003.3506, - 101.5865, - 10.2101, - 0.9433, - 0.0422 - ], - "gs": [ - 0.0, - 0.0, - 0.0001, - 0.0154, - 0.07 - ], - "gi": [ - 1.0, - 0.9997, - 0.9973, - 0.9905, - 0.9388 - ], - "o": [ - 112.942, - 75.4848, - 64.5762, - 50.4691, - 27.0347 - ], - "s": [ - 3.75e-07, - 3.9e-06, - 2.87e-05, - 0.000278, - 0.005151 - ], - "i": [ - -3.7e-09, - -9e-07, - 1.19e-05, - 0.0001945, - 0.0175 - ], - "ug": [ - 1.0, - 1.0, - 1.0004, - 1.0027, - 1.0058 - ] - }, - "rawBase64": "uAsAALgLAAC4CwAAuAsAALgLAAC4CwAAVEsAAFRLAABUSwAAVEsAAFRLAADwigAA8IoAAPCKAADwigAA8IoAAIzKAACMygAAjMoAAIzKAACMygAAKAoBACgKAQDEyQAAxMkAAMTJAADEyQAAYAkBAGAJAQBgCQEAYAkBAGAJAQBgCQEAYAkBAGAJAQD8iAAA/IgAAPyIAAD8iAAA/IgAAPyIAACYCAEANIgAADSIAAA0iAAANIgAADSIAAA0iAAA", - "expectedMicroAmps": [ - 131.5428088538406, - 131.5428088538406, - 131.5428088538406, - 131.5428088538406, - 131.5428088538406, - 131.5428088538406, - 334.57579506250187, - 501.0628437536041, - 637.5822236803081, - 1259.5038433464033, - 1259.5038433464033, - 2862.9499083867026, - 4520.683361311804, - 5880.024792710388, - 12072.580202415042, - 12072.580202415042, - 29131.011739692833, - 46533.30249139866, - 60803.18090779743, - 125810.40480472519, - 125810.40480472519, - 36067.79772695207, - 36067.79772695207, - 88295.58180557229, - 93376.32997186817, - 97542.54346823078, - 116521.96050721603, - 53707.45658885421, - 53707.45658885421, - 220125.51341007874, - 2827341.7369425963, - 2827341.7369425963, - 2827341.7369425963, - 2827341.7369425963, - 2827341.7369425963, - 1640559.1662885298, - 1347045.1021293458, - 1106363.5695188148, - 9925.476515284337, - 9925.476515284337, - 9925.476515284337, - 633546.9153543299, - 505494.4054363133, - 416137.40681175713, - 342864.66793962114, - 9066.63529988992, - 9066.63529988992, - 9066.63529988992 - ], - "expectedLogic": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - }, - { - "name": "logic_bits", - "vddMv": 5000, - "modifiers": { - "calibrated": "0", - "hw": "9173", - "ia": "56", - "r": [ - 1003.3506, - 101.5865, - 10.2101, - 0.9433, - 0.0422 - ], - "gs": [ - 0.0, - 0.0, - 0.0001, - 0.0154, - 0.07 - ], - "gi": [ - 1.0, - 0.9997, - 0.9973, - 0.9905, - 0.9388 - ], - "o": [ - 112.942, - 75.4848, - 64.5762, - 50.4691, - 27.0347 - ], - "s": [ - 3.75e-07, - 3.9e-06, - 2.87e-05, - 0.000278, - 0.005151 - ], - "i": [ - -3.7e-09, - -9e-07, - 1.19e-05, - 0.0001945, - 0.0175 - ], - "ug": [ - 1.0, - 1.0, - 1.0004, - 1.0027, - 1.0058 - ] - }, - "rawBase64": "0sQAANLEABHSxAAi0sQAM9LEAETSxABV0sQAZtLEAHfSxACI0sQAmdLEAKrSxAC70sQAzNLEAN3SxADu0sQA/9LEABDSxAAh0sQAMtLEAEPSxABU0sQAZdLEAHbSxACH0sQAmNLEAKnSxAC60sQAy9LEANzSxADt0sQA/tLEAA8=", - "expectedMicroAmps": [ - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038, - 58150.68309233038 - ], - "expectedLogic": [ - 0, - 17, - 34, - 51, - 68, - 85, - 102, - 119, - 136, - 153, - 170, - 187, - 204, - 221, - 238, - 255, - 16, - 33, - 50, - 67, - 84, - 101, - 118, - 135, - 152, - 169, - 186, - 203, - 220, - 237, - 254, - 15 - ] - }, - { - "name": "uncalibrated_low_vdd", - "vddMv": 800, - "modifiers": { - "calibrated": null, - "hw": null, - "ia": null, - "r": [ - 1031.64, - 101.65, - 10.15, - 0.94, - 0.043 - ], - "gs": [ - 1, - 1, - 1, - 1, - 1 - ], - "gi": [ - 1, - 1, - 1, - 1, - 1 - ], - "o": [ - 0, - 0, - 0, - 0, - 0 - ], - "s": [ - 0, - 0, - 0, - 0, - 0 - ], - "i": [ - 0, - 0, - 0, - 0, - 0 - ], - "ug": [ - 1, - 1, - 1, - 1, - 1 - ] - }, - "rawBase64": "WEIAAGNCAABuQgAAeUIAAIRCAACPQgAAmkIAAKVCAACwQgAAu0IAAMZCAADRQgAA3EIAAOdCAADyQgAA/UIAAAhDAAATQwAAHkMAAClDAAA0QwAAP0MAAEpDAABVQwAA", - "expectedMicroAmps": [ - 259.459192671792, - 264.2172006953697, - 268.97525394885645, - 273.7333524322522, - 278.49149614555705, - 283.2496850887709, - 288.0079192618937, - 292.7661986649256, - 297.52452329786655, - 302.2828931607166, - 307.04130825347556, - 311.7997685761436, - 316.55827412872077, - 321.3168249112069, - 326.075420923602, - 330.8340621659062, - 335.5927486381194, - 340.35148034024166, - 345.11025727227303, - 349.86907943421335, - 354.62794682606267, - 359.3868594478211, - 364.14581729948856, - 368.90482038106495 - ], - "expectedLogic": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - } - ] -} \ No newline at end of file + "decodeVectors": [ + { + "name": "uncalibrated_single_range", + "vddMv": 3700, + "modifiers": { + "calibrated": null, + "hw": null, + "ia": null, + "r": [1031.64, 101.65, 10.15, 0.94, 0.043], + "gs": [1, 1, 1, 1, 1], + "gi": [1, 1, 1, 1, 1], + "o": [0, 0, 0, 0, 0], + "s": [0, 0, 0, 0, 0], + "i": [0, 0, 0, 0, 0], + "ug": [1, 1, 1, 1, 1] + }, + "rawBase64": "ZIAAAImAAACugAAA04AAAPiAAAAdgQAAQoEAAGeBAACMgQAAsYEAANaBAAD7gQAAIIIAAEWCAABqggAAj4IAALSCAADZggAA/oIAACODAABIgwAAbYMAAJKDAAC3gwAA3IMAAAGEAAAmhAAAS4QAAHCEAACVhAAAuoQAAN+EAAAEhQAAKYUAAE6FAABzhQAAmIUAAL2FAADihQAAB4YAACyGAABRhgAAdoYAAJuGAADAhgAA5YYAAAqHAAAvhwAA", + "expectedMicroAmps": [ + 433.146197116234, 593.5053101137651, 753.9157478176817, 914.3775102279836, + 1074.890597344671, 1235.4550091677443, 1396.070745697203, 1556.7378069330468, + 1717.4561928752762, 1878.2259035238912, 2039.0469388788913, 2199.9192989402777, + 2360.8429837080494, 2521.8179931822056, 2682.844327362748, 2843.921986249676, + 3005.050969842989, 3166.231278142688, 3327.4629111487725, 3488.7458688612423, + 3650.080151280097, 3811.465758405338, 3972.902690236964, 4134.390946774976, + 4295.930528019373, 4457.521433970157, 4619.163664627325, 4780.857219990879, + 4942.602100060818, 5104.398304837143, 5266.245834319852, 5428.144688508948, + 5590.094867404429, 5752.096371006296, 5914.149199314548, 6076.253352329185, + 6238.408830050208, 6400.615632477617, 6562.873759611411, 6725.18321145159, + 6887.543987998155, 7049.956089251105, 7212.419515210441, 7374.934265876162, + 7537.500341248269, 7700.117741326763, 7862.786466111641, 8025.506515602904 + ], + "expectedLogic": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + }, + { + "name": "calibrated_range_transitions", + "vddMv": 3700, + "modifiers": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [1003.3506, 101.5865, 10.2101, 0.9433, 0.0422], + "gs": [0.0, 0.0, 0.0001, 0.0154, 0.07], + "gi": [1.0, 0.9997, 0.9973, 0.9905, 0.9388], + "o": [112.942, 75.4848, 64.5762, 50.4691, 27.0347], + "s": [3.75e-7, 3.9e-6, 2.87e-5, 0.000278, 0.005151], + "i": [-3.7e-9, -9e-7, 1.19e-5, 0.0001945, 0.0175], + "ug": [1.0, 1.0, 1.0004, 1.0027, 1.0058] + }, + "rawBase64": "uAsAALgLAAC4CwAAuAsAALgLAAC4CwAAVEsAAFRLAABUSwAAVEsAAFRLAADwigAA8IoAAPCKAADwigAA8IoAAIzKAACMygAAjMoAAIzKAACMygAAKAoBACgKAQDEyQAAxMkAAMTJAADEyQAAYAkBAGAJAQBgCQEAYAkBAGAJAQBgCQEAYAkBAGAJAQD8iAAA/IgAAPyIAAD8iAAA/IgAAPyIAACYCAEANIgAADSIAAA0iAAANIgAADSIAAA0iAAA", + "expectedMicroAmps": [ + 131.5428088538406, 131.5428088538406, 131.5428088538406, 131.5428088538406, + 131.5428088538406, 131.5428088538406, 334.57579506250187, 501.0628437536041, + 637.5822236803081, 1259.5038433464033, 1259.5038433464033, 2862.9499083867026, + 4520.683361311804, 5880.024792710388, 12072.580202415042, 12072.580202415042, + 29131.011739692833, 46533.30249139866, 60803.18090779743, 125810.40480472519, + 125810.40480472519, 36067.79772695207, 36067.79772695207, 88295.58180557229, + 93376.32997186817, 97542.54346823078, 116521.96050721603, 53707.45658885421, + 53707.45658885421, 220125.51341007874, 2827341.7369425963, 2827341.7369425963, + 2827341.7369425963, 2827341.7369425963, 2827341.7369425963, 1640559.1662885298, + 1347045.1021293458, 1106363.5695188148, 9925.476515284337, 9925.476515284337, + 9925.476515284337, 633546.9153543299, 505494.4054363133, 416137.40681175713, + 342864.66793962114, 9066.63529988992, 9066.63529988992, 9066.63529988992 + ], + "expectedLogic": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + }, + { + "name": "logic_bits", + "vddMv": 5000, + "modifiers": { + "calibrated": "0", + "hw": "9173", + "ia": "56", + "r": [1003.3506, 101.5865, 10.2101, 0.9433, 0.0422], + "gs": [0.0, 0.0, 0.0001, 0.0154, 0.07], + "gi": [1.0, 0.9997, 0.9973, 0.9905, 0.9388], + "o": [112.942, 75.4848, 64.5762, 50.4691, 27.0347], + "s": [3.75e-7, 3.9e-6, 2.87e-5, 0.000278, 0.005151], + "i": [-3.7e-9, -9e-7, 1.19e-5, 0.0001945, 0.0175], + "ug": [1.0, 1.0, 1.0004, 1.0027, 1.0058] + }, + "rawBase64": "0sQAANLEABHSxAAi0sQAM9LEAETSxABV0sQAZtLEAHfSxACI0sQAmdLEAKrSxAC70sQAzNLEAN3SxADu0sQA/9LEABDSxAAh0sQAMtLEAEPSxABU0sQAZdLEAHbSxACH0sQAmNLEAKnSxAC60sQAy9LEANzSxADt0sQA/tLEAA8=", + "expectedMicroAmps": [ + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038, + 58150.68309233038, 58150.68309233038, 58150.68309233038, 58150.68309233038 + ], + "expectedLogic": [ + 0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255, 16, 33, 50, 67, 84, + 101, 118, 135, 152, 169, 186, 203, 220, 237, 254, 15 + ] + }, + { + "name": "uncalibrated_low_vdd", + "vddMv": 800, + "modifiers": { + "calibrated": null, + "hw": null, + "ia": null, + "r": [1031.64, 101.65, 10.15, 0.94, 0.043], + "gs": [1, 1, 1, 1, 1], + "gi": [1, 1, 1, 1, 1], + "o": [0, 0, 0, 0, 0], + "s": [0, 0, 0, 0, 0], + "i": [0, 0, 0, 0, 0], + "ug": [1, 1, 1, 1, 1] + }, + "rawBase64": "WEIAAGNCAABuQgAAeUIAAIRCAACPQgAAmkIAAKVCAACwQgAAu0IAAMZCAADRQgAA3EIAAOdCAADyQgAA/UIAAAhDAAATQwAAHkMAAClDAAA0QwAAP0MAAEpDAABVQwAA", + "expectedMicroAmps": [ + 259.459192671792, 264.2172006953697, 268.97525394885645, 273.7333524322522, + 278.49149614555705, 283.2496850887709, 288.0079192618937, 292.7661986649256, + 297.52452329786655, 302.2828931607166, 307.04130825347556, 311.7997685761436, + 316.55827412872077, 321.3168249112069, 326.075420923602, 330.8340621659062, + 335.5927486381194, 340.35148034024166, 345.11025727227303, 349.86907943421335, + 354.62794682606267, 359.3868594478211, 364.14581729948856, 368.90482038106495 + ], + "expectedLogic": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ] +} From f022744c8c89510abb1a1e1ebe8c04b99c723b74 Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Wed, 15 Jul 2026 16:32:09 +0100 Subject: [PATCH 3/3] DEV-854: address Copilot review feedback on PPK2 SDK - tests: load the JSON fixture via new URL(..., import.meta.url) instead of __dirname so the ESM ("type": "module") test suite is self-contained and no longer depends on Vitest's __dirname shim; drop the now-unused node:path join import in both codec.test.ts and decoder.test.ts - ppk2Codec.ts: annotate the clonePpk2Modifiers() range clones as Ppk2RangeTable so the 5-tuple type is preserved explicitly rather than relying on contextual inference - ppk2Codec.ts: validate MinMaxDownsampler maxBins (positive integer or Infinity), matching the existing binSize guard, and cover it with a test Co-Authored-By: Claude Opus 4.8 --- src/instruments/ppk2/ppk2Codec.ts | 17 ++++++++++------- tests/instruments/ppk2/codec.test.ts | 4 ++-- tests/instruments/ppk2/decoder.test.ts | 15 +++++++++++++-- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/instruments/ppk2/ppk2Codec.ts b/src/instruments/ppk2/ppk2Codec.ts index a53eb57..0afaf0e 100644 --- a/src/instruments/ppk2/ppk2Codec.ts +++ b/src/instruments/ppk2/ppk2Codec.ts @@ -50,13 +50,13 @@ export function clonePpk2Modifiers(m: Readonly): Ppk2Modifiers { calibrated: m.calibrated, hw: m.hw, ia: m.ia, - r: [...m.r], - gs: [...m.gs], - gi: [...m.gi], - o: [...m.o], - s: [...m.s], - i: [...m.i], - ug: [...m.ug], + r: [...m.r] as Ppk2RangeTable, + gs: [...m.gs] as Ppk2RangeTable, + gi: [...m.gi] as Ppk2RangeTable, + o: [...m.o] as Ppk2RangeTable, + s: [...m.s] as Ppk2RangeTable, + i: [...m.i] as Ppk2RangeTable, + ug: [...m.ug] as Ppk2RangeTable, }; } @@ -331,6 +331,9 @@ export class MinMaxDownsampler { if (!Number.isInteger(binSize) || binSize < 1) { throw new Error('binSize must be a positive integer'); } + if (maxBins !== Number.POSITIVE_INFINITY && (!Number.isInteger(maxBins) || maxBins < 1)) { + throw new Error('maxBins must be a positive integer or Infinity'); + } this.binSize = binSize; this.maxBins = maxBins; } diff --git a/tests/instruments/ppk2/codec.test.ts b/tests/instruments/ppk2/codec.test.ts index a5676b3..90522f7 100644 --- a/tests/instruments/ppk2/codec.test.ts +++ b/tests/instruments/ppk2/codec.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { convertSourceVoltage, clampSourceVoltageMv, @@ -27,8 +26,9 @@ interface Fixture { }; } +// ESM-safe fixture path (no __dirname under "type": "module"). const fixture: Fixture = JSON.parse( - readFileSync(join(__dirname, 'fixtures', 'ppk2-fixtures.json'), 'utf-8'), + readFileSync(new URL('fixtures/ppk2-fixtures.json', import.meta.url), 'utf-8'), ); describe('convertSourceVoltage', () => { diff --git a/tests/instruments/ppk2/decoder.test.ts b/tests/instruments/ppk2/decoder.test.ts index 8b3fb90..5d99a5b 100644 --- a/tests/instruments/ppk2/decoder.test.ts +++ b/tests/instruments/ppk2/decoder.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { Ppk2SampleDecoder, DEFAULT_PPK2_MODIFIERS, @@ -18,8 +17,9 @@ interface DecodeVector { expectedLogic: number[]; } +// ESM-safe fixture path (no __dirname under "type": "module"). const fixture: { decodeVectors: DecodeVector[] } = JSON.parse( - readFileSync(join(__dirname, 'fixtures', 'ppk2-fixtures.json'), 'utf-8'), + readFileSync(new URL('fixtures/ppk2-fixtures.json', import.meta.url), 'utf-8'), ); function rawBytes(v: DecodeVector): Uint8Array { @@ -120,4 +120,15 @@ describe('MinMaxDownsampler', () => { expect(ds.binCount).toBe(3); expect(ds.snapshot().map((b) => b.mean)).toEqual([3, 4, 5]); }); + + it('rejects an invalid binSize or maxBins', () => { + expect(() => new MinMaxDownsampler(0)).toThrow(/binSize/); + expect(() => new MinMaxDownsampler(1, 0)).toThrow(/maxBins/); + expect(() => new MinMaxDownsampler(1, -2)).toThrow(/maxBins/); + expect(() => new MinMaxDownsampler(1, 2.5)).toThrow(/maxBins/); + expect(() => new MinMaxDownsampler(1, Number.NaN)).toThrow(/maxBins/); + // The Infinity default and explicit positive integers are accepted. + expect(() => new MinMaxDownsampler(1)).not.toThrow(); + expect(() => new MinMaxDownsampler(1, 5)).not.toThrow(); + }); });