smithkit 0.4.0 → 0.5.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/README.md CHANGED
@@ -5,7 +5,7 @@ interactive SVG Smith charts, RF calculations, and S11 measurement exploration.
5
5
 
6
6
  [Try the interactive demo](https://rosek86.github.io/smithkit/) or browse the
7
7
  [integration examples](https://rosek86.github.io/smithkit/examples/index.html):
8
- static charts, marker controls, themes, report exports, and lifecycle cleanup.
8
+ static charts, marker controls, a 100,000-sample trace, themes, report exports, and lifecycle cleanup.
9
9
  Each example includes its TypeScript source.
10
10
 
11
11
  Upgrading from 0.3.x? Version 0.4.0 introduces grouped constructor options and
@@ -34,7 +34,7 @@ define the public contract and the compatibility scope intended for 1.0. During
34
34
  - Physical impedance/admittance conversions and DOM-independent RF readings.
35
35
  - Twelve parameter scales mounted independently of chart zoom.
36
36
  - Four peripheral rulers for phase and electrical length.
37
- - One-port Touchstone 1.x import with RI, MA, and DB representations.
37
+ - Touchstone 1.x `.s1p` / `.s2p` reflection import (RI/MA/DB), packed output, and `.s1p` export.
38
38
  - Standalone SVG and configurable PNG export of the chart and radial scales.
39
39
  - ESM modules and TypeScript declarations.
40
40
 
@@ -121,6 +121,69 @@ events work independently.
121
121
  container throws. Call `chart.destroy()` on unmount; it removes the SVG, releases
122
122
  event handlers, and cancels queued notifications. Repeated destruction is safe.
123
123
 
124
+ ## Trace input formats
125
+
126
+ `addTrace()` and `updateTrace()` accept the same `TraceInput` union:
127
+
128
+ - `TraceSamples`: an array of objects with `frequencyHz` and `reflectionCoefficient`.
129
+ - `readonly TraceTuple[]`: `[frequencyHz, re, im]` tuples, including `as const` arrays.
130
+ - `Float64Array`: packed triples in the order `f, re, im, f, re, im, …`.
131
+
132
+ In every format, frequency is in **Hz** and `re`/`im` are the dimensionless
133
+ voltage reflection coefficient **Γ**, relative to the chart's reference impedance.
134
+ A single input must use one format throughout; mixed tuples and objects are rejected.
135
+ Typed-array views are supported; only values inside the supplied view are copied.
136
+ The packed length must be a positive multiple of three. All formats use the same
137
+ finite-value checks and retain input order, duplicate frequencies, and full sample precision.
138
+
139
+ ```ts
140
+ import { Smith, type TraceTuple } from 'smithkit';
141
+
142
+ const chart = new Smith();
143
+ chart.draw('#smith');
144
+ const samples: readonly TraceTuple[] = [
145
+ [1e9, 0.4, -0.4],
146
+ [1.5e9, 0, 0],
147
+ [2e9, 0.4, 0.4],
148
+ ];
149
+ const traceId = chart.addTrace(samples, { name: 'S11', mode: 'line' });
150
+
151
+ // A later update may use a different format. Each triple is one sample.
152
+ chart.updateTrace(traceId, new Float64Array([1e9, 0.3, -0.3, 1.5e9, 0, 0, 2e9, 0.3, 0.3]));
153
+ ```
154
+
155
+ For large datasets, fill the packed array directly to avoid creating intermediate
156
+ objects or tuples:
157
+
158
+ ```ts
159
+ import { Smith } from 'smithkit';
160
+
161
+ const count = 100_000;
162
+ const samples = new Float64Array(count * 3);
163
+ for (let i = 0; i < count; i++) {
164
+ samples[3 * i] = 1e9 + i * 1e4;
165
+ samples[3 * i + 1] = 0.7 * Math.cos((i / count) * 10);
166
+ samples[3 * i + 2] = 0.7 * Math.sin((i / count) * 10);
167
+ }
168
+ const chart = new Smith();
169
+ chart.draw('#smith');
170
+ const traceId = chart.addTrace(samples, { mode: 'points' });
171
+ chart.addMarker(traceId, count - 1);
172
+ ```
173
+
174
+ All inputs are copied into owned packed storage (24 bytes per sample for the numeric
175
+ payload). Modifying the input after adding or updating a trace does not change the
176
+ chart. Marker readings and events keep their existing object shape. `Touchstone.parse()` returns object samples by default, or packed triples with
177
+ `output: 'packed'`. `RfCalculations.renormalizeSamples()` preserves object or packed
178
+ representation.
179
+ Invalid replacements leave data, markers, and rendering unchanged.
180
+
181
+ For more than 5,000 samples, point rendering groups overlapping points into screen
182
+ cells and skips offscreen points; markers still select from the full data. Lines
183
+ retain every sample, so very large SVG paths can remain expensive to render and export.
184
+ Sorted ascending frequencies accelerate frequency selection; unsorted inputs remain
185
+ supported without reordering. There is no automatic interpolation or data reduction.
186
+
124
187
  ## Constructor configuration
125
188
 
126
189
  `new Smith(options?)` accepts a typed `SmithOptions` object. Every field is optional:
@@ -308,21 +371,41 @@ after destruction throw; trace/marker lookups return empty or missing results.
308
371
 
309
372
  ## Trace appearance
310
373
 
311
- Pass `mode: 'points' | 'line' | 'both'`, `lineWidth`, and `pointRadius` to
374
+ Pass `mode: 'points' | 'line' | 'both'`, `lineWidth`, `pointRadius`, and `lineTolerancePx` to
312
375
  `addTrace` or `setTraceOptions`. Defaults are points, 2 px line width, and 2 px point radius. Both sizes are
313
376
  CSS pixels and remain constant under chart zoom and container resize. Widths and radii must be positive and finite.
314
377
  `getTraces()` includes the current style.
315
378
 
316
- Lines connect all samples in input order, without smoothing or frequency sorting.
379
+ By default, lines connect all samples in input order, without smoothing or frequency sorting.
317
380
  A line-only trace creates one SVG path and no sample circles, suitable for dense
318
381
  sweeps. Points/both render every sample for traces up to 5,000 samples. Larger traces
319
382
  render one representative per point-radius-sized display cell and skip points
320
383
  outside the viewport; representatives are recalculated during zoom/pan.
321
384
  This only simplifies the image: all original samples remain available to markers,
322
- snapping, readouts, and comparisons. Lines still connect every input sample.
385
+ snapping, readouts, and comparisons.
323
386
  Styles do not change marker identity or layer ordering. Hidden traces retain their style after sample replacement.
324
387
  The demo exposes the same controls per trace.
325
388
 
389
+ For dense lines, opt into `lineTolerancePx` (finite, non-negative; default `0` disables
390
+ simplification):
391
+
392
+ ```ts
393
+ chart.setTraceOptions(traceId, { mode: 'line', lineTolerancePx: 0.5 });
394
+ ```
395
+
396
+ The tolerance bounds additional geometric deviation in **CSS pixels**, accounting for
397
+ chart zoom and container size. It does not include SVG coordinate rounding or browser
398
+ antialiasing. Cached, nested geometry levels retain endpoints and input order; zooming
399
+ in selects finer geometry, while panning reuses the current level. The cache is rebuilt
400
+ when samples change, including reference-impedance renormalization. Markers, frequency
401
+ selection, RF calculations, and sample counts always use the full data. The option
402
+ applies to the line in `line` and `both` modes; point rendering is unchanged.
403
+
404
+ Preparing levels takes extra CPU and memory, and noisy traces may simplify very little.
405
+ This is an opt-in display optimization, not data compression. See the
406
+ [large-trace example](https://rosek86.github.io/smithkit/examples/large-trace/) to compare
407
+ full geometry with pixel tolerances and [local measurements](docs/performance.md).
408
+
326
409
  ## Events and readings
327
410
 
328
411
  `onEvent(listener)` supports multiple independent subscriptions and returns an
@@ -638,7 +721,30 @@ from S11 alone. At \|Γ\| = 0.1 it reads 10 dB, while return loss reads 20 dB.
638
721
 
639
722
  Peripheral rulers are hidden by default. Enable them with
640
723
  `{ peripheralScales: { visible: true } }` or `chart.peripheralScales.setVisible(true)`.
641
- `chart.peripheralScales` supports `setVisible(boolean)` and `update(Complex | null)`.
724
+ Axis captions and numeric tick labels can be controlled independently, through the
725
+ constructor, `setOptions()`, or the peripheral scale controls:
726
+
727
+ ```ts
728
+ import { Smith } from 'smithkit';
729
+
730
+ const chart = new Smith({
731
+ peripheralScales: { visible: true, captionsVisible: false, tickLabelsVisible: true },
732
+ });
733
+ chart.draw('#smith');
734
+
735
+ chart.setOptions({ peripheralScales: { captionsVisible: true } });
736
+ chart.peripheralScales.setTickLabelsVisible(false); // Captions and rulers remain visible.
737
+ chart.peripheralScales.setCaptionsVisible(false); // Rulers and indicators remain visible.
738
+ ```
739
+
740
+ Both text options default to `true`; the entire group remains hidden by default.
741
+ When captions are hidden, numeric labels fill the sectors normally reserved for them.
742
+ Hiding/re-showing the rulers preserves both preferences. Text controls do not affect
743
+ ruler lines, ticks, indicators, grid labels, or the independent radial parameter scales.
744
+ SVG/PNG exports preserve the current text visibility. The demo exposes **Axis captions**
745
+ and **Tick values** checkboxes in **Chart settings**.
746
+
747
+ `chart.peripheralScales.update(Complex | null)` supplies or clears the indicators.
642
748
  Its four rulers zoom with the chart: reflection phase, voltage transmission phase,
643
749
  wavelengths toward generator, and wavelengths toward load. Wavelength rulers start
644
750
  at the negative real axis, with 0.5 λ per revolution (generator clockwise, load
@@ -677,17 +783,29 @@ Import renormalization is enabled by a labeled checkbox. Disable it to reject
677
783
  files whose reference differs from the chart. Complex reference impedances and
678
784
  multiport renormalization are outside the supported scope.
679
785
 
680
- ## Touchstone import
786
+ ## Touchstone import and export
681
787
 
682
- `Touchstone.parse(text)` returns `{ samples, referenceImpedanceOhms }`. It accepts
683
- one-port Touchstone 1.x, converts RI/MA/DB into Cartesian Γ and all frequencies
684
- into Hz, and throws for unsupported or invalid input.
788
+ `Touchstone.parse(text, options?)` returns `{ samples, referenceImpedanceOhms }`.
789
+ It reads Touchstone 1.x S-parameters, converts RI/MA/DB into Cartesian Γ and
790
+ frequencies into Hz, and throws an `Error` with a line number for invalid content.
791
+ Invalid parser options throw `TypeError`.
792
+
793
+ | Option | Default | Meaning |
794
+ | ----------- | ----------- | -------------------------------------------------------------------------------- |
795
+ | `ports` | `1` | Source port count: `1` for `.s1p`, `2` for `.s2p`. It is not inferred from text. |
796
+ | `parameter` | `'S11'` | Reflection parameter: `'S11'` or, with two ports, `'S22'`. |
797
+ | `output` | `'objects'` | Object samples or `'packed'` `Float64Array` containing f/re/im triples. |
685
798
 
686
799
  ```ts
687
800
  import { Smith, Touchstone } from 'smithkit';
688
801
 
689
802
  async function showMeasurement(file: File): Promise<Smith> {
690
- const { samples, referenceImpedanceOhms } = Touchstone.parse(await file.text());
803
+ const ports = /\.s2p$/i.test(file.name) ? 2 : 1;
804
+ const { samples, referenceImpedanceOhms } = Touchstone.parse(await file.text(), {
805
+ ports,
806
+ parameter: ports === 2 ? 'S22' : 'S11',
807
+ output: 'packed',
808
+ });
691
809
  const chart = new Smith({ referenceImpedanceOhms });
692
810
  chart.draw('#smith');
693
811
  chart.addTrace(samples, { name: file.name });
@@ -695,10 +813,51 @@ async function showMeasurement(file: File): Promise<Smith> {
695
813
  }
696
814
  ```
697
815
 
698
- Omitted options use Touchstone defaults: GHz, S, MA, 50 Ω. For an existing chart,
699
- use `RfCalculations.renormalizeSamples` when the file reference differs from
700
- `chart.referenceImpedanceOhms`. The parser itself does not renormalize data.
701
- Multiport data and Touchstone 2.x are not supported.
816
+ Packed parsing writes directly into growing typed storage, without creating an
817
+ object or coordinate array per sample. It scans lines incrementally within the
818
+ supplied string; this is not a streaming file API. The returned buffer has exact
819
+ length and is caller-owned. The default object result remains unchanged.
820
+
821
+ Omitted header options use GHz, S, MA, 50 Ω. The parser accepts comments, BOM,
822
+ LF/CRLF/CR line endings, decimal scientific notation, and continued records. Each
823
+ frequency record starts on a new line. Two-port records use S11, S21, S12, S22
824
+ order; all four parameters are validated even though only one reflection trace is
825
+ returned. Two-port frequencies must increase strictly. Three or more ports,
826
+ per-port/complex reference impedances, noise blocks and Touchstone 2.x keywords
827
+ are rejected. See the [IBIS Touchstone specification](https://www.ibis.org/touchstone_ver2.1/touchstone_ver2_1.pdf)
828
+ for the legacy 1.x data layout.
829
+
830
+ `Touchstone.stringify(samples, { referenceImpedanceOhms })` returns `.s1p` text
831
+ with a `# Hz S RI R …` header and a final newline. It accepts object samples,
832
+ tuples, or packed triples, writes numeric values without a precision limit, and
833
+ does not modify or renormalize the input. The reference defaults to 50 Ω; supply
834
+ the actual reference of your data. Empty/invalid samples, nonpositive/nonfinite
835
+ reference impedance, and duplicate or descending frequencies throw `RangeError`.
836
+ Samples are never silently sorted or discarded.
837
+
838
+ ```ts
839
+ import { RfCalculations, Touchstone } from 'smithkit';
840
+
841
+ const sourceText = '# GHz S RI R 50\n1 0 0\n2 0.5 0';
842
+ const parsed = Touchstone.parse(sourceText, { output: 'packed' });
843
+ const samples = RfCalculations.renormalizeSamples(
844
+ parsed.samples,
845
+ parsed.referenceImpedanceOhms,
846
+ 75,
847
+ );
848
+ const text = Touchstone.stringify(samples, { referenceImpedanceOhms: 75 });
849
+ // Save text as measurement.s1p in your application.
850
+ ```
851
+
852
+ For an extracted S11/S22 trace, this is **one-port renormalization**: the other
853
+ port remains terminated in the original file reference. It is not renormalization
854
+ of the complete two-port network. Exporting S22 to `.s1p` represents that selected
855
+ port as the single port of the new file; transmission data is not exported.
856
+
857
+ The [Touchstone example](https://rosek86.github.io/smithkit/examples/touchstone/index.html)
858
+ shows parameter selection, packed import, renormalization and download. In the demo,
859
+ choose **Two-port reflection** before importing `.s2p`; the trace name includes the
860
+ selected parameter. Both `.s1p` and `.s2p` use packed import.
702
861
 
703
862
  ## PNG and report export
704
863
 
@@ -725,6 +884,11 @@ const scalePng = await scales.toPng({ width: 1200, background: 'transparent' });
725
884
  - `background`: omit to keep component backgrounds, or pass a concrete CSS color
726
885
  (for example `'#fff'`) or `'transparent'` to replace them. Chart colors stay as
727
886
  configured; choose a suitable chart theme for your report background.
887
+ - `lineDetail` (chart only): `'full'` (default) includes every sample in each trace line;
888
+ `'view'` preserves the currently displayed line geometry, including simplification.
889
+ Both work for SVG and PNG without changing the live chart. Increasing export dimensions
890
+ scales the current-view error too; use `'full'` for reports requiring all samples.
891
+ This option does not change point culling, the viewport, markers, or scales.
728
892
  - `legend` (chart only): append the names and colors of visible traces. Long names
729
893
  wrap; hidden traces are omitted. Default: `false`.
730
894
  - `markerLegend` (chart only): independently append marker descriptions below the chart/scales.
@@ -96,9 +96,13 @@ export class OptionsValidation {
96
96
  }
97
97
  }
98
98
  if (options.peripheralScales !== undefined) {
99
- OptionsValidation.object(options.peripheralScales, 'Peripheral scales', ['visible']);
100
- if (options.peripheralScales.visible !== undefined) {
101
- OptionsValidation.boolean(options.peripheralScales.visible, 'Peripheral scale visibility');
99
+ const keys = ['visible', 'captionsVisible', 'tickLabelsVisible'];
100
+ OptionsValidation.object(options.peripheralScales, 'Peripheral scales', keys);
101
+ for (const key of keys) {
102
+ const value = options.peripheralScales[key];
103
+ if (value !== undefined) {
104
+ OptionsValidation.boolean(value, `Peripheral scales ${key}`);
105
+ }
102
106
  }
103
107
  }
104
108
  if (options.grid !== undefined) {
@@ -1,7 +1,7 @@
1
1
  import type { SmithOptions } from './options.js';
2
2
  import type { SmithImageExportOptions } from './svg/export.js';
3
3
  import type { SmithAppearance } from './appearance/types.js';
4
- import type { TraceSamples } from './samples.js';
4
+ import type { TraceInput, TraceSamples, TraceTuple } from './samples.js';
5
5
  import type { ChartLayers, GridDetail, PeripheralScales } from './layers.js';
6
6
  import type { SmithReading } from './rf/RfCalculations.js';
7
7
  import type { TraceOptions, TraceUpdateOptions, TraceInfo, MarkerSnapshot, MarkerComparison } from './measurements.js';
@@ -62,6 +62,9 @@ export declare class Smith {
62
62
  resetView(): void;
63
63
  /** Add a named trace without markers. Use addMarker to create markers explicitly. Returns a chart-local, stable ID. */
64
64
  addTrace(values: TraceSamples, options?: TraceOptions): string;
65
+ addTrace(values: readonly TraceTuple[], options?: TraceOptions): string;
66
+ addTrace(values: Float64Array, options?: TraceOptions): string;
67
+ addTrace(values: TraceInput, options?: TraceOptions): string;
65
68
  /** Detached metadata snapshots; IDs and marker display numbers survive removals. */
66
69
  getTraces(): TraceInfo[];
67
70
  private traceIndex;
@@ -71,6 +74,9 @@ export declare class Smith {
71
74
  setTraceOptions(id: string, options: TraceOptions): boolean;
72
75
  /** Replace samples; by default each marker follows its nearest measured frequency. */
73
76
  updateTrace(id: string, values: TraceSamples, options?: TraceUpdateOptions): boolean;
77
+ updateTrace(id: string, values: readonly TraceTuple[], options?: TraceUpdateOptions): boolean;
78
+ updateTrace(id: string, values: Float64Array, options?: TraceUpdateOptions): boolean;
79
+ updateTrace(id: string, values: TraceInput, options?: TraceUpdateOptions): boolean;
74
80
  removeTrace(id: string): boolean;
75
81
  /** Place a marker on an existing sample; no interpolation is performed. */
76
82
  addMarker(traceId: string, sampleIndex?: number): string | undefined;
package/dist/lib/Smith.js CHANGED
@@ -80,6 +80,12 @@ export class Smith {
80
80
  if (options.peripheralScales?.visible !== undefined) {
81
81
  this.peripheralScales.setVisible(options.peripheralScales.visible);
82
82
  }
83
+ if (options.peripheralScales?.captionsVisible !== undefined) {
84
+ this.peripheralScales.setCaptionsVisible(options.peripheralScales.captionsVisible);
85
+ }
86
+ if (options.peripheralScales?.tickLabelsVisible !== undefined) {
87
+ this.peripheralScales.setTickLabelsVisible(options.peripheralScales.tickLabelsVisible);
88
+ }
83
89
  for (const name of ['resistance', 'reactance', 'conductance', 'susceptance']) {
84
90
  const layer = this.layers[name];
85
91
  for (const settings of [options.grid, options.grid?.layers?.[name]]) {
@@ -146,7 +152,7 @@ export class Smith {
146
152
  toSvg(options) {
147
153
  this.assertAlive();
148
154
  this.validateExportOptions(options);
149
- const source = this.renderer.toSvg();
155
+ const source = this.renderer.toSvg(options?.lineDetail ?? 'full');
150
156
  if (options === undefined) {
151
157
  return source;
152
158
  }
@@ -157,6 +163,9 @@ export class Smith {
157
163
  return ImageExporter.svg(sources, options, this.exportLegend(options));
158
164
  }
159
165
  validateExportOptions(options) {
166
+ if (options?.lineDetail !== undefined && !['full', 'view'].includes(options.lineDetail)) {
167
+ throw new TypeError('Export line detail must be full or view.');
168
+ }
160
169
  if (options?.scaleReadout !== undefined && !options.scales) {
161
170
  throw new TypeError('Scale readout requires included radial scales.');
162
171
  }
@@ -178,7 +187,7 @@ export class Smith {
178
187
  async toPng(options = {}) {
179
188
  this.assertAlive();
180
189
  this.validateExportOptions(options);
181
- const sources = [this.toSvg()];
190
+ const sources = [this.renderer.toSvg(options.lineDetail ?? 'full')];
182
191
  if (options.scales) {
183
192
  sources.push(options.scales.toSvg({ readout: options.scaleReadout }));
184
193
  }
@@ -231,7 +240,6 @@ export class Smith {
231
240
  this.assertAlive();
232
241
  this.renderer.resetView();
233
242
  }
234
- /** Add a named trace without markers. Use addMarker to create markers explicitly. Returns a chart-local, stable ID. */
235
243
  addTrace(values, options = {}) {
236
244
  this.assertAlive();
237
245
  this.validateTraceOptions(options);
@@ -285,6 +293,10 @@ export class Smith {
285
293
  if (options.mode !== undefined && !['points', 'line', 'both'].includes(options.mode)) {
286
294
  throw new TypeError('Trace mode must be points, line, or both.');
287
295
  }
296
+ if (options.lineTolerancePx !== undefined &&
297
+ (!Number.isFinite(options.lineTolerancePx) || options.lineTolerancePx < 0)) {
298
+ throw new RangeError('Line tolerance must be finite and non-negative.');
299
+ }
288
300
  for (const key of ['lineWidth', 'pointRadius']) {
289
301
  const value = options[key];
290
302
  if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
@@ -323,7 +335,6 @@ export class Smith {
323
335
  }
324
336
  return true;
325
337
  }
326
- /** Replace samples; by default each marker follows its nearest measured frequency. */
327
338
  updateTrace(id, values, options = {}) {
328
339
  this.assertAlive();
329
340
  const data = this.data[this.traceIndex(id)];
@@ -419,7 +430,7 @@ export class Smith {
419
430
  if (referenceImpedanceOhms === this.referenceOhms) {
420
431
  return;
421
432
  }
422
- const samples = this.data.map((data) => RfCalculations.renormalizeSamples(data.Samples, this.referenceOhms, referenceImpedanceOhms));
433
+ const samples = this.data.map((data) => data.Samples.renormalize(this.referenceOhms, referenceImpedanceOhms));
423
434
  this.data.forEach((data) => data.Markers.forEach((entry) => entry.marker.cancelDrag()));
424
435
  this.referenceOhms = referenceImpedanceOhms;
425
436
  this.data.forEach((data, index) => data.update(samples[index], 'sample-index'));
@@ -1,9 +1,9 @@
1
1
  export { Smith, SmithEventType } from './Smith.js';
2
2
  export type { SmithEvent } from './Smith.js';
3
3
  export { Complex } from './math/Complex.js';
4
- export type { TraceSamples, TraceSample } from './samples.js';
4
+ export type { TraceSamples, TraceSample, TraceTuple, TraceInput } from './samples.js';
5
5
  export { Touchstone } from './io/Touchstone.js';
6
- export type { TouchstoneData } from './io/Touchstone.js';
6
+ export type { TouchstoneData, TouchstoneParseOptions, TouchstoneWriteOptions } from './io/types.js';
7
7
  export { SmithScales } from './scales/SmithScales.js';
8
8
  export { MarkerMeasurements } from './MarkerMeasurements.js';
9
9
  export type { TraceOptions, TraceUpdateOptions, MarkerSelectionStrategy, TraceStyle, TraceRenderMode, TraceInfo, MarkerInfo, MarkerSnapshot, MarkerComparison, } from './measurements.js';
@@ -1,10 +1,16 @@
1
- import type { TraceSamples } from '../samples.js';
2
- export interface TouchstoneData {
3
- samples: TraceSamples;
4
- referenceImpedanceOhms: number;
5
- }
1
+ import type { TraceInput, TraceSamples } from '../samples.js';
2
+ import type { TouchstoneData, TouchstoneParseOptions, TouchstoneWriteOptions } from './types.js';
3
+ /** DOM-independent Touchstone 1.x reflection-trace import and one-port export. */
6
4
  export declare class Touchstone {
7
5
  private constructor();
8
- /** Read one-port Touchstone 1.x data; frequencies in the result are always Hz. */
9
- static parse(text: string): TouchstoneData;
6
+ static parse(text: string, options: TouchstoneParseOptions & {
7
+ output: 'packed';
8
+ }): TouchstoneData<Float64Array>;
9
+ static parse(text: string, options?: TouchstoneParseOptions & {
10
+ output?: 'objects';
11
+ }): TouchstoneData;
12
+ static parse(text: string, options: TouchstoneParseOptions): TouchstoneData<TraceSamples | Float64Array>;
13
+ /** Serialize a reflection trace as one-port Touchstone, in Hz/RI without rounding or renormalization. */
14
+ static stringify(samples: TraceInput, options?: TouchstoneWriteOptions): string;
15
+ private static isNumber;
10
16
  }
@@ -1,75 +1,175 @@
1
+ import { TraceBuffer } from '../traces/TraceBuffer.js';
2
+ /** DOM-independent Touchstone 1.x reflection-trace import and one-port export. */
1
3
  export class Touchstone {
2
4
  constructor() { }
3
- /** Read one-port Touchstone 1.x data; frequencies in the result are always Hz. */
4
- static parse(text) {
5
+ /** Frequencies are returned in Hz. Two-port input must be explicitly selected. */
6
+ static parse(text, options = {}) {
7
+ const { ports = 1, parameter = 'S11', output = 'objects' } = options;
8
+ if (![1, 2].includes(ports) ||
9
+ !['S11', 'S22'].includes(parameter) ||
10
+ !['objects', 'packed'].includes(output) ||
11
+ (ports === 1 && parameter !== 'S11')) {
12
+ throw new TypeError('Expected ports 1 or 2, S11 (or S22 for two ports), and objects or packed output.');
13
+ }
5
14
  const units = { HZ: 1, KHZ: 1e3, MHZ: 1e6, GHZ: 1e9 };
6
15
  let frequencyScale = 1e9;
7
16
  let format = 'MA';
8
17
  let referenceImpedanceOhms = 50;
9
18
  let optionsSeen = false;
19
+ let count = 0;
20
+ let lineNumber = 0;
21
+ let recordLine = 0;
22
+ let fields = 0;
23
+ let previousFrequency = -1;
24
+ const width = ports === 1 ? 3 : 9;
25
+ const record = new Float64Array(width);
10
26
  const samples = [];
11
- for (const [index, raw] of text
12
- .replace(/^\uFEFF/, '')
13
- .split(/\r?\n/)
14
- .entries()) {
15
- const line = raw.split('!')[0].trim();
27
+ let packed = new Float64Array(output === 'packed' ? 3072 : 0);
28
+ const fail = (message) => {
29
+ throw new Error(`Line ${lineNumber}: ${message}`);
30
+ };
31
+ // Scan lines lazily: do not retain a line array or allocate sample objects for packed output.
32
+ for (const match of text.matchAll(/[^\r\n]*(?:\r\n|\r|\n|$)/g)) {
33
+ lineNumber++;
34
+ const line = match[0].split('!', 1)[0].trim();
16
35
  if (!line) {
17
36
  continue;
18
37
  }
19
- const fail = (message) => {
20
- throw new Error(`Line ${index + 1}: ${message}`);
21
- };
22
38
  if (line.startsWith('[')) {
23
- fail('Only Touchstone 1.x one-port files (.s1p) are supported.');
39
+ fail('Touchstone 2.x keywords are not supported; use a 1.x .s1p or .s2p file.');
24
40
  }
25
41
  if (line.startsWith('#')) {
26
- if (optionsSeen || samples.length) {
42
+ if (optionsSeen || count || fields) {
27
43
  fail('The option line must appear once, before the data.');
28
44
  }
29
45
  optionsSeen = true;
30
- const options = line.slice(1).trim().toUpperCase().split(/\s+/).filter(Boolean);
31
- if (!options.length) {
32
- continue;
46
+ const tokens = line.slice(1).trim().toUpperCase().split(/\s+/).filter(Boolean);
47
+ const seen = new Set();
48
+ for (let i = 0; i < tokens.length; i++) {
49
+ const token = tokens[i];
50
+ const kind = Object.hasOwn(units, token)
51
+ ? 'unit'
52
+ : ['RI', 'MA', 'DB'].includes(token)
53
+ ? 'format'
54
+ : token;
55
+ if (!['unit', 'format', 'S', 'R'].includes(kind) || seen.has(kind)) {
56
+ fail('Expected Hz|kHz|MHz|GHz, S, RI|MA|DB and one R impedance.');
57
+ }
58
+ seen.add(kind);
59
+ if (kind === 'unit') {
60
+ frequencyScale = units[token];
61
+ }
62
+ else if (kind === 'format') {
63
+ format = token;
64
+ }
65
+ else if (kind === 'R') {
66
+ const value = tokens[++i];
67
+ if (!value || !Touchstone.isNumber(value)) {
68
+ fail('Reference impedance must be a positive finite number.');
69
+ }
70
+ referenceImpedanceOhms = Number(value);
71
+ if (!Number.isFinite(referenceImpedanceOhms) || referenceImpedanceOhms <= 0) {
72
+ fail('Reference impedance must be a positive finite number.');
73
+ }
74
+ }
33
75
  }
34
- const [unit = 'GHZ', parameter = 'S', representation = 'MA', r = 'R', impedance = '50'] = options;
35
- if (!(unit in units) ||
36
- parameter !== 'S' ||
37
- !['RI', 'MA', 'DB'].includes(representation) ||
38
- r !== 'R' ||
39
- options.length > 5) {
40
- fail('Expected: # Hz|kHz|MHz|GHz S RI|MA|DB R impedance.');
76
+ continue;
77
+ }
78
+ for (const token of line.matchAll(/\S+/g)) {
79
+ if (fields === width) {
80
+ fail('Each frequency record must begin on a new line.');
41
81
  }
42
- frequencyScale = units[unit];
43
- format = representation;
44
- referenceImpedanceOhms = Number(impedance);
45
- if (!Number.isFinite(referenceImpedanceOhms) || referenceImpedanceOhms <= 0) {
46
- fail('Reference impedance must be positive.');
82
+ if (!Touchstone.isNumber(token[0])) {
83
+ fail('Expected finite decimal numbers.');
84
+ }
85
+ const value = Number(token[0]);
86
+ if (!Number.isFinite(value)) {
87
+ fail('Expected finite decimal numbers.');
88
+ }
89
+ if (fields === 0) {
90
+ recordLine = lineNumber;
91
+ const frequencyHz = value * frequencyScale;
92
+ if (frequencyHz < 0 || !Number.isFinite(frequencyHz)) {
93
+ fail('Frequency must be finite and non-negative.');
94
+ }
95
+ // Legacy noise blocks restart the frequency sweep. Never interpret them as S data.
96
+ if (ports === 2 && frequencyHz <= previousFrequency) {
97
+ fail('Two-port frequencies must increase strictly; noise data is not supported.');
98
+ }
99
+ record[fields++] = frequencyHz;
100
+ }
101
+ else {
102
+ record[fields++] = value;
47
103
  }
48
- continue;
49
104
  }
50
- const fields = line.split(/\s+/).map(Number);
51
- if (fields.length !== 3 || !fields.every(Number.isFinite)) {
52
- fail('Expected frequency and two finite S11 components.');
105
+ if (fields !== width) {
106
+ continue;
53
107
  }
54
- const [frequency, a, b] = fields;
55
- const frequencyHz = frequency * frequencyScale;
56
- if (frequencyHz < 0 || !Number.isFinite(frequencyHz)) {
57
- fail('Frequency must be finite and non-negative.');
108
+ let real = 0;
109
+ let imaginary = 0;
110
+ for (let offset = 1; offset < width; offset += 2) {
111
+ const a = record[offset];
112
+ const b = record[offset + 1];
113
+ if (format === 'MA' && a < 0) {
114
+ fail('Magnitude must be non-negative.');
115
+ }
116
+ const magnitude = format === 'DB' ? 10 ** (a / 20) : a;
117
+ const radians = ((b % 360) * Math.PI) / 180;
118
+ const re = format === 'RI' ? a : magnitude * Math.cos(radians);
119
+ const im = format === 'RI' ? b : magnitude * Math.sin(radians);
120
+ if (!Number.isFinite(re) || !Number.isFinite(im)) {
121
+ fail('An S-parameter is outside the supported numeric range.');
122
+ }
123
+ if (offset === (parameter === 'S11' ? 1 : 7)) {
124
+ real = re;
125
+ imaginary = im;
126
+ }
58
127
  }
59
- if (format === 'MA' && a < 0) {
60
- fail('Magnitude must be non-negative.');
128
+ if (output === 'packed') {
129
+ if (count * 3 === packed.length) {
130
+ const grown = new Float64Array(packed.length * 2);
131
+ grown.set(packed);
132
+ packed = grown;
133
+ }
134
+ packed[count * 3] = record[0];
135
+ packed[count * 3 + 1] = real;
136
+ packed[count * 3 + 2] = imaginary;
61
137
  }
62
- const magnitude = format === 'DB' ? 10 ** (a / 20) : a;
63
- const radians = (b * Math.PI) / 180;
64
- const reflectionCoefficient = format === 'RI' ? [a, b] : [magnitude * Math.cos(radians), magnitude * Math.sin(radians)];
65
- if (!reflectionCoefficient.every(Number.isFinite)) {
66
- fail('S11 is outside the supported numeric range.');
138
+ else {
139
+ samples.push({ frequencyHz: record[0], reflectionCoefficient: [real, imaginary] });
67
140
  }
68
- samples.push({ frequencyHz, reflectionCoefficient });
141
+ previousFrequency = record[0];
142
+ count++;
143
+ fields = 0;
69
144
  }
70
- if (!samples.length) {
71
- throw new Error('The file contains no S11 samples.');
145
+ if (fields) {
146
+ throw new Error(`Line ${recordLine}: Incomplete ${ports}-port record; expected ${width} values. Noise data is not supported.`);
72
147
  }
73
- return { samples, referenceImpedanceOhms };
148
+ if (!count) {
149
+ throw new Error(`The file contains no ${parameter} samples.`);
150
+ }
151
+ return {
152
+ samples: output === 'packed' ? packed.slice(0, count * 3) : samples,
153
+ referenceImpedanceOhms,
154
+ };
155
+ }
156
+ /** Serialize a reflection trace as one-port Touchstone, in Hz/RI without rounding or renormalization. */
157
+ static stringify(samples, options = {}) {
158
+ const { referenceImpedanceOhms = 50 } = options;
159
+ if (!Number.isFinite(referenceImpedanceOhms) || referenceImpedanceOhms <= 0) {
160
+ throw new RangeError('Reference impedance must be positive and finite.');
161
+ }
162
+ const data = TraceBuffer.from(samples);
163
+ const lines = [`# Hz S RI R ${referenceImpedanceOhms}`];
164
+ for (let i = 0; i < data.length; i++) {
165
+ if (i > 0 && data.frequency(i) <= data.frequency(i - 1)) {
166
+ throw new RangeError('Touchstone export requires strictly increasing frequencies.');
167
+ }
168
+ lines.push(`${data.frequency(i)} ${data.real(i)} ${data.imaginary(i)}`);
169
+ }
170
+ return lines.join('\n') + '\n';
171
+ }
172
+ static isNumber(token) {
173
+ return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(token);
74
174
  }
75
175
  }