From a1a3f625c20b0cf35041ee2ae14c7c753753f59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 13 Sep 2026 16:47:08 +0200 Subject: [PATCH] feat(wasm): expose solved array current distribution --- CHANGELOG.md | 18 ++ docs/wasm-api.md | 35 ++- packages/necpp-wasm/README.md | 28 ++- .../current-quadrature-v1/manifest.json | 2 +- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 2 +- packages/necpp-wasm/src/array-solver.ts | 156 +++++++++++++- packages/necpp-wasm/src/types.ts | 15 ++ packages/necpp-wasm/src/versions.ts | 2 +- packages/necpp-wasm/test-d/public-api.test.ts | 6 + .../test/array-solver.integration.test.mjs | 203 ++++++++++++++++++ .../necpp-wasm/test/facade-runtime.test.mjs | 2 +- .../necpp-wasm/test/pack/manifest.test.mjs | 2 +- 13 files changed, 459 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d1b6b1..db4c082a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/wasm-api.md b/docs/wasm-api.md index a32ac6b3..fcf048b9 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -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). @@ -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: @@ -442,6 +442,9 @@ interface NecArraySolver { computeImpedanceMatrix(): Promise; solveVoltages(value: ComplexVector): Promise; solveCurrents(value: ComplexVector): Promise; + getCurrentDistribution( + options: { readonly kind: "latest-solution" }, + ): Promise; computeFarField(request: FarFieldRequest): Promise; computeEmbeddedFarFields( request: FarFieldRequest, @@ -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 diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 0cfd3378..a0e933e6 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -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 @@ -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(); ``` diff --git a/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json b/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json index 676e2fd2..a0ec359e 100644 --- a/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json +++ b/packages/necpp-wasm/fixtures/current-quadrature-v1/manifest.json @@ -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", diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 6cfcd6cc..0a77c1c3 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@necpp-engine/wasm", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.5.1", + "version": "0.6.0", "license": "GPL-2.0-or-later", "devDependencies": { "@types/node": "^24.13.3", diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index 14cd65df..53f66476 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -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", diff --git a/packages/necpp-wasm/src/array-solver.ts b/packages/necpp-wasm/src/array-solver.ts index 5ec84625..8f05759a 100644 --- a/packages/necpp-wasm/src/array-solver.ts +++ b/packages/necpp-wasm/src/array-solver.ts @@ -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, @@ -22,6 +27,7 @@ import type { ImpedanceResult, LoadDefinition, NecArraySolver, + NecCurrentDistribution, NecModel, NecModelState, NecWorkerModel, @@ -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, +): 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, +): NecCurrentDistribution { + const caller = allocateCallerModel(description); + const nativeSegmentByKey = new Map(); + 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(); + 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], @@ -545,6 +686,19 @@ class WorkerNecArraySolver implements NecArraySolver { return this.#solve("current", currents); } + async getCurrentDistribution( + options: { readonly kind: "latest-solution" }, + ): Promise { + 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 { const result = await this.#model.computeFarField(request); if (result.fieldBackend !== undefined) { diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts index 4835f499..9113da68 100644 --- a/packages/necpp-wasm/src/types.ts +++ b/packages/necpp-wasm/src/types.ts @@ -791,6 +791,21 @@ export interface NecArraySolver { computeImpedanceMatrix(): Promise; solveVoltages(voltages: ComplexVector): Promise; solveCurrents(currents: ComplexVector): Promise; + /** + * 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; computeFarField(request: FarFieldRequest): Promise; computeEmbeddedFarFields( request: FarFieldRequest, diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index ab556a6e..d8ecb77e 100644 --- a/packages/necpp-wasm/src/versions.ts +++ b/packages/necpp-wasm/src/versions.ts @@ -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"; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index 9936d7cf..fe0a80c2 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -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 }, diff --git a/packages/necpp-wasm/test/array-solver.integration.test.mjs b/packages/necpp-wasm/test/array-solver.integration.test.mjs index f5bda403..47120c6b 100644 --- a/packages/necpp-wasm/test/array-solver.integration.test.mjs +++ b/packages/necpp-wasm/test/array-solver.integration.test.mjs @@ -3,6 +3,8 @@ import { existsSync } from "node:fs"; import test from "node:test"; import { + NecInputError, + NecStateError, analyzeArraySymmetry, applyArrayBuildPlan, createNecArraySolver, @@ -72,6 +74,54 @@ function assertPowerBudgetClose(left, right, tolerance = 1e-10) { } } +function currentDescription({ centerM = [0.173, -0.219] } = {}) { + const { fixture, description } = arrayDescription({ centerM }); + description.patterns[0].wires = [ + { + id: "lower", + segments: 3, + startM: [0, 0, 0.1 * fixture.wavelengthM], + endM: [0, 0, 0.4 * fixture.wavelengthM], + radiusM: fixture.radiusM, + }, + { + id: "upper", + segments: 4, + startM: [0, 0, 0.4 * fixture.wavelengthM], + endM: [0, 0, 0.8 * fixture.wavelengthM], + radiusM: fixture.radiusM, + }, + ]; + description.patterns[0].ports = [{ wireId: "upper", segment: 1, name: "feed" }]; + return { fixture, description }; +} + +async function solvedCurrentSolver(description, fixture, symmetry) { + const solver = await createNecArraySolver(description, symmetry === "off" + ? { symmetry } + : { + symmetry, + symmetrizer: { + positionEpsilonM: 1e-12, + allowRotation: false, + }, + }); + await solver.prepare({ frequencyMHz: fixture.frequencyMHz }); + const count = description.elements.length; + const current = { + real: Float64Array.from({ length: count }, (_, index) => { + const magnitude = 0.25 + 0.11 * index; + return magnitude * Math.cos(0.37 * index); + }), + imag: Float64Array.from({ length: count }, (_, index) => { + const magnitude = 0.25 + 0.11 * index; + return magnitude * Math.sin(0.37 * index); + }), + }; + await solver.solveCurrents(current); + return solver; +} + async function exerciseUnbranched(description, fixture, symmetry) { const solver = await createNecArraySolver(description, symmetry === "off" ? { symmetry } @@ -196,3 +246,156 @@ test("off-origin explicit and centered symmetric complex fields prove the phase ); } }); + +test("array current distributions require a solved state and latest-solution kind", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = arrayDescription(); + const solver = await createNecArraySolver(description, { symmetry: "off" }); + try { + await assert.rejects( + solver.getCurrentDistribution({ kind: "latest-solution" }), + NecStateError, + ); + await assert.rejects( + solver.getCurrentDistribution({ kind: "unit-current" }), + NecInputError, + ); + await solver.prepare({ frequencyMHz: fixture.frequencyMHz }); + await assert.rejects( + solver.getCurrentDistribution({ kind: "latest-solution" }), + NecStateError, + ); + } finally { + await solver.dispose(); + } +}); + +test("array current distributions gather exact complex currents into caller geometry", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = currentDescription(); + const [explicit, symmetric] = await Promise.all([ + solvedCurrentSolver(description, fixture, "off"), + solvedCurrentSolver(description, fixture, "require"), + ]); + try { + const [explicitCurrents, symmetricCurrents] = await Promise.all([ + explicit.getCurrentDistribution({ kind: "latest-solution" }), + symmetric.getCurrentDistribution({ kind: "latest-solution" }), + ]); + assert.equal(explicitCurrents.modeKind, "latest-solution"); + assert.equal(symmetricCurrents.modeKind, "latest-solution"); + assert.equal(explicitCurrents.modeCount, 1); + assert.equal(symmetricCurrents.modeCount, 1); + + const expectedSegments = description.elements.flatMap((_, elementIndex) => [ + ...Array.from({ length: 3 }, (_, index) => ({ + tag: 2 * elementIndex + 1, + segment: index + 1, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + tag: 2 * elementIndex + 2, + segment: index + 1, + })), + ]); + assert.deepEqual( + explicitCurrents.segments.map(({ tag, segment }) => ({ tag, segment })), + expectedSegments, + ); + assert.deepEqual( + symmetricCurrents.segments.map(({ tag, segment }) => ({ tag, segment })), + expectedSegments, + ); + assert.deepEqual(symmetricCurrents.startEnds, explicitCurrents.startEnds); + assert.deepEqual(symmetricCurrents.endEnds, explicitCurrents.endEnds); + assert.ok(symmetricCurrents.startEnds.some((end) => end.kind === "segment")); + assert.ok(symmetricCurrents.endEnds.some((end) => end.kind === "segment")); + for (const ends of [symmetricCurrents.startEnds, symmetricCurrents.endEnds]) { + for (const end of ends) { + if (end.kind === "segment") { + assert.ok(end.tag >= 1 && end.tag <= 2 * description.elements.length); + } + } + } + assert.deepEqual( + [...symmetricCurrents.segments].map((segment) => segment.nativeIndex).sort((a, b) => a - b), + Array.from({ length: expectedSegments.length }, (_, index) => index), + ); + assert.ok(symmetricCurrents.segments.some( + (segment, index) => segment.nativeIndex !== index, + )); + + for (const name of [ + "centresM", "startsM", "endsM", "tangents", "radiiM", "lengthsM", + "aReal", "aImag", "bReal", "bImag", "cReal", "cImag", + ]) { + assert.ok( + relativeError(symmetricCurrents[name], explicitCurrents[name]) <= 1e-8, + `${name} explicit/symmetric parity`, + ); + } + assert.ok(Math.abs(symmetricCurrents.centresM[0] - explicitCurrents.centresM[0]) < 1e-12); + assert.ok(Math.abs(symmetricCurrents.centresM[1] - explicitCurrents.centresM[1]) < 1e-12); + + for (const solver of [explicit, symmetric]) { + const earlier = await solver.getCurrentDistribution({ kind: "latest-solution" }); + const saved = earlier.aReal.slice(); + const later = await solver.getCurrentDistribution({ kind: "latest-solution" }); + later.aReal.fill(Number.NaN); + assert.deepEqual(earlier.aReal, saved); + } + } finally { + await Promise.all([explicit.dispose(), symmetric.dispose()]); + } +}); + +test("symmetric current geometry reports the planner-canonicalized absolute positions", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = arrayDescription({ side: 4 }); + const epsilon = 1e-5; + const jittered = structuredClone(description); + jittered.elements = jittered.elements.map((element, index) => ({ + ...element, + positionM: [ + element.positionM[0] + ((index % 3) - 1) * epsilon / 10, + element.positionM[1] + ((index % 5) - 2) * epsilon / 12, + ], + })); + const symmetrizer = { + positionEpsilonM: epsilon, + allowRotation: false, + }; + const plan = analyzeArraySymmetry(jittered, symmetrizer); + assert.equal(plan.kind, "symmetric"); + assert.equal(plan.diagnostics.exact, false); + assert.ok(plan.diagnostics.canonicalizations.some(({ distanceM }) => distanceM > 0)); + + const solver = await createNecArraySolver(jittered, { + symmetry: "require", + symmetrizer, + }); + try { + await solver.prepare({ frequencyMHz: fixture.frequencyMHz }); + await solver.solveCurrents({ + real: new Float64Array(jittered.elements.length).fill(0.25), + imag: Float64Array.from( + { length: jittered.elements.length }, + (_, index) => 0.1 * Math.sin(0.2 * index), + ), + }); + const currents = await solver.getCurrentDistribution({ kind: "latest-solution" }); + for (const canonicalization of plan.diagnostics.canonicalizations) { + const segment = canonicalization.callerElementIndex * fixture.segments; + assert.ok( + Math.abs(currents.centresM[3 * segment] - canonicalization.canonicalPositionM[0]) < 1e-12, + ); + assert.ok( + Math.abs(currents.centresM[3 * segment + 1] - canonicalization.canonicalPositionM[1]) < 1e-12, + ); + } + } finally { + await solver.dispose(); + } +}); diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 0414bed5..64a7b11c 100644 --- a/packages/necpp-wasm/test/facade-runtime.test.mjs +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -54,7 +54,7 @@ function addDipole(model) { } test("package, engine, and ABI versions are exported", () => { - assert.equal(packageVersion, "0.5.1"); + assert.equal(packageVersion, "0.6.0"); assert.equal(engineVersion, "2.5.0"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 70cd7b94..a9c63774 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -23,7 +23,7 @@ test("npm pack contains only the documented publish files", { skip }, () => { assert.equal(packed.version, packageJson.version); const filenamePrefix = packageJson.name.slice(1).replace("/", "-"); assert.equal(packed.filename, `${filenamePrefix}-${packageJson.version}.tgz`); - assert.equal(packageJson.version, "0.5.1"); + assert.equal(packageJson.version, "0.6.0"); assert.equal(fixtureManifest.packageVersion, packageJson.version); assert.equal(packageJson.engines.node, ">=24"); assert.deepEqual(packageJson.publishConfig, {