Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## 0.6.0 - 2026-09-12

### Added

* **Exact solved currents from the array facade:** `NecArraySolver` now exposes
`getCurrentDistribution({ kind: "latest-solution" })`. Explicit and symmetric
representations return identical exact ampere-valued `A/B/C` coefficients,
physical geometry, caller-order tags, and decoded segment connections.
Symmetric results retain the true native segment indices while hiding
generated tags and copy-major ordering.

### Compatibility

* This is an additive TypeScript facade release. The NEC2++ engine remains
`2.5.0`, WASM ABI remains `1`, and no native symbols or binary result formats
change. Unit-current distributions remain on `NecModel` and
`NecWorkerModel`; the array facade exposes only its latest consumer solution.

## 0.5.0 - 2026-09-02

### Added
Expand Down
35 changes: 28 additions & 7 deletions docs/wasm-api.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# `@necpp-engine/wasm` API and numerical contract

Status: normative specification, updated through the parallel far-field release
on 2026-09-01. The
Status: normative specification, updated through the array-current release on
2026-09-12. The
stateful native layer, versioned C/WASM ABI, handwritten TypeScript facade,
optional Web Worker entry point, and packable npm package are implemented.
The committed TypeScript surface is in [`packages/necpp-wasm/src`](../packages/necpp-wasm/src).
Expand All @@ -27,9 +27,9 @@ while the scoped name identifies this repository and leaves room for future
npm scope, but the API name will not change if the package is initially
distributed as a tarball.
The package is ESM-only and requires Node 24 or later for Node consumers.
The isolated-element current-quadrature release package identity is `0.5.0`;
it embeds NEC2++ `2.5.0` while preserving WASM ABI version `1`. The prior
parallel far-field release was `0.4.0`.
The array-current release package identity is `0.6.0`; it embeds NEC2++ `2.5.0`
while preserving WASM ABI version `1`. The preceding package release was
`0.5.1`; `0.5.0` introduced the isolated-element current-quadrature API.

The packed package exports three version identifiers that can be imported
without constructing a model:
Expand Down Expand Up @@ -442,6 +442,9 @@ interface NecArraySolver {
computeImpedanceMatrix(): Promise<ImpedanceResult>;
solveVoltages(value: ComplexVector): Promise<PortSolution>;
solveCurrents(value: ComplexVector): Promise<PortSolution>;
getCurrentDistribution(
options: { readonly kind: "latest-solution" },
): Promise<NecCurrentDistribution>;
computeFarField(request: FarFieldRequest): Promise<FarFieldResult>;
computeEmbeddedFarFields(
request: FarFieldRequest,
Expand All @@ -462,14 +465,32 @@ field request as superseded. All input arrays are borrowed during their
operation and all returned arrays are caller-owned, exactly as for the low-level
direct and worker models.

`getCurrentDistribution({ kind: "latest-solution" })` requires the `solved`
state and returns exact ampere-valued `A/B/C` coefficients without a second
solve. Its segment order is `description.elements`, then the selected
`pattern.wires`, then one-based segment position. Segment tags and decoded
endpoint references use the caller-facing tags allocated in that order;
`nativeIndex` deliberately remains the true NEC segment index. Geometry is in
the planner-canonicalized absolute coordinate frame, including the center
removed while constructing a symmetric model; inspect
`getDiagnostics().planner.canonicalizations` for any epsilon-bounded position
adjustments. Currents receive no position-dependent phase
rotation: the solved complex excitation is already present in the native
coefficients. Each result buffer is caller-owned and remains valid after later
solver operations. The array facade does not expose `"unit-current"`; use
`NecModel` or `NecWorkerModel` for isolated-element unit-current bases.

### Representation-independent order and transforms

Elements and the ports contributed by each pattern retain the order of
`description.elements`, then `pattern.ports`. The facade scatters caller
excitations into native copy-major order and gathers both dimensions of Z/Y,
all achieved/requested port vectors and powers, and the outer embedded-field
basis dimension back into caller order. Ordinary results intentionally contain
no fundamental count, generated tag, copy index, or symmetry variant.
basis dimension back into caller order. Current distributions gather every
segment and all six coefficient planes into element/wire/segment order, remap
generated tags and segment endpoint references, and restore the symmetric
center translation without far-field-style phasor rephasing. Ordinary results
intentionally contain no fundamental count, generated tag, copy index, or symmetry variant.
The aggregate `powerBudget` has no port order and passes through unchanged.

An accepted reflection candidate canonicalizes centered positions with sign
Expand Down
28 changes: 27 additions & 1 deletion packages/necpp-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,32 @@ model or `"require"` to reject a description that cannot use supported
symmetry. All three modes use a package-supplied worker and expose the same
asynchronous solver methods.

After `solveCurrents()` or `solveVoltages()`, the same facade exposes the exact
latest NEC segment currents:

```ts
import type { NecArraySolver } from "@necpp-engine/wasm";

declare const solver: NecArraySolver;

const currents = await solver.getCurrentDistribution({
kind: "latest-solution",
});

console.log(currents.aReal[0], currents.aImag[0]);
```

Segments are returned in caller element, pattern wire, and segment order.
Tags and decoded endpoint references are caller-facing, while each
`nativeIndex` retains the true NEC index for diagnostics. Symmetric geometry is
translated into the planner-canonicalized absolute coordinate frame; inspect
`getDiagnostics().planner.canonicalizations` for any epsilon-bounded position
adjustments. Current coefficients are not phase-rotated because the native
solution already includes every complex excitation. Returned numeric buffers
are caller-owned. The array facade deliberately accepts only
`"latest-solution"`; isolated `"unit-current"` bases remain available through
`NecModel` and `NecWorkerModel`.

### Parallel far fields

`createNecArraySolver()` also owns an optional pool of lightweight far-field
Expand Down Expand Up @@ -792,7 +818,7 @@ appropriate CORS header.
import { createNecModel } from "@necpp-engine/wasm";

const model = await createNecModel({
wasmUrl: new URL("https://cdn.example.test/necpp/0.5.1/nec2pp.wasm"),
wasmUrl: new URL("https://cdn.example.test/necpp/0.6.0/nec2pp.wasm"),
});
model.dispose();
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"fixtureSchema": "current-quadrature-v1",
"abiVersion": 1,
"engineVersion": "2.5.0",
"packageVersion": "0.5.1",
"packageVersion": "0.6.0",
"cases": [
{
"id": "dipole",
Expand Down
4 changes: 2 additions & 2 deletions packages/necpp-wasm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/necpp-wasm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@necpp-engine/wasm",
"version": "0.5.1",
"version": "0.6.0",
"private": false,
"type": "module",
"description": "Stateful NEC2++ electromagnetic solver and symmetric-array API for Node and browsers",
Expand Down
156 changes: 155 additions & 1 deletion packages/necpp-wasm/src/array-solver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import {
analyzeArraySymmetry,
createExplicitArrayBuildPlan,
} from "./array-symmetry.js";
import { NecError, NecGeometryError, NecInputError } from "./errors.js";
import {
NecError,
NecGeometryError,
NecInputError,
NecRuntimeError,
} from "./errors.js";
import { createNecArrayWorkerModel } from "./worker-client.js";
import type {
ArrayBuildPlan,
Expand All @@ -22,6 +27,7 @@ import type {
ImpedanceResult,
LoadDefinition,
NecArraySolver,
NecCurrentDistribution,
NecModel,
NecModelState,
NecWorkerModel,
Expand Down Expand Up @@ -336,6 +342,141 @@ export function gatherEmbeddedBasis(
return gathered;
}

function segmentKey(tag: number, segment: number): string {
return `${tag}:${segment}`;
}

function remapSegmentEnd(
value: NecCurrentDistribution["startEnds"][number],
nativeTagToCallerTag: ReadonlyMap<number, number>,
): NecCurrentDistribution["startEnds"][number] {
if (value.kind !== "segment") {
return Object.freeze({ kind: value.kind });
}
const tag = nativeTagToCallerTag.get(value.tag);
if (tag === undefined) {
throw new NecRuntimeError(
`Current distribution endpoint references unmapped native tag ${value.tag}`,
);
}
return Object.freeze({
kind: "segment",
tag,
segment: value.segment,
end: value.end,
});
}

function gatherSymmetricCurrentDistribution(
result: NecCurrentDistribution,
description: FullArrayDescription,
plan: Extract<ArrayBuildPlan, { readonly kind: "symmetric" }>,
): NecCurrentDistribution {
const caller = allocateCallerModel(description);
const nativeSegmentByKey = new Map<string, number>();
for (let index = 0; index < result.segments.length; index += 1) {
const identity = result.segments[index]!;
nativeSegmentByKey.set(segmentKey(identity.tag, identity.segment), index);
}

const nativeTagToCallerTag = new Map<number, number>();
for (let callerElementIndex = 0;
callerElementIndex < description.elements.length;
callerElementIndex += 1) {
const mapping = plan.mappings[callerElementIndex]!;
const allocation = caller.allocations[callerElementIndex]!;
for (let wireIndex = 0; wireIndex < allocation.pattern.wires.length; wireIndex += 1) {
const wire = allocation.pattern.wires[wireIndex]!;
const nativeTag = mapping.generatedTag + wireIndex;
const callerTag = allocation.wireTags.get(wire.id)!;
nativeTagToCallerTag.set(nativeTag, callerTag);
}
}

const sourceIndices: number[] = [];
const segments: NecCurrentDistribution["segments"][number][] = [];
for (let callerElementIndex = 0;
callerElementIndex < description.elements.length;
callerElementIndex += 1) {
const mapping = plan.mappings[callerElementIndex]!;
const allocation = caller.allocations[callerElementIndex]!;
for (let wireIndex = 0; wireIndex < allocation.pattern.wires.length; wireIndex += 1) {
const wire = allocation.pattern.wires[wireIndex]!;
const nativeTag = mapping.generatedTag + wireIndex;
const callerTag = allocation.wireTags.get(wire.id)!;
for (let segment = 1; segment <= wire.segments; segment += 1) {
const source = nativeSegmentByKey.get(segmentKey(nativeTag, segment));
if (source === undefined) {
throw new NecRuntimeError(
`Current distribution is missing native tag ${nativeTag} segment ${segment}`,
);
}
sourceIndices.push(source);
segments.push(Object.freeze({
tag: callerTag,
segment,
nativeIndex: result.segments[source]!.nativeIndex,
}));
}
}
}
if (sourceIndices.length !== result.segments.length) {
throw new NecRuntimeError(
"Current distribution segment count does not match the symmetric array plan",
);
}

const gatherScalar = (source: Float64Array): Float64Array =>
Float64Array.from(sourceIndices, (index) => source[index]!);
const gatherTriples = (source: Float64Array, translate: boolean): Float64Array => {
const gathered = new Float64Array(3 * sourceIndices.length);
for (let target = 0; target < sourceIndices.length; target += 1) {
const sourceOffset = 3 * sourceIndices[target]!;
const targetOffset = 3 * target;
gathered[targetOffset] = source[sourceOffset]! + (translate ? plan.centerM[0] : 0);
gathered[targetOffset + 1] = source[sourceOffset + 1]!
+ (translate ? plan.centerM[1] : 0);
gathered[targetOffset + 2] = source[sourceOffset + 2]!;
}
return gathered;
};
const gatherPlanes = (source: Float64Array): Float64Array => {
const gathered = new Float64Array(result.modeCount * sourceIndices.length);
for (let mode = 0; mode < result.modeCount; mode += 1) {
for (let target = 0; target < sourceIndices.length; target += 1) {
gathered[mode * sourceIndices.length + target] =
source[mode * result.segments.length + sourceIndices[target]!]!;
}
}
return gathered;
};

return {
schemaVersion: 1,
frequencyMHz: result.frequencyMHz,
wavelengthM: result.wavelengthM,
modeKind: result.modeKind,
modeCount: result.modeCount,
segments: Object.freeze(segments),
startEnds: Object.freeze(sourceIndices.map((source) =>
remapSegmentEnd(result.startEnds[source]!, nativeTagToCallerTag))),
endEnds: Object.freeze(sourceIndices.map((source) =>
remapSegmentEnd(result.endEnds[source]!, nativeTagToCallerTag))),
centresM: gatherTriples(result.centresM, true),
startsM: gatherTriples(result.startsM, true),
endsM: gatherTriples(result.endsM, true),
tangents: gatherTriples(result.tangents, false),
radiiM: gatherScalar(result.radiiM),
lengthsM: gatherScalar(result.lengthsM),
aReal: gatherPlanes(result.aReal),
aImag: gatherPlanes(result.aImag),
bReal: gatherPlanes(result.bReal),
bImag: gatherPlanes(result.bImag),
cReal: gatherPlanes(result.cReal),
cImag: gatherPlanes(result.cImag),
};
}

function rephaseArrays(
result: FarFieldResult,
centerM: readonly [number, number],
Expand Down Expand Up @@ -545,6 +686,19 @@ class WorkerNecArraySolver implements NecArraySolver {
return this.#solve("current", currents);
}

async getCurrentDistribution(
options: { readonly kind: "latest-solution" },
): Promise<NecCurrentDistribution> {
if (typeof options !== "object" || options === null
|| options.kind !== "latest-solution") {
throw new NecInputError("options.kind must be latest-solution");
}
const result = await this.#model.getCurrentDistribution(options);
return this.#plan.kind === "explicit"
? result
: gatherSymmetricCurrentDistribution(result, this.#description, this.#plan);
}

async computeFarField(request: FarFieldRequest): Promise<FarFieldResult> {
const result = await this.#model.computeFarField(request);
if (result.fieldBackend !== undefined) {
Expand Down
15 changes: 15 additions & 0 deletions packages/necpp-wasm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,21 @@ export interface NecArraySolver {
computeImpedanceMatrix(): Promise<ImpedanceResult>;
solveVoltages(voltages: ComplexVector): Promise<PortSolution>;
solveCurrents(currents: ComplexVector): Promise<PortSolution>;
/**
* Return exact ampere-valued coefficients for the most recent consumer
* solution. Segments are ordered by caller description element, then that
* element's pattern wire order, then one-based segment position. Tags and
* decoded endpoint references are the caller-facing tags allocated in that
* same order; `nativeIndex` remains the true zero-based NEC segment index.
* Geometry is in the planner-canonicalized absolute coordinate frame; any
* epsilon-bounded position adjustments are reported by
* `getDiagnostics().planner.canonicalizations`. No position phase rotation
* is applied. Every numeric buffer is an owned copy and remains valid after
* later solver calls.
*/
getCurrentDistribution(
options: { readonly kind: "latest-solution" },
): Promise<NecCurrentDistribution>;
computeFarField(request: FarFieldRequest): Promise<FarFieldResult>;
computeEmbeddedFarFields(
request: FarFieldRequest,
Expand Down
2 changes: 1 addition & 1 deletion packages/necpp-wasm/src/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
* CMake `project(necpp VERSION ...)` value compiled into the shipped WASM.
* `abiVersion` is the stable C ABI prefix `necpp_wasm_v1`.
*/
export const packageVersion = "0.5.1";
export const packageVersion = "0.6.0";
export const abiVersion = 1;
export const engineVersion = "2.5.0";
6 changes: 6 additions & 0 deletions packages/necpp-wasm/test-d/public-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ async function validUnbranchedArrayConsumer(
real: new Float64Array(2),
imag: new Float64Array(2),
});
const arrayCurrents: NecCurrentDistribution = await solver.getCurrentDistribution({
kind: "latest-solution",
});
arrayCurrents.aImag[0];
// @ts-expect-error Array solvers expose only the latest consumer solution.
await solver.getCurrentDistribution({ kind: "unit-current" });
await solver.computeFarField({
theta: { startDeg: 0, count: 1, stepDeg: 0 },
phi: { startDeg: 0, count: 1, stepDeg: 0 },
Expand Down
Loading
Loading