From b068d940d72a4c394abe081f652fada019590e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 20:39:00 +0200 Subject: [PATCH 01/46] Update build.yml to preserve wasm artifacts --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31b4c38b..c501dd58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,3 +70,10 @@ jobs: test -f nec2pp.js test -f nec2pp.wasm echo "WASM artifacts present: $(ls -la nec2pp.js nec2pp.wasm)" + - name: Upload WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: necpp-wasm + path: | + nec2pp.js + nec2pp.wasm From 41c0df51ad0c985ac165623f58bf4b51dbde3417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 20:42:53 +0200 Subject: [PATCH 02/46] Update build.yml to allow manual runs --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c501dd58..00571680 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: [master, main] pull_request: branches: [master, main] + workflow_dispatch: jobs: build: From 1b31db443d6d8a085cbac89324e4c78f20821681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 20:51:46 +0200 Subject: [PATCH 03/46] Update build.yml to only compile wasm --- .github/workflows/build.yml | 85 +++++++++++++------------------------ 1 file changed, 30 insertions(+), 55 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00571680..a04f057b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build +name: Build WASM on: push: @@ -7,74 +7,49 @@ on: branches: [master, main] workflow_dispatch: +# Cancel an obsolete build when newer commits arrive on the same branch/PR. +concurrency: + group: wasm-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: - name: Build & test (${{ matrix.os }}) - runs-on: ${{ matrix.os }} + wasm: + name: Build NEC++ WebAssembly + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: contents: read - # Hard backstop so a hung step/process never consumes the full 6-hour CI - # ceiling (seen on windows-latest before the CI guard was added). - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - # Exercise the platforms INSTALL.md documents: Linux, macOS, Windows. - os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install CMake (Linux) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y cmake - - # CMake is preinstalled on the GitHub Actions macOS and Windows runners. - - - name: Configure - run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release - - - name: Build - run: cmake --build build --config Release -j$(nproc) 2>/dev/null || cmake --build build --config Release -j2 - shell: bash - - - name: Run unit tests - run: ctest --test-dir build --build-config Release --output-on-failure --timeout 300 - shell: bash + - name: Build optimized WASM + run: ./scripts/build_wasm_docker.sh - - name: Simulation smoke test + - name: Prepare package artifacts shell: bash run: | - BIN="build/src/nec2++" - [ -f "$BIN" ] || BIN="build/src/Release/nec2++.exe" - [ -f "$BIN" ] || BIN="build/src/nec2++.exe" - "$BIN" -i testharness/data/herzian_dipole.nec -o smoke_output.txt - grep -q "TOTAL RUN TIME" smoke_output.txt && echo "Smoke test passed" + test -s nec2pp.js + test -s nec2pp.wasm - # Separate job: the WASM build (needs Docker, so Linux only). - wasm: - name: WASM build - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 + mkdir -p wasm-dist + cp nec2pp.js nec2pp.wasm wasm-dist/ - - name: Build WASM via Emscripten Docker image - run: ./scripts/build_wasm_docker.sh + sha256sum \ + wasm-dist/nec2pp.js \ + wasm-dist/nec2pp.wasm \ + > wasm-dist/SHA256SUMS - - name: Verify WASM artifacts - run: | - test -f nec2pp.js - test -f nec2pp.wasm - echo "WASM artifacts present: $(ls -la nec2pp.js nec2pp.wasm)" - - name: Upload WASM artifacts + echo "Generated artifacts:" + ls -lh wasm-dist + + - name: Upload WASM package uses: actions/upload-artifact@v4 with: name: necpp-wasm - path: | - nec2pp.js - nec2pp.wasm + path: wasm-dist/ + if-no-files-found: error + retention-days: 30 + compression-level: 6 From 22c712cc70f015ad625cb768b4705c2cc6c9806d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 20:54:37 +0200 Subject: [PATCH 04/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 347a35f9..d9e91b05 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -1,4 +1,69 @@ #!/bin/bash +# Build the reusable NEC++ WebAssembly module inside the Emscripten Docker image. +# +# Produces in the repository root: +# nec2pp.js +# nec2pp.wasm +# nec2pp.d.ts + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="build-wasm" +WASM_IMAGE="emscripten/emsdk:4.0.7" + +cd "$PROJECT_DIR" + +rm -f nec2pp.js nec2pp.wasm nec2pp.d.ts + +echo "=== Building WASM via Emscripten Docker image: $WASM_IMAGE ===" + +docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PROJECT_DIR:/src" \ + -w /src \ + "$WASM_IMAGE" \ + bash -c " + set -euo pipefail + + emcmake cmake -B $BUILD_DIR -S . \ + -DCMAKE_BUILD_TYPE=Release \ + -DNECPP_BUILD_WASM=ON \ + -DNECPP_BUILD_TESTS=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_C_FLAGS_RELEASE='-O3 -DNDEBUG -flto' \ + -DCMAKE_CXX_FLAGS_RELEASE='-O3 -DNDEBUG -flto' \ + -DCMAKE_EXE_LINKER_FLAGS_RELEASE=' + -O3 + -flto + -sMODULARIZE=1 + -sEXPORT_ES6=1 + -sEXPORT_NAME=createNecModule + -sENVIRONMENT=web,worker + -sINVOKE_RUN=0 + -sEXIT_RUNTIME=0 + -sALLOW_MEMORY_GROWTH=1 + -sFILESYSTEM=1 + -sEXPORTED_RUNTIME_METHODS=FS,callMain + --emit-tsd nec2pp.d.ts + ' + + cmake --build $BUILD_DIR --config Release -j\$(nproc) + + test -s $BUILD_DIR/src/nec2pp.js + test -s $BUILD_DIR/src/nec2pp.wasm + test -s $BUILD_DIR/src/nec2pp.d.ts + + cp \ + $BUILD_DIR/src/nec2pp.js \ + $BUILD_DIR/src/nec2pp.wasm \ + $BUILD_DIR/src/nec2pp.d.ts \ + . + " + +echo "=== WASM build complete ===" +ls -lh nec2pp.js nec2pp.wasm nec2pp.d.ts#!/bin/bash # Build the nec2++ WASM target inside the Emscripten Docker image, for hosts # that don't have a local emsdk install. # From 988c3065e5aad88deb6b4f903b4c1ddf1fc9bd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 20:57:04 +0200 Subject: [PATCH 05/46] Update build.yml to new wasb build shell script --- .github/workflows/build.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a04f057b..3babd598 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,19 +27,21 @@ jobs: - name: Build optimized WASM run: ./scripts/build_wasm_docker.sh - + - name: Prepare package artifacts shell: bash run: | test -s nec2pp.js test -s nec2pp.wasm + test -s nec2pp.d.ts mkdir -p wasm-dist - cp nec2pp.js nec2pp.wasm wasm-dist/ + cp nec2pp.js nec2pp.wasm nec2pp.d.ts wasm-dist/ sha256sum \ wasm-dist/nec2pp.js \ wasm-dist/nec2pp.wasm \ + wasm-dist/nec2pp.d.ts \ > wasm-dist/SHA256SUMS echo "Generated artifacts:" @@ -53,3 +55,6 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 6 + + + From 03a4abb72c8d0b2b6b7bcac86db855683e363863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:01:49 +0200 Subject: [PATCH 06/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index d9e91b05..62ca2d23 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -27,27 +27,16 @@ docker run --rm \ bash -c " set -euo pipefail - emcmake cmake -B $BUILD_DIR -S . \ + # Remove the cache potentially corrupted by an earlier configuration. + rm -rf "$BUILD_DIR" + + emcmake cmake -B "$BUILD_DIR" -S . \ -DCMAKE_BUILD_TYPE=Release \ -DNECPP_BUILD_WASM=ON \ -DNECPP_BUILD_TESTS=OFF \ -DBUILD_SHARED_LIBS=OFF \ - -DCMAKE_C_FLAGS_RELEASE='-O3 -DNDEBUG -flto' \ - -DCMAKE_CXX_FLAGS_RELEASE='-O3 -DNDEBUG -flto' \ - -DCMAKE_EXE_LINKER_FLAGS_RELEASE=' - -O3 - -flto - -sMODULARIZE=1 - -sEXPORT_ES6=1 - -sEXPORT_NAME=createNecModule - -sENVIRONMENT=web,worker - -sINVOKE_RUN=0 - -sEXIT_RUNTIME=0 - -sALLOW_MEMORY_GROWTH=1 - -sFILESYSTEM=1 - -sEXPORTED_RUNTIME_METHODS=FS,callMain - --emit-tsd nec2pp.d.ts - ' + "-DCMAKE_CXX_FLAGS_RELEASE=-O3 -DNDEBUG -flto" \ + "-DCMAKE_EXE_LINKER_FLAGS_RELEASE=-O3 -flto -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createNecModule -sENVIRONMENT=web,worker -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain --emit-tsd nec2pp.d.ts" cmake --build $BUILD_DIR --config Release -j\$(nproc) From c7b4a4120be7b0053229011764858a5800b6b5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:04:49 +0200 Subject: [PATCH 07/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 39 +----------------------------------- 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 62ca2d23..f1a9cd9c 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -52,41 +52,4 @@ docker run --rm \ " echo "=== WASM build complete ===" -ls -lh nec2pp.js nec2pp.wasm nec2pp.d.ts#!/bin/bash -# Build the nec2++ WASM target inside the Emscripten Docker image, for hosts -# that don't have a local emsdk install. -# -# Produces nec2pp.js + nec2pp.wasm in the repo root (matching the legacy -# Makefile output names so existing consumers are unaffected). -# -# Replaces the `make wasm` target from the old hand-written Makefile. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -BUILD_DIR="build-wasm" -WASM_IMAGE="emscripten/emsdk:4.0.7" - -cd "$PROJECT_DIR" - -echo "=== Building WASM via Emscripten Docker image: $WASM_IMAGE ===" -docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PROJECT_DIR:/src" \ - -w /src \ - "$WASM_IMAGE" \ - bash -c " - set -euo pipefail - emcmake cmake -B $BUILD_DIR -S . \ - -DCMAKE_BUILD_TYPE=Release \ - -DNECPP_BUILD_WASM=ON \ - -DNECPP_BUILD_TESTS=OFF \ - -DBUILD_SHARED_LIBS=OFF - cmake --build $BUILD_DIR -j\$(nproc) - # Emscripten emits nec2pp.js + nec2pp.wasm in the build dir; copy them - # to the repo root for parity with the old Makefile output location. - cp -f $BUILD_DIR/src/nec2pp.js $BUILD_DIR/src/nec2pp.wasm . 2>/dev/null || true - " - -echo "=== WASM build complete: nec2pp.js + nec2pp.wasm ===" -ls -la nec2pp.js nec2pp.wasm +ls -lh nec2pp.js nec2pp.wasm nec2pp.d.ts From d0ed7d32bc03d1da0b27787003d07892ace959a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:08:35 +0200 Subject: [PATCH 08/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index f1a9cd9c..4c1756e4 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -52,4 +52,3 @@ docker run --rm \ " echo "=== WASM build complete ===" -ls -lh nec2pp.js nec2pp.wasm nec2pp.d.ts From a2e62c68b5987f6ecb84f62120030d18628519f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:15:17 +0200 Subject: [PATCH 09/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 37 ++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 4c1756e4..dc205069 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -21,34 +21,47 @@ echo "=== Building WASM via Emscripten Docker image: $WASM_IMAGE ===" docker run --rm \ --user "$(id -u):$(id -g)" \ + -e BUILD_DIR="$BUILD_DIR" \ -v "$PROJECT_DIR:/src" \ -w /src \ "$WASM_IMAGE" \ - bash -c " + bash -c ' set -euo pipefail - # Remove the cache potentially corrupted by an earlier configuration. rm -rf "$BUILD_DIR" + CXX_FLAGS="-O3 -DNDEBUG -flto" + LINK_FLAGS="-O3 -flto \ +-sMODULARIZE=1 \ +-sEXPORT_ES6=1 \ +-sEXPORT_NAME=createNecModule \ +-sENVIRONMENT=web,worker \ +-sINVOKE_RUN=0 \ +-sEXIT_RUNTIME=0 \ +-sALLOW_MEMORY_GROWTH=1 \ +-sFILESYSTEM=1 \ +-sEXPORTED_RUNTIME_METHODS=FS,callMain \ +--emit-tsd nec2pp.d.ts" + emcmake cmake -B "$BUILD_DIR" -S . \ -DCMAKE_BUILD_TYPE=Release \ -DNECPP_BUILD_WASM=ON \ -DNECPP_BUILD_TESTS=OFF \ -DBUILD_SHARED_LIBS=OFF \ - "-DCMAKE_CXX_FLAGS_RELEASE=-O3 -DNDEBUG -flto" \ - "-DCMAKE_EXE_LINKER_FLAGS_RELEASE=-O3 -flto -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createNecModule -sENVIRONMENT=web,worker -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain --emit-tsd nec2pp.d.ts" + "-DCMAKE_CXX_FLAGS_RELEASE=$CXX_FLAGS" \ + "-DCMAKE_EXE_LINKER_FLAGS_RELEASE=$LINK_FLAGS" - cmake --build $BUILD_DIR --config Release -j\$(nproc) + cmake --build "$BUILD_DIR" --config Release -j"$(nproc)" - test -s $BUILD_DIR/src/nec2pp.js - test -s $BUILD_DIR/src/nec2pp.wasm - test -s $BUILD_DIR/src/nec2pp.d.ts + test -s "$BUILD_DIR/src/nec2pp.js" + test -s "$BUILD_DIR/src/nec2pp.wasm" + test -s "$BUILD_DIR/src/nec2pp.d.ts" cp \ - $BUILD_DIR/src/nec2pp.js \ - $BUILD_DIR/src/nec2pp.wasm \ - $BUILD_DIR/src/nec2pp.d.ts \ + "$BUILD_DIR/src/nec2pp.js" \ + "$BUILD_DIR/src/nec2pp.wasm" \ + "$BUILD_DIR/src/nec2pp.d.ts" \ . - " + ' echo "=== WASM build complete ===" From d8a79c97bdb29566df83488dd26d732f73fca4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:19:11 +0200 Subject: [PATCH 10/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index dc205069..07dd3b4e 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -8,6 +8,19 @@ set -euo pipefail +TS_TOOLS_DIR="/tmp/emscripten-ts-tools" +export npm_config_cache="/tmp/npm-cache" + +npm install \ + --prefix "$TS_TOOLS_DIR" \ + --no-save \ + --no-package-lock \ + typescript@5.8.3 + +export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" + +tsc --version + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_DIR="build-wasm" From 4c988e711e081d35578b10e925fd3a6ff9bcbf1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:23:50 +0200 Subject: [PATCH 11/46] Update build_wasm_docker.sh --- scripts/build_wasm_docker.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 07dd3b4e..1f403e23 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -8,19 +8,6 @@ set -euo pipefail -TS_TOOLS_DIR="/tmp/emscripten-ts-tools" -export npm_config_cache="/tmp/npm-cache" - -npm install \ - --prefix "$TS_TOOLS_DIR" \ - --no-save \ - --no-package-lock \ - typescript@5.8.3 - -export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" - -tsc --version - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_DIR="build-wasm" @@ -41,6 +28,19 @@ docker run --rm \ bash -c ' set -euo pipefail + TS_TOOLS_DIR="/tmp/emscripten-ts-tools" + export npm_config_cache="/tmp/npm-cache" + + npm install \ + --prefix "$TS_TOOLS_DIR" \ + --no-save \ + --no-package-lock \ + typescript@5.8.3 + + export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" + + tsc --version + rm -rf "$BUILD_DIR" CXX_FLAGS="-O3 -DNDEBUG -flto" From 0d095df1b606df4763f4afa373fae23b15b441f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 27 Aug 2026 21:51:01 +0200 Subject: [PATCH 12/46] Update build_wasm_docker.sh to enable exceptions --- scripts/build_wasm_docker.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 1f403e23..054cfad4 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -43,7 +43,7 @@ docker run --rm \ rm -rf "$BUILD_DIR" - CXX_FLAGS="-O3 -DNDEBUG -flto" + CXX_FLAGS="-O3 -DNDEBUG -flto -fexceptions" LINK_FLAGS="-O3 -flto \ -sMODULARIZE=1 \ -sEXPORT_ES6=1 \ @@ -54,6 +54,7 @@ docker run --rm \ -sALLOW_MEMORY_GROWTH=1 \ -sFILESYSTEM=1 \ -sEXPORTED_RUNTIME_METHODS=FS,callMain \ +-sDISABLE_EXCEPTION_CATCHING=0 \ --emit-tsd nec2pp.d.ts" emcmake cmake -B "$BUILD_DIR" -S . \ From 48ab35a2bd2eb42bc2ceab1e0fc7494b8e0a5955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 07:16:11 +0200 Subject: [PATCH 13/46] WASM API fix --- INSTALL.md | 27 +++++ scripts/build_wasm_docker.sh | 9 +- scripts/wasm_smoke_test.mjs | 77 +++++++++++++ src/CMakeLists.txt | 8 +- src/c_geometry.cpp | 36 +++++-- src/c_geometry.h | 16 ++- src/nec2cpp.cpp | 81 -------------- src/nec_deck.cpp | 203 +++++++++++++++++++++++++++++++++++ src/nec_deck.h | 12 +++ src/nec_output.cpp | 121 ++++----------------- src/nec_wasm.cpp | 184 ++++++++++++++++++------------- tests/CMakeLists.txt | 3 +- wasm/nec2pp.d.ts | 44 ++++++++ 13 files changed, 547 insertions(+), 274 deletions(-) create mode 100644 scripts/wasm_smoke_test.mjs create mode 100644 src/nec_deck.cpp create mode 100644 src/nec_deck.h create mode 100644 wasm/nec2pp.d.ts diff --git a/INSTALL.md b/INSTALL.md index ebdc291f..373e0ad4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -96,6 +96,33 @@ Two ways: Both produce `nec2pp.js` + `nec2pp.wasm` exposing a C API (`nec_create_context`, `nec_process_input`, `nec_get_output`, …). +The module is an ES module factory. Runtime helpers and C exports are available +on the initialized module object: + +```js +import createNecModule from "./nec2pp.js"; + +const module = await createNecModule(); +const context = module._nec_create_context(); +try { + const status = module.ccall( + "nec_process_input", "number", ["number", "string"], + [context, necInputText]); + const length = module._nec_get_output_length(context); + const output = module.UTF8ToString( + module._nec_get_output(context), length); + if (status !== 0) + throw new Error(output); +} finally { + module._nec_delete_context(context); +} +``` + +`nec_process_input` consumes the supplied complete deck and returns `0` on +success. Parse and solver failures return a negative status with a controlled +message available through `nec_get_output`; C++ exceptions do not cross the +WASM boundary. + ## Using the library from another project After `cmake --install`, necpp exposes both pkg-config and a CMake package diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 054cfad4..49049a6b 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -48,12 +48,11 @@ docker run --rm \ -sMODULARIZE=1 \ -sEXPORT_ES6=1 \ -sEXPORT_NAME=createNecModule \ --sENVIRONMENT=web,worker \ +-sENVIRONMENT=web,worker,node \ -sINVOKE_RUN=0 \ -sEXIT_RUNTIME=0 \ -sALLOW_MEMORY_GROWTH=1 \ --sFILESYSTEM=1 \ --sEXPORTED_RUNTIME_METHODS=FS,callMain \ +-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,lengthBytesUTF8 \ -sDISABLE_EXCEPTION_CATCHING=0 \ --emit-tsd nec2pp.d.ts" @@ -71,6 +70,10 @@ docker run --rm \ test -s "$BUILD_DIR/src/nec2pp.wasm" test -s "$BUILD_DIR/src/nec2pp.d.ts" + node --experimental-default-type=module \ + scripts/wasm_smoke_test.mjs \ + "$BUILD_DIR/src/nec2pp.js" + cp \ "$BUILD_DIR/src/nec2pp.js" \ "$BUILD_DIR/src/nec2pp.wasm" \ diff --git a/scripts/wasm_smoke_test.mjs b/scripts/wasm_smoke_test.mjs new file mode 100644 index 00000000..36d1be72 --- /dev/null +++ b/scripts/wasm_smoke_test.mjs @@ -0,0 +1,77 @@ +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.argv[2]; +if (!modulePath) { + throw new Error("usage: node wasm_smoke_test.mjs "); +} + +const { default: createNecModule } = await import( + pathToFileURL(path.resolve(modulePath)).href +); +const module = await createNecModule(); + +const context = module._nec_create_context(); +if (!context) { + throw new Error("nec_create_context returned null"); +} + +const processInput = (deck) => module.ccall( + "nec_process_input", + "number", + ["number", "string"], + [context, deck], +); +const getOutput = () => { + const length = module._nec_get_output_length(context); + const pointer = module._nec_get_output(context); + return { length, text: module.UTF8ToString(pointer, length) }; +}; + +const validDeck = `CM WASM STRING INPUT SMOKE TEST +CE +GW 0 9 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 +GE 0 +FR 0 1 0 0 300.0 +EX 0 0 5 0 1.0 0.0 +XQ +EN +`; + +try { + const validStatus = processInput(validDeck); + if (validStatus !== 0) { + throw new Error(`valid deck returned ${validStatus}: ${getOutput().text}`); + } + + const validOutput = getOutput(); + if (validOutput.length <= 0 || validOutput.text.length <= 0) { + throw new Error("valid deck produced empty output"); + } + if (!validOutput.text.includes("WASM STRING INPUT SMOKE TEST")) { + throw new Error("supplied string marker was not consumed"); + } + if (!validOutput.text.includes("ANTENNA INPUT PARAMETERS")) { + throw new Error("valid deck did not produce the expected NEC result marker"); + } + + let invalidStatus; + try { + invalidStatus = processInput("CE INVALID INPUT\nBOGUS\nEN\n"); + } catch (error) { + throw new Error(`invalid input caused a WASM trap: ${error}`); + } + + const invalidOutput = getOutput(); + if (invalidStatus >= 0) { + throw new Error(`invalid deck unexpectedly returned ${invalidStatus}`); + } + if (!invalidOutput.text.startsWith("Error:")) { + throw new Error(`invalid deck did not return a controlled error: ${invalidOutput.text}`); + } +} finally { + module._nec_delete_context(context); +} + +console.log("WASM API smoke test passed"); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7af746d7..1876ac38 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -17,6 +17,7 @@ set(NECPP_LIB_SRCS libNEC.cpp matrix_algebra.cpp misc.cpp + nec_deck.cpp nec_context.cpp nec_exception.cpp nec_ground.cpp @@ -143,17 +144,20 @@ endif() if(NECPP_BUILD_WASM) add_executable(nec2pp_wasm ${CMAKE_CURRENT_SOURCE_DIR}/nec_wasm.cpp - XGetopt.cpp ${NECPP_LIB_SRCS}) target_include_directories(nec2pp_wasm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${NECPP_EIGEN_DIR} ${CMAKE_BINARY_DIR}) - # Flags carried verbatim from the old Makefile wasm target. + # Exceptions must be enabled while compiling every source in the target and + # while linking the Emscripten module so the C ABI can contain failures. + target_compile_options(nec2pp_wasm PRIVATE -fexceptions) target_link_options(nec2pp_wasm PRIVATE + -fexceptions -sWASM=1 "-sEXPORTED_FUNCTIONS=[\"_nec_create_context\",\"_nec_delete_context\",\"_nec_process_input\",\"_nec_get_output\",\"_nec_get_output_length\",\"_nec_free\"]" "-sEXPORTED_RUNTIME_METHODS=[\"ccall\",\"cwrap\",\"UTF8ToString\",\"lengthBytesUTF8\"]" + -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 --no-entry) # OUTPUT_NAME nec2pp + SUFFIX .js → Emscripten emits nec2pp.js + nec2pp.wasm, diff --git a/src/c_geometry.cpp b/src/c_geometry.cpp index 51dcc290..2a8de153 100644 --- a/src/c_geometry.cpp +++ b/src/c_geometry.cpp @@ -112,7 +112,8 @@ void str_toupper(std::string &str) } /* input card mnemonic list (for reference): "GW", "GX", "GR", "GS", "GE", "GM", "SP", "SM", "GA", "SC", "GH", "GF" */ -void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp ) +template +void c_geometry::parse_geometry_impl(nec_context* in_context, Input& input) { geometry_parse_state st; @@ -127,7 +128,7 @@ void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp ) /* read geometry data card and dispatch to the handler for the requested operation */ do { - read_geometry_card(input_fp, st.gm, &st.card_int_1, &st.card_int_2, + read_geometry_card(input, st.gm, &st.card_int_1, &st.card_int_2, &st.xw1, &st.yw1, &st.zw1, &st.xw2, &st.yw2, &st.zw2, &st.rad); std::string card_id(st.gm); @@ -145,7 +146,7 @@ void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp ) /* Dispatch by first character then second character */ char c0 = card_id[0], c1 = card_id[1]; if (c0 == 'G') { - if (c1 == 'W') parse_gw_card(input_fp, st); + if (c1 == 'W') parse_gw_card(input, st); else if (c1 == 'X') gx_card(st.card_int_1, st.card_int_2); else if (c1 == 'R') parse_gr_card(st); else if (c1 == 'S') parse_gs_card(st); @@ -156,8 +157,8 @@ void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp ) else if (c1 == 'A') parse_ga_card(st); else parse_geometry_error(st); } else if (c0 == 'S') { - if (c1 == 'P') parse_sp_card(input_fp, st); - else if (c1 == 'M') parse_sm_card(input_fp, st); + if (c1 == 'P') parse_sp_card(input, st); + else if (c1 == 'M') parse_sm_card(input, st); else if (c1 == 'C') parse_sc_card(st); else parse_geometry_error(st); } else { @@ -167,6 +168,16 @@ void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp ) while( true ); } +void c_geometry::parse_geometry(nec_context* in_context, FILE* input_fp) +{ + parse_geometry_impl(in_context, input_fp); +} + +void c_geometry::parse_geometry(nec_context* in_context, std::istream& input) +{ + parse_geometry_impl(in_context, input); +} + /* Print the one-time "STRUCTURE SPECIFICATION" banner. */ void c_geometry::parse_structure_header() { @@ -186,7 +197,8 @@ void c_geometry::parse_structure_header() whose taper ratio and end radii follow on a "GC" continuation card. card_int_1 - tag no. card_int_2 - no. segments xw1,yw1,zw1 - end 1 xw2,yw2,zw2 - end 2 rad - wire radius */ -void c_geometry::parse_gw_card(FILE* input_fp, geometry_parse_state& st) +template +void c_geometry::parse_gw_card(Input& input, geometry_parse_state& st) { int64_t wire_segment_count = st.card_int_2; int64_t wire_tag = st.card_int_1; @@ -211,7 +223,7 @@ void c_geometry::parse_gw_card(FILE* input_fp, geometry_parse_state& st) int ix,iy; nec_float zs1, dummy; char gm[3]; - read_geometry_card(input_fp, gm, &ix, &iy, &xs1, &ys1, &zs1, + read_geometry_card(input, gm, &ix, &iy, &xs1, &ys1, &zs1, &dummy, &dummy, &dummy, &dummy); if ( strcmp(gm, "GC" ) != 0 ) @@ -269,7 +281,8 @@ void c_geometry::parse_gm_card(geometry_parse_state& st) /* "SP" card: generate a single new surface patch. For multi-corner patches the remaining corner data follows on an "SC" continuation card. */ -void c_geometry::parse_sp_card(FILE* input_fp, geometry_parse_state& st) +template +void c_geometry::parse_sp_card(Input& input, geometry_parse_state& st) { const char ipt[4] = { 'P', 'R', 'T', 'Q' }; int64_t i1= m+1; @@ -291,7 +304,7 @@ void c_geometry::parse_sp_card(FILE* input_fp, geometry_parse_state& st) int ix,iy; nec_float dummy; char gm[3]; - read_geometry_card(input_fp, gm, &ix, &iy, &st.x3, &st.y3, &st.z3, &st.x4, &st.y4, &st.z4, &dummy); + read_geometry_card(input, gm, &ix, &iy, &st.x3, &st.y3, &st.z3, &st.x4, &st.y4, &st.z4, &dummy); if ( (st.card_int_2 == 2) || (st.card_int_1 > 0) ) { @@ -318,7 +331,8 @@ void c_geometry::parse_sp_card(FILE* input_fp, geometry_parse_state& st) /* "SM" card: generate a multiple-patch surface. The opposite-corner data follows on an "SC" continuation card. */ -void c_geometry::parse_sm_card(FILE* input_fp, geometry_parse_state& st) +template +void c_geometry::parse_sm_card(Input& input, geometry_parse_state& st) { const char ipt[4] = { 'P', 'R', 'T', 'Q' }; int64_t i1= m+1; @@ -333,7 +347,7 @@ void c_geometry::parse_sm_card(FILE* input_fp, geometry_parse_state& st) int ix,iy; nec_float dummy; char gm[3]; - read_geometry_card(input_fp, gm, &ix, &iy, &st.x3, &st.y3, &st.z3, &st.x4, &st.y4, &st.z4, &dummy); + read_geometry_card(input, gm, &ix, &iy, &st.x3, &st.y3, &st.z3, &st.x4, &st.y4, &st.z4, &dummy); if ( (st.card_int_2 == 2) || (st.card_int_1 > 0) ) { diff --git a/src/c_geometry.h b/src/c_geometry.h index b7ce7d43..214ae0c1 100644 --- a/src/c_geometry.h +++ b/src/c_geometry.h @@ -170,6 +170,10 @@ class c_geometry */ void parse_geometry(nec_context* m_context, FILE* input_fp); + /*!\brief Parse an NEC geometry description from a C++ input stream. + */ + void parse_geometry(nec_context* m_context, std::istream& input); + /*!\brief Helper method to decide whether extended. thin-wire approximation can be used */ @@ -287,13 +291,19 @@ class c_geometry int nwire = 0; // running count of wire/arc/helix elements }; + template + void parse_geometry_impl(nec_context* in_context, Input& input); + void parse_structure_header(); - void parse_gw_card(FILE* input_fp, geometry_parse_state& st); + template + void parse_gw_card(Input& input, geometry_parse_state& st); void parse_gr_card(geometry_parse_state& st); void parse_gs_card(geometry_parse_state& st); void parse_gm_card(geometry_parse_state& st); - void parse_sp_card(FILE* input_fp, geometry_parse_state& st); - void parse_sm_card(FILE* input_fp, geometry_parse_state& st); + template + void parse_sp_card(Input& input, geometry_parse_state& st); + template + void parse_sm_card(Input& input, geometry_parse_state& st); void parse_ga_card(geometry_parse_state& st); void parse_sc_card(geometry_parse_state& st); void parse_gh_card(geometry_parse_state& st); diff --git a/src/nec2cpp.cpp b/src/nec2cpp.cpp index 707121a8..53cf93be 100644 --- a/src/nec2cpp.cpp +++ b/src/nec2cpp.cpp @@ -546,84 +546,3 @@ static void sig_handler(int signal ) } } #endif - -/*-----------------------------------------------------------------------*/ -/* Table-driven card handlers (replaces atst[] + switch dispatch) */ -/* See nec_card_parser.h for the handler table declaration. */ -/*-----------------------------------------------------------------------*/ - -#include "nec_card_parser.h" - -void handle_fr(nec_context& ctx, const nec_card& c) { - ctx.fr_card(c.i[0], c.i[1], c.f[0], c.f[1]); -} -void handle_ld(nec_context& ctx, const nec_card& c) { - ctx.ld_card(c.i[0], c.i[1], c.i[2], c.i[3], c.f[0], c.f[1], c.f[2]); -} -void handle_gn(nec_context& ctx, const nec_card& c) { - ctx.gn_card(c.i[0], c.i[1], c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_ex(nec_context& ctx, const nec_card& c) { - ctx.ex_card((enum excitation_type)c.i[0], c.i[1], c.i[2], c.i[3], - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_nt(nec_context& ctx, const nec_card& c) { - ctx.nt_card(c.i[0], c.i[1], c.i[2], c.i[3], - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_tl(nec_context& ctx, const nec_card& c) { - ctx.tl_card(c.i[0], c.i[1], c.i[2], c.i[3], - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_xq(nec_context& ctx, const nec_card& c) { - ctx.xq_card(c.i[0]); -} -void handle_gd(nec_context& ctx, const nec_card& c) { - ctx.gd_card(c.f[0], c.f[1], c.f[2], c.f[3]); -} -void handle_rp(nec_context& ctx, const nec_card& c) { - int XNDA = c.i[3]; - ctx.rp_card(c.i[0], c.i[1], c.i[2], - XNDA / 1000, (XNDA / 100) % 10, (XNDA / 10) % 10, XNDA % 10, - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_nx(nec_context&, const nec_card&) { - /* NX sets next_job flag — handled in the dispatch loop */ -} -void handle_pt(nec_context& ctx, const nec_card& c) { - ctx.pt_card(c.i[0], c.i[1], c.i[2], c.i[3]); -} -void handle_kh(nec_context& ctx, const nec_card& c) { - ctx.kh_card(c.f[0]); -} -void handle_ne(nec_context& ctx, const nec_card& c) { - ctx.ne_card(c.i[0], c.i[1], c.i[2], c.i[3], - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_nh(nec_context& ctx, const nec_card& c) { - ctx.nh_card(c.i[0], c.i[1], c.i[2], c.i[3], - c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); -} -void handle_pq(nec_context& ctx, const nec_card& c) { - ctx.pq_card(c.i[0], c.i[1], c.i[2], c.i[3]); -} -void handle_ek(nec_context& ctx, const nec_card& c) { - ctx.set_extended_thin_wire_kernel(c.i[0] != -1); -} -void handle_cp(nec_context& ctx, const nec_card& c) { - ctx.cp_card(c.i[0], c.i[1], c.i[2], c.i[3]); -} -void handle_pl(nec_context& ctx, const nec_card& c) { - /* PL requires filename — not cleanly supported via table dispatch yet. - Falls back to the old switch-based handler in nec_main. */ - (void)ctx; (void)c; -} -void handle_en(nec_context& ctx, const nec_card&) { - ctx.all_jobs_completed(); -} -void handle_wg(nec_context&, const nec_card&) { - throw nec_exception("\"WG\" card, not supported."); -} -void handle_mp(nec_context& ctx, const nec_card& c) { - ctx.medium_parameters(c.f[0], c.f[1]); -} diff --git a/src/nec_deck.cpp b/src/nec_deck.cpp new file mode 100644 index 00000000..88d0e86b --- /dev/null +++ b/src/nec_deck.cpp @@ -0,0 +1,203 @@ +#include "nec_deck.h" + +#include "c_geometry.h" +#include "misc.h" +#include "nec_card_parser.h" +#include "nec_context.h" +#include "nec_exception.h" +#include "nec_output.h" + +#include +#include +#include + +namespace { + +void print_program_header(nec_output_file& output) +{ + output.end_section(); + output.set_indent(31); + output.line(" __________________________________________"); + output.line("| |"); + output.line("| NUMERICAL ELECTROMAGNETICS CODE (nec2++) |"); + output.line("| Implemented in 'C++' in Double Precision |"); + output.line("| Version " nec_version " |"); + output.line("|__________________________________________|"); +} + +void read_comments_or_rewind(std::istream& input, nec_output_file& output) +{ + const std::streampos job_start = input.tellg(); + char mnemonic[3] = {0, 0, 0}; + char line[LINE_LEN + 1] = {0}; + + if ((load_line(line, input) == EOF) && (line[0] == '\0')) + throw nec_exception("Error reading input text."); + + std::strncpy(mnemonic, line, 2); + if ((0 != std::strcmp(mnemonic, "CM")) && + (0 != std::strcmp(mnemonic, "CE"))) { + input.clear(); + input.seekg(job_start); + if (!input) + throw nec_exception("Unable to rewind NEC input text."); + return; + } + + output.end_section(); + output.set_indent(31); + output.line("---------------- COMMENTS ----------------"); + output.line(&line[2]); + + while (0 == std::strcmp(mnemonic, "CM")) { + line[0] = '\0'; + if ((load_line(line, input) == EOF) && (line[0] == '\0')) + throw nec_exception("Error reading input text (comments not terminated?)."); + std::strncpy(mnemonic, line, 2); + mnemonic[2] = '\0'; + output.line(&line[2]); + } + + if (0 != std::strcmp(mnemonic, "CE")) + throw nec_exception("ERROR: INCORRECT LABEL FOR A COMMENT CARD"); +} + +nec_card read_control_card(std::istream& input) +{ + char line[LINE_LEN + 1] = {0}; + const int eof = load_line(line, input); + const size_t length = std::strlen(line); + + if (length < 2) { + if (EOF == eof) { + nec_card end; + end.mnemonic = "EN"; + return end; + } + throw nec_exception( + "COMMAND DATA CARD ERROR: CARD'S MNEMONIC CODE TOO SHORT OR MISSING."); + } + + nec_card card = parse_nec_card(line); + if (card.mnemonic == "XT") + throw nec_exception("XT is not supported by the string API."); + return card; +} + +void print_control_card(nec_output_file& output, int count, const nec_card& card) +{ + output.nec_printf( + "\n***** DATA CARD N0. %3d %s %3d %5d %5d %5d" + " %12.5E %12.5E %12.5E %12.5E %12.5E %12.5E", + count, card.mnemonic.c_str(), + card.i[0], card.i[1], card.i[2], card.i[3], + card.f[0], card.f[1], card.f[2], + card.f[3], card.f[4], card.f[5]); +} + +} // namespace + +void nec_process_deck(const std::string& input_text, + nec_context& context, + nec_output_file& output) +{ + if (input_text.empty()) + throw nec_exception("NEC input text is empty."); + + std::istringstream input(input_text); + nec_output_flags output_flags; + context.set_output(output, output_flags); + context.initialize(); + + while (true) { + print_program_header(output); + read_comments_or_rewind(input, output); + + context.get_geometry()->parse_geometry(&context, input); + context.calc_prepare(); + output.end_section(); + + int data_card_count = 0; + while (true) { + nec_card card = read_control_card(input); + print_control_card(output, ++data_card_count, card); + + if (card.mnemonic == "NX") + break; + if (card.mnemonic == "EN") { + context.all_jobs_completed(); + return; + } + if (card.mnemonic == "PL") + throw nec_exception("PL is not supported by the string API."); + + const card_handler* handler = find_handler(card.mnemonic); + if (nullptr == handler) + throw nec_exception("FAULTY DATA CARD LABEL AFTER GEOMETRY SECTION."); + handler->dispatch(context, card); + } + } +} + +/* Table-driven control-card handlers shared by the CLI and string API. */ +void handle_fr(nec_context& ctx, const nec_card& c) { + ctx.fr_card(c.i[0], c.i[1], c.f[0], c.f[1]); +} +void handle_ld(nec_context& ctx, const nec_card& c) { + ctx.ld_card(c.i[0], c.i[1], c.i[2], c.i[3], c.f[0], c.f[1], c.f[2]); +} +void handle_gn(nec_context& ctx, const nec_card& c) { + ctx.gn_card(c.i[0], c.i[1], c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_ex(nec_context& ctx, const nec_card& c) { + ctx.ex_card(static_cast(c.i[0]), c.i[1], c.i[2], c.i[3], + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_nt(nec_context& ctx, const nec_card& c) { + ctx.nt_card(c.i[0], c.i[1], c.i[2], c.i[3], + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_tl(nec_context& ctx, const nec_card& c) { + ctx.tl_card(c.i[0], c.i[1], c.i[2], c.i[3], + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_xq(nec_context& ctx, const nec_card& c) { ctx.xq_card(c.i[0]); } +void handle_gd(nec_context& ctx, const nec_card& c) { + ctx.gd_card(c.f[0], c.f[1], c.f[2], c.f[3]); +} +void handle_rp(nec_context& ctx, const nec_card& c) { + const int xnda = c.i[3]; + ctx.rp_card(c.i[0], c.i[1], c.i[2], + xnda / 1000, (xnda / 100) % 10, (xnda / 10) % 10, xnda % 10, + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_nx(nec_context&, const nec_card&) {} +void handle_pt(nec_context& ctx, const nec_card& c) { + ctx.pt_card(c.i[0], c.i[1], c.i[2], c.i[3]); +} +void handle_kh(nec_context& ctx, const nec_card& c) { ctx.kh_card(c.f[0]); } +void handle_ne(nec_context& ctx, const nec_card& c) { + ctx.ne_card(c.i[0], c.i[1], c.i[2], c.i[3], + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_nh(nec_context& ctx, const nec_card& c) { + ctx.nh_card(c.i[0], c.i[1], c.i[2], c.i[3], + c.f[0], c.f[1], c.f[2], c.f[3], c.f[4], c.f[5]); +} +void handle_pq(nec_context& ctx, const nec_card& c) { + ctx.pq_card(c.i[0], c.i[1], c.i[2], c.i[3]); +} +void handle_ek(nec_context& ctx, const nec_card& c) { + ctx.set_extended_thin_wire_kernel(c.i[0] != -1); +} +void handle_cp(nec_context& ctx, const nec_card& c) { + ctx.cp_card(c.i[0], c.i[1], c.i[2], c.i[3]); +} +void handle_pl(nec_context&, const nec_card&) {} +void handle_en(nec_context& ctx, const nec_card&) { ctx.all_jobs_completed(); } +void handle_wg(nec_context&, const nec_card&) { + throw nec_exception("\"WG\" card, not supported."); +} +void handle_mp(nec_context& ctx, const nec_card& c) { + ctx.medium_parameters(c.f[0], c.f[1]); +} diff --git a/src/nec_deck.h b/src/nec_deck.h new file mode 100644 index 00000000..285486f6 --- /dev/null +++ b/src/nec_deck.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +class nec_context; +class nec_output_file; + +/* Process one or more complete NEC jobs supplied as text. */ +void nec_process_deck(const std::string& input_text, + nec_context& context, + nec_output_file& output); diff --git a/src/nec_output.cpp b/src/nec_output.cpp index 3699228a..73d43615 100644 --- a/src/nec_output.cpp +++ b/src/nec_output.cpp @@ -17,9 +17,12 @@ */ #include "nec_output.h" #include "nec_exception.h" +#include +#include #include #include #include +#include /* ---------------------------------------------------------------------*/ @@ -117,19 +120,11 @@ void nec_output_file::real(nec_float in_nec_float) void nec_output_file::integer(long in_integer) { - if (NULL == m_output_fp) - return; - - fprintf(m_output_fp,"%ld",in_integer); - if (m_error_mode) - std::cerr << in_integer; + nec_printf("%ld", in_integer); } void nec_output_file::real_out(int w, int p, nec_float f, bool sci) { - if (NULL == m_output_fp) - return; - std::stringstream ss; ss << "%" << w << "." << p; @@ -141,102 +136,26 @@ void nec_output_file::real_out(int w, int p, nec_float f, bool sci) std::string s = ss.str(); const char* fmt = s.c_str(); - fprintf(m_output_fp,fmt,f); - if (m_error_mode) - std::cerr << f; + nec_printf(fmt, f); } - -#include "stdarg.h" -#include "safe_array.h" - void nec_output_file::nec_printf(const char* fmt, ...) { - if (NULL == m_output_fp) + if ((NULL == m_output_fp) && (nullptr == m_output_os)) return; - - { - va_list ap; /* special type for variable */ - - safe_array format(2048); /* argument lists */ - int count = 0; - int i, j; /* Need all these to store */ - char c; /* values below in switch */ - double d; - unsigned u; - char *s; - void *v; - - va_start(ap, fmt); /* must be called before work */ - while (*fmt) - { - for (j = 0; fmt[j] && fmt[j] != '%'; j++) - format[j] = fmt[j]; /* not a format string */ - if (j) - { - format[j] = '\0'; - count += fprintf(m_output_fp, "%s", format.data()); /* log it verbatim */ - fmt += j; - } - else - { - for (j = 0; !isalpha(fmt[j]); j++) - { /* find end of format specifier */ - format[j] = fmt[j]; - if (j && fmt[j] == '%') /* special case printing '%' */ - break; - } - format[j] = fmt[j]; /* finish writing specifier */ - format[j + 1] = '\0'; /* don't forget NULL terminator */ - fmt += j + 1; - - switch (format[j]) - { /* cases for all specifiers */ - case 'd': - case 'i': /* many use identical actions */ - i = va_arg(ap, int); /* process the argument */ - count += fprintf(m_output_fp, format.data(), i); /* and log it */ - break; - case 'o': - case 'x': - case 'X': - case 'u': - u = va_arg(ap, unsigned); - count += fprintf(m_output_fp, format.data(), u); - break; - case 'c': - c = static_cast(va_arg(ap, int)); /* must cast! */ - count += fprintf(m_output_fp, format.data(), c); - break; - case 's': - s = va_arg(ap, char *); - count += fprintf(m_output_fp, format.data(), s); - break; - case 'f': - case 'e': - case 'E': - case 'g': - case 'G': - d = va_arg(ap, double); - count += fprintf(m_output_fp, format.data(), d); - break; - case 'p': - v = va_arg(ap, void *); - count += fprintf(m_output_fp, format.data(), v); - break; - case 'n': - count += fprintf(m_output_fp, "%d", count); - break; - case '%': - count += fprintf(m_output_fp, "%%"); - break; - default: - throw nec_exception("Invalid format specifier in nec_printf()"); - } - } - } - - va_end(ap); /* clean up */ - } + + va_list sizing_args; + va_start(sizing_args, fmt); + const int required = std::vsnprintf(nullptr, 0, fmt, sizing_args); + va_end(sizing_args); + if (required < 0) + throw nec_exception("Unable to format NEC output"); + + std::vector buffer(static_cast(required) + 1); + va_list writing_args; + va_start(writing_args, fmt); + std::vsnprintf(buffer.data(), buffer.size(), fmt, writing_args); + va_end(writing_args); + do_output(buffer.data()); } diff --git a/src/nec_wasm.cpp b/src/nec_wasm.cpp index 9702b22a..43a1f787 100644 --- a/src/nec_wasm.cpp +++ b/src/nec_wasm.cpp @@ -1,108 +1,148 @@ /* - * WASM wrapper for nec2++ — exposes a minimal C API for Emscripten builds. + * String-based C ABI for the reusable Emscripten module. * - * Build with: - * emcc -std=c++17 -O2 -I src -isystem src/eigen -I build/simple \ - * -s WASM=1 -s EXPORTED_FUNCTIONS='[\"_nec_create_context\",\"_nec_delete_context\",\"_nec_process_input\",\"_nec_get_output\",\"_nec_get_output_length\",\"_nec_free\"]' \ - * -s EXPORTED_RUNTIME_METHODS='[\"ccall\",\"cwrap\",\"UTF8ToString\",\"lengthBytesUTF8\"]' \ - * -s ALLOW_MEMORY_GROWTH=1 \ - * -o nec2pp.js src/nec_wasm.cpp ... + * The WASM target intentionally has no CLI main(). JavaScript supplies a + * complete NEC deck to nec_process_input(), then reads the generated report + * with nec_get_output(). Every exported function contains its exceptions so + * no C++ exception can cross the C/WASM boundary. */ #include "nec_context.h" -#include "c_geometry.h" +#include "nec_deck.h" #include "nec_exception.h" +#include "nec_output.h" -#include +#include +#include +#include #include -#include - -extern "C" { +#include -/* Opaque handle for a NEC simulation context. */ struct nec_wasm_context { - nec_context ctx; - std::string output_buffer; - bool initialized = false; - bool has_results = false; + std::unique_ptr context; + std::string output_buffer; + bool has_results = false; }; -nec_wasm_context* nec_create_context(void) +namespace { + +void store_error(nec_wasm_context* context, + const char* prefix, + const char* message) noexcept +{ + if (nullptr == context) + return; + + context->has_results = false; + try { + context->output_buffer.assign(prefix ? prefix : "Error: "); + if (message) + context->output_buffer.append(message); + } catch (...) { + /* An allocation failure while reporting an error must not escape ABI. */ + try { + context->output_buffer.clear(); + } catch (...) { + } + } +} + +} // namespace + +extern "C" { + +nec_wasm_context* nec_create_context(void) noexcept { - return new nec_wasm_context(); + try { + return new nec_wasm_context(); + } catch (...) { + return nullptr; + } } -void nec_delete_context(nec_wasm_context* c) +void nec_delete_context(nec_wasm_context* context) noexcept { - delete c; + try { + delete context; + } catch (...) { + /* C++ destructors are not allowed to escape the C ABI. */ + } } /* - * Process a complete NEC input file (as a C string). + * Process a complete NEC input deck supplied as a UTF-8 C string. * Returns 0 on success, or a negative error code: * -1 : null context or input * -2 : parse/execution error (message stored in output) */ -int nec_process_input(nec_wasm_context* c, const char* input_text) +int nec_process_input(nec_wasm_context* context, const char* input_text) noexcept { - if (!c || !input_text) - return -1; - - try { - std::istringstream input(input_text); - std::ostringstream output; - - nec_output_file s_output; - s_output.set_stream(output); - nec_output_flags s_output_flags; - - c->ctx.set_output(s_output, s_output_flags); - c->ctx.initialize(); - - /* Parse geometry from the stream. */ - /* TODO: Wire up stream-based geometry parsing when available. */ - /* For now, this is a stub showing the API shape. */ - c->ctx.get_geometry()->parse_geometry(&c->ctx, stdin); - - c->ctx.calc_prepare(); - - c->output_buffer = output.str(); - c->has_results = true; - return 0; - - } catch (const nec_exception& e) { - c->output_buffer = std::string("Error: ") + e.get_message(); - return -2; - } catch (const std::exception& e) { - c->output_buffer = std::string("Error: ") + e.what(); - return -2; - } catch (...) { - c->output_buffer = "Unknown error"; - return -2; - } + if ((nullptr == context) || (nullptr == input_text)) + return -1; + + try { + context->has_results = false; + context->output_buffer.clear(); + + /* Each call gets a fresh solver so a failed deck cannot poison the next. */ + std::unique_ptr solver(new nec_context()); + std::ostringstream report; + nec_output_file output; + output.set_stream(report); + + nec_process_deck(input_text, *solver, output); + + context->output_buffer = report.str(); + context->context = std::move(solver); + context->has_results = true; + return 0; + } catch (const nec_exception& error) { + try { + const std::string message = error.get_message(); + store_error(context, "Error: ", message.c_str()); + } catch (...) { + store_error(context, "Error: ", "NEC++ exception"); + } + return -2; + } catch (const std::exception& error) { + store_error(context, "Error: ", error.what()); + return -2; + } catch (const char* error) { + store_error(context, "Error: ", error); + return -2; + } catch (...) { + store_error(context, "Error: ", "Unknown exception"); + return -2; + } } -/* Returns the output buffer (caller must NOT free). */ -const char* nec_get_output(nec_wasm_context* c) +/* Returns the output buffer; the caller must not free it. */ +const char* nec_get_output(nec_wasm_context* context) noexcept { - if (!c) - return ""; - return c->output_buffer.c_str(); + try { + return context ? context->output_buffer.c_str() : ""; + } catch (...) { + return ""; + } } -/* Length of the output string, for JS interop. */ -int nec_get_output_length(nec_wasm_context* c) +int nec_get_output_length(nec_wasm_context* context) noexcept { - if (!c) - return 0; - return static_cast(c->output_buffer.size()); + try { + if (!context) + return 0; + const size_t length = context->output_buffer.size(); + return length > static_cast(INT_MAX) + ? INT_MAX + : static_cast(length); + } catch (...) { + return 0; + } } -/* Free a string returned by the API (for future use). */ -void nec_free(void* ptr) +/* Reserved for a future API returning caller-owned allocations. */ +void nec_free(void*) noexcept { - /* Currently unused — strings are owned by nec_wasm_context. */ - (void)ptr; } } /* extern "C" */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2f821508..d127ecd6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,7 @@ add_executable(nec2++_tests ${CMAKE_SOURCE_DIR}/src/libNEC.cpp ${CMAKE_SOURCE_DIR}/src/matrix_algebra.cpp ${CMAKE_SOURCE_DIR}/src/misc.cpp + ${CMAKE_SOURCE_DIR}/src/nec_deck.cpp ${CMAKE_SOURCE_DIR}/src/nec_context.cpp ${CMAKE_SOURCE_DIR}/src/nec_exception.cpp ${CMAKE_SOURCE_DIR}/src/nec_ground.cpp @@ -99,4 +100,4 @@ add_test(NAME necpp_smoke_hertzian_dipole COMMAND ${CMAKE_COMMAND} -DNEC2PP=$ -DINPUT=${CMAKE_SOURCE_DIR}/testharness/data/herzian_dipole.nec - -P ${CMAKE_CURRENT_SOURCE_DIR}/smoke_test.cmake) \ No newline at end of file + -P ${CMAKE_CURRENT_SOURCE_DIR}/smoke_test.cmake) diff --git a/wasm/nec2pp.d.ts b/wasm/nec2pp.d.ts new file mode 100644 index 00000000..9591f61e --- /dev/null +++ b/wasm/nec2pp.d.ts @@ -0,0 +1,44 @@ +// TypeScript bindings for emscripten-generated code. Automatically generated at compile time. +declare namespace RuntimeExports { + /** + * @param {string|null=} returnType + * @param {Array=} argTypes + * @param {Arguments|Array=} args + * @param {Object=} opts + */ + function ccall(ident: any, returnType?: (string | null) | undefined, argTypes?: any[] | undefined, args?: (Arguments | any[]) | undefined, opts?: any | undefined): any; + /** + * @param {string=} returnType + * @param {Array=} argTypes + * @param {Object=} opts + */ + function cwrap(ident: any, returnType?: string | undefined, argTypes?: any[] | undefined, opts?: any | undefined): any; + /** + * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the + * emscripten HEAP, returns a copy of that string as a Javascript String object. + * + * @param {number} ptr + * @param {number=} maxBytesToRead - An optional length that specifies the + * maximum number of bytes to read. You can omit this parameter to scan the + * string until the first 0 byte. If maxBytesToRead is passed, and the string + * at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the + * string will cut short at that byte index (i.e. maxBytesToRead will not + * produce a string of exact length [ptr, ptr+maxBytesToRead[) N.B. mixing + * frequent uses of UTF8ToString() with and without maxBytesToRead may throw + * JS JIT optimizations off, so it is worth to consider consistently using one + * @return {string} + */ + function UTF8ToString(ptr: number, maxBytesToRead?: number | undefined): string; + function lengthBytesUTF8(str: any): number; +} +interface WasmModule { + _nec_create_context(): number; + _nec_delete_context(_0: number): void; + _nec_process_input(_0: number, _1: number): number; + _nec_get_output(_0: number): number; + _nec_get_output_length(_0: number): number; + _nec_free(_0: number): void; +} + +export type MainModule = WasmModule & typeof RuntimeExports; +export default function MainModuleFactory (options?: unknown): Promise; From 8a79253d45c0fff721b09432a766318051d20d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 09:08:33 +0200 Subject: [PATCH 14/46] Prepare local build environment --- .gitattributes | 2 ++ .github/workflows/build.yml | 21 +++++------ .gitignore | 6 ++-- INSTALL.md | 15 ++++++-- scripts/build_wasm_docker.ps1 | 41 ++++++++++++++++++++++ scripts/build_wasm_docker.sh | 61 +++----------------------------- scripts/build_wasm_inner.sh | 65 +++++++++++++++++++++++++++++++++++ wasm/.gitkeep | 0 wasm/nec2pp.d.ts | 44 ------------------------ 9 files changed, 137 insertions(+), 118 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/build_wasm_docker.ps1 create mode 100644 scripts/build_wasm_inner.sh create mode 100644 wasm/.gitkeep delete mode 100644 wasm/nec2pp.d.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..80e7cdf9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.mjs text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3babd598..907f0fb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,27 +31,24 @@ jobs: - name: Prepare package artifacts shell: bash run: | - test -s nec2pp.js - test -s nec2pp.wasm - test -s nec2pp.d.ts - - mkdir -p wasm-dist - cp nec2pp.js nec2pp.wasm nec2pp.d.ts wasm-dist/ + test -s wasm/nec2pp.js + test -s wasm/nec2pp.wasm + test -s wasm/nec2pp.d.ts sha256sum \ - wasm-dist/nec2pp.js \ - wasm-dist/nec2pp.wasm \ - wasm-dist/nec2pp.d.ts \ - > wasm-dist/SHA256SUMS + wasm/nec2pp.js \ + wasm/nec2pp.wasm \ + wasm/nec2pp.d.ts \ + > wasm/SHA256SUMS echo "Generated artifacts:" - ls -lh wasm-dist + ls -lh wasm - name: Upload WASM package uses: actions/upload-artifact@v4 with: name: necpp-wasm - path: wasm-dist/ + path: wasm/ if-no-files-found: error retention-days: 30 compression-level: 6 diff --git a/.gitignore b/.gitignore index 4019d7d1..c889aaf3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,10 @@ # spots so neither tree shows stray binaries in git status. /src/nec2++ /src/nec2diff -nec2pp.js -nec2pp.wasm +/wasm/nec2pp.js +/wasm/nec2pp.wasm +/wasm/nec2pp.d.ts +/wasm/SHA256SUMS # Legacy test binary name (pre-CMake era); kept ignored in case of stale trees. src/necpp_test src/test_manager diff --git a/INSTALL.md b/INSTALL.md index 373e0ad4..534e9130 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -88,19 +88,28 @@ Two ways: emcmake cmake -B build-wasm -DNECPP_BUILD_WASM=ON -DNECPP_BUILD_TESTS=OFF cmake --build build-wasm -j4 + mkdir -p wasm + cp build-wasm/src/nec2pp.js build-wasm/src/nec2pp.wasm build-wasm/src/nec2pp.d.ts wasm/ **Docker wrapper** (no local emsdk needed): - ./scripts/build_wasm_docker.sh + ./scripts/build_wasm_docker.sh # Linux / macOS / Git Bash + .\scripts\build_wasm_docker.ps1 # Windows PowerShell (Docker Desktop) -Both produce `nec2pp.js` + `nec2pp.wasm` exposing a C API +On Windows, run the PowerShell script from the repo root. Docker Desktop must +be running; WSL2 is used by Docker Desktop but you do not need to invoke the +build from inside WSL. If you prefer WSL, enable *Settings → Resources → WSL +Integration* for your distro so `docker` works inside Ubuntu, then use the +`.sh` script from there. + +Both produce `wasm/nec2pp.js` + `wasm/nec2pp.wasm` exposing a C API (`nec_create_context`, `nec_process_input`, `nec_get_output`, …). The module is an ES module factory. Runtime helpers and C exports are available on the initialized module object: ```js -import createNecModule from "./nec2pp.js"; +import createNecModule from "./wasm/nec2pp.js"; const module = await createNecModule(); const context = module._nec_create_context(); diff --git a/scripts/build_wasm_docker.ps1 b/scripts/build_wasm_docker.ps1 new file mode 100644 index 00000000..e49d1f4c --- /dev/null +++ b/scripts/build_wasm_docker.ps1 @@ -0,0 +1,41 @@ +# Build the reusable NEC++ WebAssembly module inside the Emscripten Docker image. +# +# Produces in wasm/: +# nec2pp.js +# nec2pp.wasm +# nec2pp.d.ts +# +# Usage (from repo root or scripts/): +# .\scripts\build_wasm_docker.ps1 + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..") +$BuildDir = "build-wasm" +$WasmImage = "emscripten/emsdk:4.0.7" + +Set-Location $ProjectDir + +$WasmOutDir = Join-Path $ProjectDir "wasm" +New-Item -ItemType Directory -Force -Path $WasmOutDir | Out-Null + +Remove-Item -ErrorAction SilentlyContinue ` + (Join-Path $WasmOutDir "nec2pp.js"), ` + (Join-Path $WasmOutDir "nec2pp.wasm"), ` + (Join-Path $WasmOutDir "nec2pp.d.ts") + +Write-Host "=== Building WASM via Emscripten Docker image: $WasmImage ===" + +docker run --rm ` + -e "BUILD_DIR=$BuildDir" ` + -v "${ProjectDir}:/src" ` + -w /src ` + $WasmImage ` + bash scripts/build_wasm_inner.sh + +if ($LASTEXITCODE -ne 0) { + throw "Docker build failed with exit code $LASTEXITCODE" +} + +Write-Host "=== WASM build complete ===" diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 49049a6b..4992f3ab 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build the reusable NEC++ WebAssembly module inside the Emscripten Docker image. # -# Produces in the repository root: +# Produces in wasm/: # nec2pp.js # nec2pp.wasm # nec2pp.d.ts @@ -15,7 +15,8 @@ WASM_IMAGE="emscripten/emsdk:4.0.7" cd "$PROJECT_DIR" -rm -f nec2pp.js nec2pp.wasm nec2pp.d.ts +rm -f wasm/nec2pp.js wasm/nec2pp.wasm wasm/nec2pp.d.ts +mkdir -p wasm echo "=== Building WASM via Emscripten Docker image: $WASM_IMAGE ===" @@ -25,60 +26,6 @@ docker run --rm \ -v "$PROJECT_DIR:/src" \ -w /src \ "$WASM_IMAGE" \ - bash -c ' - set -euo pipefail - - TS_TOOLS_DIR="/tmp/emscripten-ts-tools" - export npm_config_cache="/tmp/npm-cache" - - npm install \ - --prefix "$TS_TOOLS_DIR" \ - --no-save \ - --no-package-lock \ - typescript@5.8.3 - - export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" - - tsc --version - - rm -rf "$BUILD_DIR" - - CXX_FLAGS="-O3 -DNDEBUG -flto -fexceptions" - LINK_FLAGS="-O3 -flto \ --sMODULARIZE=1 \ --sEXPORT_ES6=1 \ --sEXPORT_NAME=createNecModule \ --sENVIRONMENT=web,worker,node \ --sINVOKE_RUN=0 \ --sEXIT_RUNTIME=0 \ --sALLOW_MEMORY_GROWTH=1 \ --sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,lengthBytesUTF8 \ --sDISABLE_EXCEPTION_CATCHING=0 \ ---emit-tsd nec2pp.d.ts" - - emcmake cmake -B "$BUILD_DIR" -S . \ - -DCMAKE_BUILD_TYPE=Release \ - -DNECPP_BUILD_WASM=ON \ - -DNECPP_BUILD_TESTS=OFF \ - -DBUILD_SHARED_LIBS=OFF \ - "-DCMAKE_CXX_FLAGS_RELEASE=$CXX_FLAGS" \ - "-DCMAKE_EXE_LINKER_FLAGS_RELEASE=$LINK_FLAGS" - - cmake --build "$BUILD_DIR" --config Release -j"$(nproc)" - - test -s "$BUILD_DIR/src/nec2pp.js" - test -s "$BUILD_DIR/src/nec2pp.wasm" - test -s "$BUILD_DIR/src/nec2pp.d.ts" - - node --experimental-default-type=module \ - scripts/wasm_smoke_test.mjs \ - "$BUILD_DIR/src/nec2pp.js" - - cp \ - "$BUILD_DIR/src/nec2pp.js" \ - "$BUILD_DIR/src/nec2pp.wasm" \ - "$BUILD_DIR/src/nec2pp.d.ts" \ - . - ' + bash scripts/build_wasm_inner.sh echo "=== WASM build complete ===" diff --git a/scripts/build_wasm_inner.sh b/scripts/build_wasm_inner.sh new file mode 100644 index 00000000..b2929121 --- /dev/null +++ b/scripts/build_wasm_inner.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Container-side WASM build steps (invoked by build_wasm_docker.sh / .ps1). + +set -euo pipefail + +: "${BUILD_DIR:=build-wasm}" +: "${WASM_OUT_DIR:=wasm}" + +# Build on the container filesystem. Windows bind mounts (/mnt/c/...) reject +# writes from a non-root container user, which breaks emscripten link steps. +CONTAINER_BUILD_DIR="/tmp/necpp-${BUILD_DIR}" + +TS_TOOLS_DIR="/tmp/emscripten-ts-tools" +export npm_config_cache="/tmp/npm-cache" + +npm install \ + --prefix "$TS_TOOLS_DIR" \ + --no-save \ + --no-package-lock \ + typescript@5.8.3 + +export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" + +tsc --version + +rm -rf "$CONTAINER_BUILD_DIR" + +CXX_FLAGS="-O3 -DNDEBUG -flto -fexceptions" +LINK_FLAGS="-O3 -flto \ +-sMODULARIZE=1 \ +-sEXPORT_ES6=1 \ +-sEXPORT_NAME=createNecModule \ +-sENVIRONMENT=web,worker,node \ +-sINVOKE_RUN=0 \ +-sEXIT_RUNTIME=0 \ +-sALLOW_MEMORY_GROWTH=1 \ +-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,lengthBytesUTF8 \ +-sDISABLE_EXCEPTION_CATCHING=0 \ +--emit-tsd nec2pp.d.ts" + +emcmake cmake -B "$CONTAINER_BUILD_DIR" -S . \ + -DCMAKE_BUILD_TYPE=Release \ + -DNECPP_BUILD_WASM=ON \ + -DNECPP_BUILD_TESTS=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + "-DCMAKE_CXX_FLAGS_RELEASE=$CXX_FLAGS" \ + "-DCMAKE_EXE_LINKER_FLAGS_RELEASE=$LINK_FLAGS" + +cmake --build "$CONTAINER_BUILD_DIR" --config Release -j"$(nproc)" + +test -s "$CONTAINER_BUILD_DIR/src/nec2pp.js" +test -s "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" +test -s "$CONTAINER_BUILD_DIR/src/nec2pp.d.ts" + +node --experimental-default-type=module \ + scripts/wasm_smoke_test.mjs \ + "$CONTAINER_BUILD_DIR/src/nec2pp.js" + +mkdir -p "$WASM_OUT_DIR" + +cp \ + "$CONTAINER_BUILD_DIR/src/nec2pp.js" \ + "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" \ + "$CONTAINER_BUILD_DIR/src/nec2pp.d.ts" \ + "$WASM_OUT_DIR/" diff --git a/wasm/.gitkeep b/wasm/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/wasm/nec2pp.d.ts b/wasm/nec2pp.d.ts deleted file mode 100644 index 9591f61e..00000000 --- a/wasm/nec2pp.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -// TypeScript bindings for emscripten-generated code. Automatically generated at compile time. -declare namespace RuntimeExports { - /** - * @param {string|null=} returnType - * @param {Array=} argTypes - * @param {Arguments|Array=} args - * @param {Object=} opts - */ - function ccall(ident: any, returnType?: (string | null) | undefined, argTypes?: any[] | undefined, args?: (Arguments | any[]) | undefined, opts?: any | undefined): any; - /** - * @param {string=} returnType - * @param {Array=} argTypes - * @param {Object=} opts - */ - function cwrap(ident: any, returnType?: string | undefined, argTypes?: any[] | undefined, opts?: any | undefined): any; - /** - * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the - * emscripten HEAP, returns a copy of that string as a Javascript String object. - * - * @param {number} ptr - * @param {number=} maxBytesToRead - An optional length that specifies the - * maximum number of bytes to read. You can omit this parameter to scan the - * string until the first 0 byte. If maxBytesToRead is passed, and the string - * at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the - * string will cut short at that byte index (i.e. maxBytesToRead will not - * produce a string of exact length [ptr, ptr+maxBytesToRead[) N.B. mixing - * frequent uses of UTF8ToString() with and without maxBytesToRead may throw - * JS JIT optimizations off, so it is worth to consider consistently using one - * @return {string} - */ - function UTF8ToString(ptr: number, maxBytesToRead?: number | undefined): string; - function lengthBytesUTF8(str: any): number; -} -interface WasmModule { - _nec_create_context(): number; - _nec_delete_context(_0: number): void; - _nec_process_input(_0: number, _1: number): number; - _nec_get_output(_0: number): number; - _nec_get_output_length(_0: number): number; - _nec_free(_0: number): void; -} - -export type MainModule = WasmModule & typeof RuntimeExports; -export default function MainModuleFactory (options?: unknown): Promise; From 26928e81521602487b8116d87861247978fc67c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 09:38:36 +0200 Subject: [PATCH 15/46] Planning --- docs/ts_engine_plan.md | 605 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/ts_engine_plan.md diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md new file mode 100644 index 00000000..6376aa07 --- /dev/null +++ b/docs/ts_engine_plan.md @@ -0,0 +1,605 @@ +The target should be a versioned, stateful npm package—provisionally `@necpp/wasm`—with a high-level TypeScript API. Consumers should never touch raw WASM pointers, copy artifacts manually, parse NEC reports, or understand Emscripten. + +A successful consumer experience would look like: + +```ts +import { createNecModel } from "@necpp/wasm"; + +const model = await createNecModel(); + +try { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + + model.completeGeometry(); + + model.definePorts([ + { tag: 1, segment: 6 }, + ]); + + model.prepare({ frequencyMHz: 300 }); + + const impedance = model.computeImpedanceMatrix(); + + const solution = model.solveCurrents({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + + const field = model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 181, stepDeg: 1 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + }); +} finally { + model.dispose(); +} +``` + +## Public numerical contract + +This contract should be fixed before implementation: + +- Phasor convention: \(e^{+j\omega t}\), outgoing propagation \(e^{-jkR}\). +- Geometry and distance: metres. +- Frequency: MHz at the public API, converted internally. +- Port voltage: complex volts. +- Port current: complex amperes, positive into the modeled antenna. +- Impedance: complex ohms. +- Far fields: complex V/m. +- Far-field radius: defaults to 1 m but is always retained in returned metadata. +- θ: polar angle from +Z. +- φ: azimuth from +X toward +Y. +- Far-field indexing: `index = phiIndex * thetaCount + thetaIndex`. +- Matrix indexing: documented row-major order, `row * columnCount + column`. +- Port equation: + +\[ +\mathbf V = \mathbf Z\mathbf I,\qquad +\mathbf I = \mathbf Y\mathbf V +\] + +- RP results are far-field approximations even when `radiusM` is small. +- Fields are referenced to the model coordinate origin. +- Returned typed arrays are JS-owned copies and remain valid after subsequent solves or WASM memory growth. + +The NEC execution model supports reuse of the expensive interaction-matrix factorization when only excitation changes. That behavior is described in the [NEC-2 control-card documentation](https://www.nec2.org/part_3/control.html). RP’s radius and \(e^{-jkR}/R\) convention come from the [RP card definition](https://www.nec2.org/part_3/cards/rp.html). + +# Work packages + +## WP0 — API and numerical specification + +Deliverables: + +- `docs/wasm-api.md` defining lifecycle, units, coordinate systems, phase convention and array layouts. +- Public TypeScript interfaces committed before implementation. +- Error taxonomy and state-transition table. +- Three canonical test models: + - Center-fed dipole. + - Two parallel coupled dipoles. + - Four-element linear array. +- Tolerance policy for numerical tests. +- Decision on final npm name. + +Recommended states: + +```text +empty → geometry-building → geometry-complete → prepared → solved → disposed +``` + +Rules: + +- Geometry can only change before `completeGeometry()`. +- Frequency, ground, loads or geometry changes invalidate factorization. +- Changing excitation or far-field sampling does not invalidate factorization. +- `prepare()` with an unchanged configuration is idempotent. +- Operations after `dispose()` throw a typed JS error. + +Intermediate tests: + +- TypeScript contract compiles with `strict: true`. +- State-transition table has a test for every legal and illegal transition. +- Canonical models run through the existing native API before refactoring. + +DoD: + +- Every public method has defined inputs, outputs, units, ownership and failure behavior. +- Port-current direction and field phase have executable tests, not only prose. +- No unresolved numerical convention remains. + +--- + +## WP1 — Stateful native solver layer + +Introduce an explicit stateful C++ layer above `nec_context`. It should not depend on parsing or formatting a NEC deck. + +Capabilities: + +- Programmatic geometry construction. +- Port registration by tag/segment. +- Frequency and environment configuration. +- Explicit preparation/factorization. +- Repeated excitation solves against a retained factorization. +- Direct access to registered-port currents. +- Replacement of per-solve results rather than indefinite accumulation. +- Factorization-generation counter for deterministic cache tests. + +The existing state machine in [nec_context.cpp](C:/Users/andre/VSCode_Projects/necpp/src/nec_context.cpp:1031) already separates memory allocation, structure loading and excitation. The work is to make that lifecycle explicit and safe instead of relying on `iflow` card sequencing. + +Also audit mutable global state. In particular, multiple models must either be genuinely isolated or the package must instantiate separate Emscripten modules/workers where global NEC settings could interfere. + +Intermediate tests: + +- Existing native Catch2 and regression tests remain green. +- Two successive excitations at one frequency increment the solve count but not the factorization count. +- Changing frequency increments the factorization count. +- Changing only the far-field grid does not refactor. +- Invalid or duplicate ports fail cleanly. +- Interleaved operations on two contexts do not contaminate one another. +- Repeated solves do not grow the native result collection. + +DoD: + +- Geometry is constructed and solved without generating a NEC text deck. +- One prepared model supports at least 1,000 repeated excitation solves without unbounded memory growth. +- Cache invalidation is covered by deterministic unit tests. +- The legacy string/deck API remains operational. + +--- + +## WP2 — Multi-port solving and impedance matrices + +Add a port-oriented numerical engine: + +```cpp +prepare(frequency) +solve_port_voltages(V) +compute_admittance_matrix() +compute_impedance_matrix() +solve_port_currents(I) +``` + +Matrix extraction: + +1. Factor the interaction matrix once. +2. Apply a unit voltage to port \(j\). +3. Sample current at every registered port. +4. Store the result as column \(j\) of \(\mathbf Y\). +5. Repeat for all ports. +6. Invert \(\mathbf Y\) to obtain \(\mathbf Z\). + +The implementation should bypass the current `ex_card()` behavior that changes an exactly zero source voltage to \(1+0j\) at [nec_context.cpp](C:/Users/andre/VSCode_Projects/necpp/src/nec_context.cpp:588). + +Return both matrices when requested: + +```ts +interface ComplexMatrix { + readonly rows: number; + readonly columns: number; + readonly order: "row-major"; + readonly real: Float64Array; + readonly imag: Float64Array; +} + +interface ImpedanceResult { + readonly impedance: ComplexMatrix; + readonly admittance: ComplexMatrix; + readonly conditionEstimate?: number; + readonly frequencyMHz: number; +} +``` + +For requested currents, calculate: + +\[ +\mathbf V=\mathbf Z\mathbf I +\] + +and execute one simultaneous voltage-source solve. Return requested and achieved currents, required voltages, active impedances and powers. + +Intermediate tests: + +- One-port matrix agrees with `nec_impedance_real/imag`. +- Two-port reciprocal geometry satisfies \(Z_{12}\approx Z_{21}\). +- \(\mathbf Z\mathbf Y\approx\mathbf I\). +- For random complex \(\mathbf V\), direct NEC currents agree with \(\mathbf Y\mathbf V\). +- For random complex \(\mathbf I\), achieved currents agree with requested currents after applying \(\mathbf Z\mathbf I\). +- Multi-source active impedance \(V_i/I_i\) changes when array weights change. +- Singular or badly conditioned matrices return a controlled diagnostic. + +Suggested normal tolerance: + +\[ +\frac{\lVert a-b\rVert}{\max(1,\lVert a\rVert,\lVert b\rVert)} +\le 10^{-7} +\] + +Use fixture-specific looser tolerances for independent NEC-2 golden values. + +DoD: + +- Full complex \(N\times N\) Z and Y matrices are available without parsing text. +- Matrix computation performs one factorization per model/frequency. +- Arbitrary simultaneous complex voltage and current excitations are supported. +- All returned port quantities have stable ordering matching `definePorts()`. + +--- + +## WP3 — Complex far-field API + +Expose bulk far-field results from the most recent current solution: + +```ts +interface FarFieldResult { + readonly radiusM: number; + readonly frequencyMHz: number; + + readonly thetaDeg: Float64Array; + readonly phiDeg: Float64Array; + + readonly eThetaReal: Float64Array; + readonly eThetaImag: Float64Array; + readonly ePhiReal: Float64Array; + readonly ePhiImag: Float64Array; +} +``` + +Use the existing complex arrays in [nec_radiation_pattern.h](C:/Users/andre/VSCode_Projects/necpp/src/nec_radiation_pattern.h:138). Gain and polarization can be added later or derived from complex fields, but the raw fields must remain the primary API. + +Add two calculation paths: + +- `computeFarField()`: field for the latest combined excitation. +- `computeEmbeddedFarFields()`: one field basis per port, with a clearly defined voltage or current normalization. + +Embedded fields allow instant JS-side beamforming: + +\[ +E_\theta=\sum_n w_n E_{\theta,n},\qquad +E_\phi=\sum_n w_n E_{\phi,n} +\] + +Intermediate tests: + +- At 1 m and 2 m, magnitude changes by exactly the expected \(1/R\) factor. +- Phase difference follows \(-k\Delta R\), modulo \(2\pi\). +- Direct combined NEC field agrees with the complex sum of basis fields. +- Zero excitation produces zero fields without NaNs. +- θ/φ array ordering agrees with `get_index()`. +- A symmetric dipole has the expected pattern nulls and symmetry. +- Native and WASM far-field arrays agree within tolerance. + +DoD: + +- Complex \(E_\theta\) and \(E_\phi\) are available at 1 m in V/m. +- Direct combined fields and superposed basis fields agree numerically. +- Returned data contains enough metadata to interpret every sample without external assumptions. +- No formatted NEC report parsing occurs. + +--- + +## WP4 — Stable C/WASM ABI + +Do not expose C++ classes through Embind. Add a small versioned C ABI with opaque handles, for example: + +```c +necpp_model_t* necpp_wasm_v1_model_create(void); +void necpp_wasm_v1_model_delete(necpp_model_t*); + +int necpp_wasm_v1_add_wire(...); +int necpp_wasm_v1_define_ports(...); +int necpp_wasm_v1_prepare(...); +int necpp_wasm_v1_solve_voltages(...); +int necpp_wasm_v1_compute_impedance(...); +int necpp_wasm_v1_compute_far_field(...); + +const char* necpp_wasm_v1_last_error(necpp_model_t*); +``` + +Boundary rules: + +- No exception crosses the ABI. +- Every mutating/calculation function returns a status code. +- Every context retains its own last-error string. +- Inputs use pointer-plus-length arrays. +- Bulk outputs use model-owned contiguous buffers. +- The TypeScript wrapper immediately copies borrowed buffers into JS-owned arrays. +- Export `_malloc`, `_free` and the required typed heap views internally. +- Include ABI and engine-version getters. + +Intermediate tests: + +- Native C test exercises every ABI function. +- Null pointers, wrong dimensions and illegal state calls return controlled errors. +- Invalid geometry does not trap the WASM runtime. +- Delete is safe after partial initialization. +- Repeated create/prepare/solve/delete cycles do not leak. +- WASM memory growth does not invalidate already returned JS results. + +DoD: + +- The generated Emscripten module exposes only the documented ABI and necessary runtime memory helpers. +- The public JS layer contains no C++ ownership concepts. +- ABI versioning allows future additions without silently changing existing signatures. + +--- + +## WP5 — TypeScript facade + +Create a handwritten TypeScript layer that is the actual package API. + +Responsibilities: + +- Initialize Emscripten asynchronously. +- Locate `nec2pp.wasm`. +- Allocate and copy input arrays. +- Copy result buffers out of WASM. +- Enforce state and array dimensions. +- Convert native status codes into typed errors. +- Hide handles, pointers, `ccall`, `cwrap` and heaps. +- Provide deterministic `dispose()`. +- Retain `runDeck(deck)` as a compatibility escape hatch. + +Suggested exports: + +```ts +createNecModel() +NecModel +NecError +NecGeometryError +NecSolverError +NecStateError +ComplexVector +ComplexMatrix +PortDefinition +PortSolution +FarFieldRequest +FarFieldResult +``` + +The current modular ES factory is a sound foundation: Emscripten documents that `MODULARIZE` produces an asynchronous factory and allows isolated module instances. [Emscripten modularized output](https://emscripten.org/docs/compiling/Modularized-Output.html) + +Intermediate tests: + +- Strict TypeScript compile. +- Public API type tests with both valid and intentionally invalid examples. +- Node ESM runtime tests. +- Consumer never imports generated Emscripten types. +- Returned arrays remain valid after the model is solved again. +- Double-disposal is harmless or produces one documented result. +- Operations after disposal fail predictably. + +DoD: + +- The normal consumer never sees the generated `MainModule`. +- Quick-start code contains no filesystem paths, `locateFile`, raw memory or glue-module calls. +- Node and browser expose the same public types and numerical behavior. + +--- + +## WP6 — Web Worker entry point + +Browser solves are synchronous and potentially expensive, so include an optional worker facade: + +```ts +import { createNecWorkerModel } from "@necpp/wasm/worker"; + +const model = await createNecWorkerModel(); +``` + +Worker requirements: + +- Preserve state across requests. +- Transfer large result `ArrayBuffer`s instead of cloning. +- Serialize operations per model. +- Report progress at coarse operation boundaries. +- Support termination as the cancellation mechanism. +- Keep the direct non-worker entry point for Node, tests and small models. + +Intermediate tests: + +- A browser heartbeat continues while a worker calculation runs. +- Z matrices and fields match direct-mode results. +- Large arrays are transferred, not duplicated. +- Termination releases the worker and rejects outstanding operations. +- Two worker models run independently. + +DoD: + +- A browser application can perform realistic solves without blocking its UI thread. +- Worker setup requires only importing the documented subpath. +- No consumer-authored worker bootstrap file is needed. + +--- + +## WP7 — npm package assembly + +Recommended layout: + +```text +packages/necpp-wasm/ + package.json + README.md + COPYING + src/ + index.ts + model.ts + types.ts + worker-client.ts + worker-entry.ts + dist/ + index.js + index.d.ts + worker.js + worker.d.ts + nec2pp.generated.js + nec2pp.wasm +``` + +`package.json` should include: + +```json +{ + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "import": "./dist/worker.js" + } + }, + "files": [ + "dist", + "README.md", + "COPYING" + ], + "license": "GPL-2.0-or-later" +} +``` + +npm recommends `"type": "module"` for ESM packages, while `"exports"` defines and encapsulates the supported entry points. [npm package metadata](https://docs.npmjs.com/files/package.json/) + +The wrapper should resolve the WASM using: + +```ts +new URL("./nec2pp.wasm", import.meta.url) +``` + +and still accept optional `wasmUrl` or `wasmBinary` overrides. Emscripten’s supported relocation hook is `locateFile`. [Emscripten Module API](https://emscripten.org/docs/api_reference/module) + +Package testing must use the packed tarball: + +```text +npm pack +→ install generated .tgz in clean fixture +→ import package by name +→ execute actual solve +``` + +Never let package tests accidentally import workspace source files. + +Intermediate tests: + +- `npm pack --dry-run` contains only intended files. +- Clean Node fixture imports the tarball and runs a dipole. +- Clean Vite fixture builds and serves it. +- Browser fixture loads the `.wasm` with the correct URL and MIME type. +- Worker subpath works after bundling. +- Custom `wasmUrl` works for CDN deployments. +- No dependency on the original repository directory exists. + +DoD: + +- Another repository can run `npm install ` followed by a normal ESM import. +- No artifact copying or bundler-specific source changes are necessary. +- Package version, engine version and ABI version are exposed and documented. +- `COPYING` and license metadata are included. Because the engine is GPL-2.0-or-later, downstream distribution implications must be clearly documented and reviewed for the intended product. + +--- + +## WP8 — CI and release pipeline + +Extend the existing WASM workflow in [.github/workflows/build.yml](C:/Users/andre/VSCode_Projects/necpp/.github/workflows/build.yml:1). + +Required CI jobs: + +1. Native build and Catch2 tests. +2. Native port/matrix/far-field tests. +3. Reproducible Emscripten build using the pinned SDK. +4. Node WASM ABI tests. +5. TypeScript facade tests. +6. `npm pack` clean-consumer test. +7. Browser direct-mode integration test. +8. Browser worker integration test. +9. Artifact size and checksum reporting. + +Keep Emscripten and TypeScript versions pinned. Upgrade them deliberately in isolated changes. Emscripten’s `--emit-tsd` may continue producing internal glue typings, but the handwritten package types remain authoritative. [Emscripten compiler documentation](https://emscripten.org/docs/tools_reference/emcc.html) + +Initial accidental-debug-build guards can be generous: + +- WASM binary under 1 MiB. +- Generated loader under 200 KiB. +- No source maps or debug symbols in release package unless intentionally published. + +Release flow: + +- Tag-driven release. +- Run the complete CI matrix. +- Build package once. +- Publish the same tested tarball to npm. +- Attach tarball and checksums to the GitHub release. +- Use semantic versioning for the public TypeScript API. +- Keep engine, package and ABI versions synchronized or explicitly map them. + +DoD: + +- Published bytes are the same bytes tested in the clean consumer jobs. +- Failed numerical, browser, packaging or licensing checks prevent publication. +- A release can be reproduced from a tagged checkout and pinned toolchain. + +--- + +## WP9 — Documentation and example application + +Documentation must include: + +- Five-minute installation and dipole example. +- Geometry and port definition. +- Z/Y matrix interpretation. +- Voltage-driven and current-driven arrays. +- Active impedance versus matrix impedance. +- Complex far-field convention at 1 m. +- θ/φ coordinate diagram. +- Array beamforming example using embedded fields. +- Direct mode versus worker mode. +- Model lifecycle and disposal. +- Browser, Node, Vite and CDN loading. +- Error handling. +- Performance and browser-memory guidance. +- GPL licensing notice. + +Add a minimal Vite example that: + +1. Builds a two- or four-element array. +2. Computes its impedance matrix. +3. Applies complex current weights. +4. Displays port voltage/current values. +5. Plots an azimuth far-field cut. + +The example must install the packed package rather than reach into the monorepo. + +DoD: + +- A fresh checkout of the example can install, build and run using only documented commands. +- Every README code example is compiled or executed in CI. +- The example demonstrates the exact intended downstream integration path. + +# Overall release Definition of Done + +The package is ready when all of the following are true: + +- `npm install` plus `import { createNecModel }` works in a separate repository. +- Geometry, ports and frequency can be configured without generating a text deck. +- Factorization is reused across excitation and far-field requests. +- Full complex Z and Y matrices are returned. +- Arbitrary simultaneous voltage and current excitation is supported. +- Resulting complex port voltages and currents are returned. +- Complex \(E_\theta\) and \(E_\phi\) at 1 m are returned in V/m. +- Direct combined fields agree with embedded-pattern superposition. +- All public numerical conventions are documented and tested. +- Results cross the WASM boundary through bulk typed arrays. +- No raw pointer, heap, `ccall` or generated Emscripten type is public. +- Direct browser, Web Worker and Node ESM consumers pass integration tests. +- Repeated solves and model lifecycles do not leak or accumulate results. +- Existing native and legacy deck behavior remains compatible. +- The exact packed tarball passes clean-consumer tests before publication. +- Versioning, licensing, release artifacts and documentation are complete. + +The critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. Worker support, CI expansion and documentation can proceed once the TypeScript facade stabilizes, but they remain release requirements for a genuinely browser-ready package. \ No newline at end of file From c0da17b8de54354b79dcdd817dddb3f91fd8d381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 10:05:54 +0200 Subject: [PATCH 16/46] WP0 --- .gitignore | 1 + docs/ts_engine_plan.md | 36 ++- docs/wasm-api.md | 258 ++++++++++++++++++ packages/necpp-wasm/package-lock.json | 30 ++ packages/necpp-wasm/package.json | 15 + packages/necpp-wasm/src/errors.ts | 86 ++++++ packages/necpp-wasm/src/index.ts | 60 ++++ packages/necpp-wasm/src/state-machine.ts | 111 ++++++++ packages/necpp-wasm/src/types.ts | 251 +++++++++++++++++ packages/necpp-wasm/test-d/public-api.test.ts | 71 +++++ .../necpp-wasm/test/state-machine.test.mjs | 82 ++++++ packages/necpp-wasm/tsconfig.json | 20 ++ src/wasm_api_contract_tb.cpp | 216 +++++++++++++++ tests/CMakeLists.txt | 1 + 14 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 docs/wasm-api.md create mode 100644 packages/necpp-wasm/package-lock.json create mode 100644 packages/necpp-wasm/package.json create mode 100644 packages/necpp-wasm/src/errors.ts create mode 100644 packages/necpp-wasm/src/index.ts create mode 100644 packages/necpp-wasm/src/state-machine.ts create mode 100644 packages/necpp-wasm/src/types.ts create mode 100644 packages/necpp-wasm/test-d/public-api.test.ts create mode 100644 packages/necpp-wasm/test/state-machine.test.mjs create mode 100644 packages/necpp-wasm/tsconfig.json create mode 100644 src/wasm_api_contract_tb.cpp diff --git a/.gitignore b/.gitignore index c889aaf3..5a80f890 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ antlr/build/ # Packages # ############ +node_modules/ # it's better to unpack these files and commit the raw source # git has its own built in compression methods *.7z diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 6376aa07..6915214f 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -74,6 +74,8 @@ The NEC execution model supports reuse of the expensive interaction-matrix facto ## WP0 — API and numerical specification +**Status: complete (2026-08-28).** + Deliverables: - `docs/wasm-api.md` defining lifecycle, units, coordinate systems, phase convention and array layouts. @@ -112,6 +114,38 @@ DoD: - Port-current direction and field phase have executable tests, not only prose. - No unresolved numerical convention remains. +### WP0 progress + +- Added [`docs/wasm-api.md`](wasm-api.md) as the normative API and numerical + specification. It fixes lifecycle behavior, units, coordinates, phasor and + power conventions, matrix/field layouts, embedded-field normalization, + ownership, method failures, canonical fixtures, and numerical tolerances. +- Chose `@necpp/wasm` as the final package name. The existing unscoped + `necpp-wasm` name is occupied by a separately published distribution; + publication of the scoped package will require control of the `necpp` npm + scope. +- Added the strict public TypeScript contract under + `packages/necpp-wasm/src`, including typed errors and an executable lifecycle + transition table. The package is deliberately `private` at this stage + because WP5 and WP7 still need to supply the runtime facade and publishable + package assembly. +- Added TypeScript tests that enumerate all 78 operation/state pairs, exercise + the conditional idempotent `prepare()` transition, and compile valid plus + intentionally invalid consumer examples under TypeScript 5.8.3 with + `strict: true`. +- Added native Catch2 baselines for the center-fed dipole, two coupled + dipoles, and four-element phased array. These construct and solve through + `nec_context`/`c_geometry` directly, without generating or parsing a deck. +- Added executable convention locks for current positive into the antenna via + \(P=\tfrac12\operatorname{Re}(VI^*)\), and for the complex far-field range + ratio \(e^{-jk\Delta R}R_1/R_2\). +- Validation completed on Windows/MSVC: all 77 native test cases pass (738 + assertions), all 5 new `[wasm_api]` cases pass (44 assertions), the strict + TypeScript/state tests pass, and the existing deck-based WASM smoke test + remains green. + +The next open package on the critical path is WP1. + --- ## WP1 — Stateful native solver layer @@ -602,4 +636,4 @@ The package is ready when all of the following are true: - The exact packed tarball passes clean-consumer tests before publication. - Versioning, licensing, release artifacts and documentation are complete. -The critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. Worker support, CI expansion and documentation can proceed once the TypeScript facade stabilizes, but they remain release requirements for a genuinely browser-ready package. \ No newline at end of file +The critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. Worker support, CI expansion and documentation can proceed once the TypeScript facade stabilizes, but they remain release requirements for a genuinely browser-ready package. diff --git a/docs/wasm-api.md b/docs/wasm-api.md new file mode 100644 index 00000000..afedfc22 --- /dev/null +++ b/docs/wasm-api.md @@ -0,0 +1,258 @@ +# `@necpp/wasm` API and numerical contract + +Status: WP0 specification, 2026-08-28. This document is normative for the +stateful native layer, C/WASM ABI, and handwritten TypeScript facade that +follow. The committed TypeScript surface is in +[`packages/necpp-wasm/src`](../packages/necpp-wasm/src). + +## Package and runtime boundary + +The final npm package name is **`@necpp/wasm`**. The unscoped name +`necpp-wasm` is already occupied by a separately published distribution, +while the scoped name identifies this repository and leaves room for future `@necpp/*` +packages. Publication requires control of the `necpp` npm scope, but the API +name will not change if the package is initially distributed as a tarball. + +`createNecModel()` asynchronously initializes the Emscripten module and +returns a stateful `NecModel`. After creation, model methods are synchronous; +large browser calculations should use the worker facade planned in WP6. +`runDeck()` is an asynchronous compatibility escape hatch for a complete NEC +text deck. It is not part of a `NecModel` lifecycle and returns the formatted +report as a string. + +The JavaScript facade owns the native handle and is solely responsible for +destroying it. A consumer never receives a pointer, heap view, generated +Emscripten type, `ccall`, or `cwrap` function. + +## Numerical conventions + +These conventions apply to every public method and returned value. + +- Phasors use \(e^{+j\omega t}\). An outgoing spherical wave therefore has + propagation factor \(e^{-jkR}/R\), with + \(k=2\pi f/c_0\) and the NEC-2 value + \(c_0=299{,}800{,}000\ \mathrm{m/s}\). +- Geometry, radii, ranges, and all other public distances are in metres. + Frequency is in MHz at the API boundary. +- Voltages and currents are complex peak-amplitude phasors, not RMS phasors, + in volts and amperes. Consequently, time-average input power is + \(P=\tfrac12\operatorname{Re}(V I^*)\) watts. +- Port current is positive from the source **into the modeled antenna**. Thus + \(V=ZI\), \(I=YV\), passive input resistance gives positive input power, + and active impedance is \(V_i/I_i\). A port with exactly zero current has + active impedance `NaN + jNaN` rather than an infinity or exception. +- Impedance is in ohms and admittance is in siemens. Conductivity is in S/m, + inductance in henries, and capacitance in farads. Per-metre RLC loads state + that fact explicitly through `perMeter: true`. +- Coordinates are a right-handed Cartesian system. \(\theta\) is the polar + angle from +Z. \(\phi\) is azimuth in the XY plane from +X toward +Y. + \(E_\theta\) points in increasing \(\theta\); \(E_\phi\) points in increasing + \(\phi\). +- Far fields are complex V/m, referenced to the model coordinate origin. + `radiusM` defaults to 1 m, is always positive, and is always returned. The + API always includes \(e^{-jkR}/R\); it does not expose the RP-card convention + in which a zero range omits this factor. Results remain far-field + approximations even if a small radius is requested. +- Segment positions in `PortDefinition` and `SegmentSelection` are one-based, + matching NEC's position within a tag group. JavaScript array indices are + zero-based. +- All computations use IEEE-754 binary64 values. Inputs must be finite except + where a result explicitly permits NaN active impedance for zero current. + +The phase, range, and angle definitions follow the NEC-2 Part 3 +[RP card](https://www.nec2.org/part_3/cards/rp.html); voltage-source fields and +tag-relative segment addressing follow the +[EX card](https://www.nec2.org/part_3/cards/ex.html). Load units and targeting +follow the [LD card](https://www.nec2.org/part_3/cards/ld.html). + +## Array layout and ownership + +`ComplexVector.real` and `.imag` have the same length. A vector supplied to a +method must contain exactly one entry per defined port, in the order passed to +`definePorts()`. + +`ComplexMatrix` is dense and row-major: + +```text +index(row, column) = row * columns + column +``` + +For an N-port model, Z and Y are N by N. `Y[row, column]` is the current at +port `row` caused by a unit-voltage excitation at port `column`, with every +other port held at zero volts. `Z` is the inverse mapping. Matrix row and +column order exactly matches `definePorts()`. + +`FarFieldResult.thetaDeg` and `.phiDeg` contain the sampled coordinate axes, +not one coordinate per field element. Every field array has +`thetaDeg.length * phiDeg.length` elements: + +```text +sampleIndex = phiIndex * thetaDeg.length + thetaIndex +``` + +Theta varies fastest, matching NEC RP stepping. Embedded fields add an outer +port dimension: + +```text +embeddedIndex = portIndex * samplesPerPort + sampleIndex +``` + +The default embedded normalization is one volt at the selected port with all +other ports held at zero volts. Unit-current normalization means one ampere +into the selected port with zero requested current at every other port. + +All input arrays are borrowed only for the duration of the call and are never +mutated. Every returned typed array is a fresh, JavaScript-owned copy. It +remains valid after another solve, native result replacement, WASM memory +growth, or `dispose()`. Returned port definitions are snapshots; mutating the +consumer's original array or its objects cannot alter the model. + +## Lifecycle + +The initial state is `empty`: + +```text +empty -> geometry-building -> geometry-complete -> prepared -> solved + | +any live state ------------------------------------------------+-> disposed +``` + +The arrows above show the normal path; the complete transition table is +below. A dash means that the operation throws `NecStateError` without changing +the model. `same` means that the operation preserves the current state. + +| Operation | empty | geometry-building | geometry-complete | prepared | solved | disposed | +|---|---|---|---|---|---|---| +| `addWire` | geometry-building | same | — | — | — | — | +| `completeGeometry` | — | geometry-complete | — | — | — | — | +| `definePorts` | — | — | same | — | — | — | +| `addLoad`, `clearLoads`, `setGround` | — | — | same | geometry-complete | geometry-complete | — | +| `prepare`, changed configuration | — | — | prepared | same | prepared | — | +| `prepare`, unchanged configuration | — | — | prepared | same | same | — | +| `computeImpedanceMatrix` | — | — | — | same | same | — | +| `solveVoltages`, `solveCurrents` | — | — | — | solved | same | — | +| `computeFarField` | — | — | — | — | same | — | +| `computeEmbeddedFarFields` | — | — | — | same | same | — | +| `dispose` | disposed | disposed | disposed | disposed | disposed | same | + +Additional lifecycle rules: + +- Geometry can change only before `completeGeometry()` and completion occurs + exactly once. At least one valid geometry element is required. +- `definePorts()` replaces the complete port list. It requires a nonempty + list of unique, valid tag/segment pairs and is deliberately frozen before + preparation so all later results have stable ordering. +- The initial environment is free space with no loads. Changing ground or + loads after preparation discards factorization, matrices, and the latest + solution and returns to `geometry-complete`. Calling these mutators is + conservatively invalidating even if the supplied value equals the old one. +- `prepare()` requires at least one port. A new frequency invalidates the old + factorization and solution. Repeating the exact same preparation is a true + no-op: it does not refactor, increment generations, discard a solution, or + change state. +- Excitation changes increment the solve generation but do not invalidate the + factorization. Far-field sampling changes neither generation. +- Matrix and embedded-field calculations may perform internal basis solves, + but they preserve an existing consumer solution and the public state. From + `prepared`, they leave the model prepared rather than making an arbitrary + basis excitation the “latest solution.” +- `computeFarField()` always uses the latest consumer solution. A zero + excitation returns exact zero field components without NaNs. +- `dispose()` is deterministic and idempotent. The `state` getter remains + readable afterwards; every other method throws `NecStateError`. + +The executable counterpart of this table is +[`state-machine.ts`](../packages/necpp-wasm/src/state-machine.ts), and its test +enumerates every operation/state pair plus both `prepare()` branches. + +## Method contract + +| Method | Inputs and units | Output | Failures beyond illegal state | +|---|---|---|---| +| `createNecModel(options?)` | Optional `wasmUrl` or caller-owned WASM bytes | Promise of an `empty` model | `NecRuntimeError` for load/instantiate/version failure; `NecInputError` if both overrides are supplied | +| `addWire(wire)` | Positive integer tag/count; distinct finite endpoints and positive finite radius, all in m | `void`; copies the definition | `NecInputError` for shape/range errors; `NecGeometryError` for engine geometry limits | +| `completeGeometry(options?)` | Ground connection: `none` (default), `interpolate`, or `zero-current` | `void` | `NecGeometryError` for intersections, invalid junctions, or a ground-incompatible structure | +| `definePorts(ports)` | Nonempty ordered tag and one-based segment pairs | `void`; copies and freezes order | `NecPortError` for missing/duplicate ports or non-source-capable segments; `NecInputError` for malformed integers | +| `addLoad(load)` | Segment target and impedance, RLC, or conductivity values in the units above | `void`; invalidates prepared data | `NecInputError` for invalid values/ranges; `NecGeometryError` when no segment matches | +| `clearLoads()` | None | `void`; removes every load and invalidates prepared data | No non-state failure | +| `setGround(ground)` | Free space, perfect ground, or finite ground with method, relative permittivity, and S/m conductivity | `void`; invalidates prepared data | `NecInputError` for nonphysical values; `NecGeometryError` if inconsistent with geometry completion | +| `prepare({ frequencyMHz })` | One positive finite MHz value | `void`; retains/factors the interaction matrix | `NecInputError` for frequency; `NecPortError` if ports are absent; `NecSolverError` for fill/factorization failure | +| `computeImpedanceMatrix()` | None | Z, Y, optional condition estimate, frequency, factorization generation | `NecConditioningError` if inversion is singular or exceeds the implementation threshold; `NecSolverError` otherwise | +| `solveVoltages(vector)` | N complex volts | `PortSolution` with requested/achieved quantities | `NecInputError` for dimensions/nonfinite values; `NecSolverError` for solve failure | +| `solveCurrents(vector)` | N complex amperes, positive into antenna | `PortSolution`; obtains required voltages through \(V=ZI\) and performs one simultaneous source solve | `NecConditioningError` if Z cannot be formed reliably; otherwise same as voltage solve | +| `computeFarField(request)` | Positive radius in m (default 1), finite angle starts/steps, positive integer counts | Complex V/m for latest solution | `NecInputError` for grid/range/size overflow; `NecSolverError` for field calculation failure | +| `computeEmbeddedFarFields(request, normalization?)` | Same grid plus unit-voltage (default) or unit-current normalization | Basis-major complex V/m arrays | Matrix/conditioning and far-field failures above | +| `dispose()` | None | `void`; idempotent | No failure is exposed; cleanup errors are contained | +| `runDeck(deck, options?)` | Complete UTF-8 deck string; optional pre-start abort signal | Promise of formatted report and engine version | `NecInputError` for empty/invalid deck or pre-abort; `NecSolverError` for execution; `NecRuntimeError` for module failure | + +All native exceptions are contained at the C ABI. The TypeScript layer maps a +nonzero native status to one of the errors below and retains the native +message as `message` or `cause`; a raw number or string is never thrown. + +## Error taxonomy + +| Class | Stable code | Meaning | +|---|---|---| +| `NecStateError` | `NEC_STATE` | Operation is not legal in the current lifecycle state, including use after disposal | +| `NecInputError` | `NEC_INPUT` | JS value, dimension, unit-domain, or deck validation failed | +| `NecGeometryError` | `NEC_GEOMETRY` | Geometry, load target, junction, intersection, or ground compatibility failed | +| `NecPortError` | `NEC_PORT` | Port is missing, duplicated, invalid, or not source-capable | +| `NecConditioningError` | `NEC_CONDITIONING` | Port matrix is singular or too ill-conditioned for the requested operation | +| `NecSolverError` | `NEC_SOLVER` | Matrix fill, factorization, excitation solve, or field computation failed | +| `NecRuntimeError` | `NEC_RUNTIME` | WASM loading, ABI mismatch, allocation, or other runtime-boundary failure | + +Every class derives from `NecError`, whose `code` is stable for programmatic +handling. Messages and `details` aid diagnostics but are not a compatibility +surface. Validation failures do not mutate model state. A failed calculation +keeps the last successfully prepared factorization and consumer solution when +the native layer can prove they are intact; otherwise it rolls back to +`geometry-complete` and discards prepared data. + +## Canonical test models + +All fixtures use free space, 300 MHz, round PEC wire of radius 0.001 m, 11 +segments per element, and a centre port at segment 6. Coordinates are metres. +The exact native builders live in +[`wasm_api_contract_tb.cpp`](../src/wasm_api_contract_tb.cpp). + +| Fixture | Wires `(tag: start -> end)` | Ports | Baseline excitation | +|---|---|---|---| +| Centre-fed dipole | `1: (0,0,-0.25) -> (0,0,0.25)` | `(1,6)` | 1 + j0 V | +| Two parallel coupled dipoles | Dipole 1 plus `2: (0.20,0,-0.25) -> (0.20,0,0.25)` | `(1,6)`, `(2,6)` | Port 1: 1 V; port 2: 0 V in future matrix tests. WP0 native baseline drives port 1 only. | +| Four-element linear array | Tags 1..4 at x = `-0.45, -0.15, 0.15, 0.45`, each spanning z = `-0.25..0.25` | Centre segment of tags 1..4 | Simultaneous voltages with progressive phases 0°, 30°, 60°, 90° | + +WP0 tests run all three through the existing programmatic native API—without +generating or parsing a deck—before the stateful layer is introduced. The +dipole also locks current direction and the (e^{-jkR}/R) field phase/range +law as executable tests. + +## Numerical tolerance policy + +Comparisons of complex vectors or matrices produced by two paths use the +scale-aware relative error + +\[ + \epsilon(a,b)= + \frac{\lVert a-b\rVert_2} + {\max(1,\lVert a\rVert_2,\lVert b\rVert_2)}. +\] + +The normal limit is `1e-7`. Algebraic identities using results from the same +factorization (for example \(ZY\approx I\)) also use `1e-7`. Exact metadata, +array sizes/order, generations, state transitions, zero-excitation output, +and ownership behavior have zero tolerance. + +Independent NEC-2 golden values may use a fixture-specific tolerance up to +`3e-4` relative or absolute, whichever is larger, because legacy report +values are rounded and implementations differ slightly in constants. Every +such test must state the source and its looser bound next to the assertion. +Reciprocity and geometric symmetry use `1e-8` unless a fixture documents why +segmentation breaks exact symmetry. The radial field law uses `1e-10` for the +magnitude ratio and `1e-10` radians for phase after comparing complex ratios, +because both fields reuse identical currents and differ only by the analytic +range factor. Native-to-WASM bulk arrays use `1e-12`; both execute the same +binary64 code path. + +Tests must reject NaN or infinity before applying a tolerance. Phase is +compared as a wrapped complex ratio, never by subtracting printed degree +values across a ±180° branch cut. diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json new file mode 100644 index 00000000..2cbc37da --- /dev/null +++ b/packages/necpp-wasm/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@necpp/wasm", + "version": "0.0.0-wp0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@necpp/wasm", + "version": "0.0.0-wp0", + "license": "GPL-2.0-or-later", + "devDependencies": { + "typescript": "5.8.3" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json new file mode 100644 index 00000000..2ebb930c --- /dev/null +++ b/packages/necpp-wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "@necpp/wasm", + "version": "0.0.0-wp0", + "private": true, + "type": "module", + "description": "Public TypeScript contract for the NEC2++ WebAssembly engine", + "license": "GPL-2.0-or-later", + "scripts": { + "test": "node --test test/*.test.mjs && npm run typecheck", + "typecheck": "tsc --project tsconfig.json" + }, + "devDependencies": { + "typescript": "5.8.3" + } +} diff --git a/packages/necpp-wasm/src/errors.ts b/packages/necpp-wasm/src/errors.ts new file mode 100644 index 00000000..da907d54 --- /dev/null +++ b/packages/necpp-wasm/src/errors.ts @@ -0,0 +1,86 @@ +import type { NecModelState } from "./types.ts"; + +export type NecErrorCode = + | "NEC_STATE" + | "NEC_INPUT" + | "NEC_GEOMETRY" + | "NEC_PORT" + | "NEC_SOLVER" + | "NEC_CONDITIONING" + | "NEC_RUNTIME"; + +export interface NecErrorOptions { + readonly cause?: unknown; + readonly details?: Readonly>; +} + +/** Base class for every package-defined operational error. */ +export class NecError extends Error { + readonly code: TCode; + readonly details: Readonly> | undefined; + + constructor(code: TCode, message: string, options: NecErrorOptions = {}) { + super(message, { cause: options.cause }); + this.name = "NecError"; + this.code = code; + this.details = options.details; + } +} + +export class NecStateError extends NecError<"NEC_STATE"> { + readonly operation: string; + readonly state: NecModelState; + + constructor(operation: string, state: NecModelState, message?: string) { + super( + "NEC_STATE", + message ?? `Operation ${operation} is not valid while the model is ${state}`, + { details: { operation, state } }, + ); + this.name = "NecStateError"; + this.operation = operation; + this.state = state; + } +} + +export class NecInputError extends NecError<"NEC_INPUT"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_INPUT", message, options ?? {}); + this.name = "NecInputError"; + } +} + +export class NecGeometryError extends NecError<"NEC_GEOMETRY"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_GEOMETRY", message, options ?? {}); + this.name = "NecGeometryError"; + } +} + +export class NecPortError extends NecError<"NEC_PORT"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_PORT", message, options ?? {}); + this.name = "NecPortError"; + } +} + +export class NecSolverError extends NecError<"NEC_SOLVER"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_SOLVER", message, options ?? {}); + this.name = "NecSolverError"; + } +} + +export class NecConditioningError extends NecError<"NEC_CONDITIONING"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_CONDITIONING", message, options ?? {}); + this.name = "NecConditioningError"; + } +} + +export class NecRuntimeError extends NecError<"NEC_RUNTIME"> { + constructor(message: string, options?: NecErrorOptions) { + super("NEC_RUNTIME", message, options ?? {}); + this.name = "NecRuntimeError"; + } +} diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts new file mode 100644 index 00000000..68035077 --- /dev/null +++ b/packages/necpp-wasm/src/index.ts @@ -0,0 +1,60 @@ +export { + NecConditioningError, + NecError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, + NecStateError, +} from "./errors.ts"; + +export type { NecErrorCode, NecErrorOptions } from "./errors.ts"; + +export type { + AngleSweep, + CartesianPointM, + CompleteGeometryOptions, + ComplexMatrix, + ComplexVector, + ConductivityLoad, + CreateNecModelOptions, + DeckResult, + DistributedParallelRlcLoad, + DistributedSeriesRlcLoad, + EmbeddedFarFieldResult, + EmbeddedFieldNormalization, + FarFieldRequest, + FarFieldResult, + FiniteGround, + FreeSpaceGround, + GroundConnection, + GroundModel, + ImpedanceLoad, + ImpedanceResult, + LoadDefinition, + NecModel, + NecModelState, + ParallelRlcLoad, + PerfectGround, + PortDefinition, + PortSolution, + PrepareOptions, + RunDeckOptions, + SegmentSelection, + SeriesRlcLoad, + WireDefinition, +} from "./types.ts"; + +import type { + CreateNecModelOptions, + DeckResult, + NecModel, + RunDeckOptions, +} from "./types.ts"; + +/** WP0 contract declaration. The runtime factory is implemented in WP5. */ +export declare function createNecModel(options?: CreateNecModelOptions): Promise; + +/** Compatibility escape hatch for complete NEC text decks. */ +export declare function runDeck(deck: string, options?: RunDeckOptions): Promise; diff --git a/packages/necpp-wasm/src/state-machine.ts b/packages/necpp-wasm/src/state-machine.ts new file mode 100644 index 00000000..0ff97f6e --- /dev/null +++ b/packages/necpp-wasm/src/state-machine.ts @@ -0,0 +1,111 @@ +import { NecStateError } from "./errors.ts"; +import type { NecModelState } from "./types.ts"; + +export type ModelOperation = + | "addWire" + | "completeGeometry" + | "definePorts" + | "addLoad" + | "clearLoads" + | "setGround" + | "prepare" + | "computeImpedanceMatrix" + | "solveVoltages" + | "solveCurrents" + | "computeFarField" + | "computeEmbeddedFarFields" + | "dispose"; + +export interface ConditionalTransition { + readonly configurationChanged: NecModelState; + readonly configurationUnchanged: NecModelState; +} + +type TransitionTarget = NecModelState | ConditionalTransition; +type TransitionRow = Readonly>>; + +export interface TransitionOptions { + /** Relevant only to `prepare`; defaults to true for conservative invalidation. */ + readonly configurationChanged?: boolean; +} + +/** Executable form of the normative lifecycle table in docs/wasm-api.md. */ +export const MODEL_TRANSITIONS: Readonly> = { + addWire: { + empty: "geometry-building", + "geometry-building": "geometry-building", + }, + completeGeometry: { + "geometry-building": "geometry-complete", + }, + definePorts: { + "geometry-complete": "geometry-complete", + }, + addLoad: { + "geometry-complete": "geometry-complete", + prepared: "geometry-complete", + solved: "geometry-complete", + }, + clearLoads: { + "geometry-complete": "geometry-complete", + prepared: "geometry-complete", + solved: "geometry-complete", + }, + setGround: { + "geometry-complete": "geometry-complete", + prepared: "geometry-complete", + solved: "geometry-complete", + }, + prepare: { + "geometry-complete": "prepared", + prepared: "prepared", + solved: { + configurationChanged: "prepared", + configurationUnchanged: "solved", + }, + }, + computeImpedanceMatrix: { + prepared: "prepared", + solved: "solved", + }, + solveVoltages: { + prepared: "solved", + solved: "solved", + }, + solveCurrents: { + prepared: "solved", + solved: "solved", + }, + computeFarField: { + solved: "solved", + }, + computeEmbeddedFarFields: { + prepared: "prepared", + solved: "solved", + }, + dispose: { + empty: "disposed", + "geometry-building": "disposed", + "geometry-complete": "disposed", + prepared: "disposed", + solved: "disposed", + disposed: "disposed", + }, +}; + +export function transitionModelState( + state: NecModelState, + operation: ModelOperation, + options: TransitionOptions = {}, +): NecModelState { + const target = MODEL_TRANSITIONS[operation][state]; + if (target === undefined) { + throw new NecStateError(operation, state); + } + if (typeof target === "string") { + return target; + } + return options.configurationChanged === false + ? target.configurationUnchanged + : target.configurationChanged; +} diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts new file mode 100644 index 00000000..8058095d --- /dev/null +++ b/packages/necpp-wasm/src/types.ts @@ -0,0 +1,251 @@ +/** Three-dimensional Cartesian coordinate in metres. */ +export type CartesianPointM = readonly [xM: number, yM: number, zM: number]; + +/** Public lifecycle state. */ +export type NecModelState = + | "empty" + | "geometry-building" + | "geometry-complete" + | "prepared" + | "solved" + | "disposed"; + +/** A complex vector whose real and imaginary arrays have identical lengths. */ +export interface ComplexVector { + readonly real: Float64Array; + readonly imag: Float64Array; +} + +/** A dense complex matrix in row-major order. */ +export interface ComplexMatrix extends ComplexVector { + readonly rows: number; + readonly columns: number; + readonly order: "row-major"; +} + +/** A straight, round wire. Segment positions are uniformly spaced. */ +export interface WireDefinition { + /** Positive integer used to address the wire and its segments. */ + readonly tag: number; + /** Positive integer segment count. Odd counts are recommended at feeds. */ + readonly segments: number; + readonly start: CartesianPointM; + readonly end: CartesianPointM; + readonly radiusM: number; +} + +/** How wire ends on z=0 are treated when geometry is completed. */ +export type GroundConnection = "none" | "interpolate" | "zero-current"; + +export interface CompleteGeometryOptions { + /** Defaults to `"none"`. A non-none value declares a ground plane at z=0. */ + readonly groundConnection?: GroundConnection; +} + +/** One-based segment position among all segments carrying `tag`. */ +export interface PortDefinition { + readonly tag: number; + readonly segment: number; + /** Optional stable consumer label; it does not affect the numerical model. */ + readonly name?: string; +} + +/** Selects one or more segments for a load. Segment positions are one-based. */ +export interface SegmentSelection { + /** Zero selects absolute segment numbers; a positive value selects a wire tag. */ + readonly tag: number; + /** Omit both bounds to select every segment carrying a nonzero `tag`. */ + readonly firstSegment?: number; + /** Defaults to `firstSegment` when the first bound is present. */ + readonly lastSegment?: number; +} + +export interface SeriesRlcLoad { + readonly kind: "series-rlc"; + readonly target: SegmentSelection; + readonly resistanceOhm: number; + readonly inductanceH: number; + readonly capacitanceF: number; + readonly perMeter?: false; +} + +export interface ParallelRlcLoad { + readonly kind: "parallel-rlc"; + readonly target: SegmentSelection; + readonly resistanceOhm: number; + readonly inductanceH: number; + readonly capacitanceF: number; + readonly perMeter?: false; +} + +export interface DistributedSeriesRlcLoad { + readonly kind: "series-rlc"; + readonly target: SegmentSelection; + readonly resistanceOhm: number; + readonly inductanceH: number; + readonly capacitanceF: number; + readonly perMeter: true; +} + +export interface DistributedParallelRlcLoad { + readonly kind: "parallel-rlc"; + readonly target: SegmentSelection; + readonly resistanceOhm: number; + readonly inductanceH: number; + readonly capacitanceF: number; + readonly perMeter: true; +} + +export interface ImpedanceLoad { + readonly kind: "impedance"; + readonly target: SegmentSelection; + readonly resistanceOhm: number; + readonly reactanceOhm: number; +} + +export interface ConductivityLoad { + readonly kind: "conductivity"; + readonly target: SegmentSelection; + readonly conductivitySPerM: number; +} + +export type LoadDefinition = + | SeriesRlcLoad + | ParallelRlcLoad + | DistributedSeriesRlcLoad + | DistributedParallelRlcLoad + | ImpedanceLoad + | ConductivityLoad; + +export interface FreeSpaceGround { + readonly kind: "free-space"; +} + +export interface PerfectGround { + readonly kind: "perfect"; +} + +export interface FiniteGround { + readonly kind: "finite"; + readonly method: "reflection-coefficient" | "sommerfeld-norton"; + readonly relativePermittivity: number; + readonly conductivitySPerM: number; +} + +export type GroundModel = FreeSpaceGround | PerfectGround | FiniteGround; + +export interface PrepareOptions { + /** Frequency in megahertz; must be finite and greater than zero. */ + readonly frequencyMHz: number; +} + +export interface ImpedanceResult { + readonly impedance: ComplexMatrix; + readonly admittance: ComplexMatrix; + readonly conditionEstimate?: number; + readonly frequencyMHz: number; + /** Native cache generation used for deterministic cache tests and diagnostics. */ + readonly factorizationGeneration: number; +} + +export interface PortSolution { + readonly drive: "voltage" | "current"; + readonly frequencyMHz: number; + readonly ports: readonly PortDefinition[]; + /** Requested drive values, in volts or amperes according to `drive`. */ + readonly requested: ComplexVector; + /** Achieved complex port voltages in volts. */ + readonly voltages: ComplexVector; + /** Achieved complex port currents in amperes, positive into the antenna. */ + readonly currents: ComplexVector; + /** Element-wise V/I in ohms; NaN + jNaN where current is exactly zero. */ + readonly activeImpedances: ComplexVector; + /** Time-average input power: 0.5 * Re(V * conjugate(I)), in watts. */ + readonly powersW: Float64Array; + readonly factorizationGeneration: number; + readonly solveGeneration: number; +} + +export interface AngleSweep { + readonly startDeg: number; + readonly count: number; + readonly stepDeg: number; +} + +export interface FarFieldRequest { + /** Defaults to 1 metre and must be finite and greater than zero. */ + readonly radiusM?: number; + readonly theta: AngleSweep; + readonly phi: AngleSweep; +} + +export interface FarFieldResult { + readonly radiusM: number; + readonly frequencyMHz: number; + readonly thetaDeg: Float64Array; + readonly phiDeg: Float64Array; + /** Sample index is `phiIndex * thetaDeg.length + thetaIndex`. */ + readonly eThetaReal: Float64Array; + readonly eThetaImag: Float64Array; + readonly ePhiReal: Float64Array; + readonly ePhiImag: Float64Array; +} + +export type EmbeddedFieldNormalization = + | { readonly kind: "unit-voltage"; readonly valueV: 1 } + | { readonly kind: "unit-current"; readonly valueA: 1 }; + +export interface EmbeddedFarFieldResult extends FarFieldResult { + readonly ports: readonly PortDefinition[]; + readonly normalization: EmbeddedFieldNormalization; + /** Number of angular samples for one port. */ + readonly samplesPerPort: number; + /** + * Field arrays are basis-major: `portIndex * samplesPerPort + sampleIndex`. + * They therefore contain `ports.length * samplesPerPort` entries. + */ + readonly eThetaReal: Float64Array; + readonly eThetaImag: Float64Array; + readonly ePhiReal: Float64Array; + readonly ePhiImag: Float64Array; +} + +export interface CreateNecModelOptions { + /** Override the package-relative URL used to load `nec2pp.wasm`. */ + readonly wasmUrl?: string | URL; + /** Caller-owned WASM bytes. The factory does not retain or mutate this buffer. */ + readonly wasmBinary?: ArrayBuffer | Uint8Array; +} + +export interface RunDeckOptions extends CreateNecModelOptions { + /** Abort before starting; an in-progress synchronous native solve is not interruptible. */ + readonly signal?: AbortSignal; +} + +export interface DeckResult { + readonly report: string; + readonly engineVersion: string; +} + +/** Stateful high-level model. All returned arrays are caller-owned copies. */ +export interface NecModel { + readonly state: NecModelState; + + addWire(wire: WireDefinition): void; + completeGeometry(options?: CompleteGeometryOptions): void; + definePorts(ports: readonly PortDefinition[]): void; + addLoad(load: LoadDefinition): void; + clearLoads(): void; + setGround(ground: GroundModel): void; + prepare(options: PrepareOptions): void; + computeImpedanceMatrix(): ImpedanceResult; + solveVoltages(voltages: ComplexVector): PortSolution; + solveCurrents(currents: ComplexVector): PortSolution; + computeFarField(request: FarFieldRequest): FarFieldResult; + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): EmbeddedFarFieldResult; + /** Idempotent. After disposal, every operation except `state` and `dispose` fails. */ + dispose(): void; +} diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts new file mode 100644 index 00000000..8ed598d5 --- /dev/null +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -0,0 +1,71 @@ +import { + NecStateError, + createNecModel, + runDeck, + type ComplexMatrix, + type FarFieldResult, + type PortSolution, +} from "../src/index.ts"; + +async function validConsumer(): Promise { + const model = await createNecModel(); + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); + model.addLoad({ + kind: "impedance", + target: { tag: 1, firstSegment: 1, lastSegment: 1 }, + resistanceOhm: 5, + reactanceOhm: 2, + }); + model.setGround({ kind: "free-space" }); + model.prepare({ frequencyMHz: 300 }); + + const matrices: ComplexMatrix = model.computeImpedanceMatrix().impedance; + const solution: PortSolution = model.solveCurrents({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + const field: FarFieldResult = model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 181, stepDeg: 1 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + }); + + matrices.real[0]; + solution.currents.imag[0]; + field.eThetaReal[0]; + model.dispose(); + + const deck = await runDeck("CE\nEN\n"); + deck.report.toUpperCase(); +} + +void validConsumer; + +async function intentionallyInvalidConsumer(): Promise { + const model = await createNecModel(); + + // @ts-expect-error radiusM is measured as a number of metres. + model.addWire({ tag: 1, segments: 11, start: [0, 0, 0], end: [0, 0, 1], radiusM: "1 mm" }); + + // @ts-expect-error complex parts must be Float64Array instances. + model.solveVoltages({ real: [1], imag: [0] }); + + // @ts-expect-error theta count is required. + model.computeFarField({ theta: { startDeg: 0, stepDeg: 1 }, phi: { startDeg: 0, count: 1, stepDeg: 0 } }); + + // @ts-expect-error the public model has no raw WASM pointer. + model.handle; +} + +void intentionallyInvalidConsumer; + +const stateError = new NecStateError("solveCurrents", "geometry-complete"); +stateError.code satisfies "NEC_STATE"; diff --git a/packages/necpp-wasm/test/state-machine.test.mjs b/packages/necpp-wasm/test/state-machine.test.mjs new file mode 100644 index 00000000..559021af --- /dev/null +++ b/packages/necpp-wasm/test/state-machine.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { NecStateError } from "../src/errors.ts"; +import { + MODEL_TRANSITIONS, + transitionModelState, +} from "../src/state-machine.ts"; + +const states = [ + "empty", + "geometry-building", + "geometry-complete", + "prepared", + "solved", + "disposed", +]; + +const operations = Object.keys(MODEL_TRANSITIONS); + +test("the lifecycle table explicitly covers every operation/state pair", () => { + assert.equal(operations.length, 13); + assert.equal(states.length, 6); + + for (const operation of operations) { + for (const state of states) { + const expected = MODEL_TRANSITIONS[operation][state]; + if (expected === undefined) { + assert.throws( + () => transitionModelState(state, operation), + (error) => + error instanceof NecStateError + && error.code === "NEC_STATE" + && error.operation === operation + && error.state === state, + `${operation} should be illegal in ${state}`, + ); + } else { + if (typeof expected === "string") { + assert.equal( + transitionModelState(state, operation), + expected, + `${operation} should transition ${state} to ${expected}`, + ); + } else { + assert.equal( + transitionModelState(state, operation, { configurationChanged: true }), + expected.configurationChanged, + ); + assert.equal( + transitionModelState(state, operation, { configurationChanged: false }), + expected.configurationUnchanged, + ); + } + } + } + } +}); + +test("unchanged preparation is idempotent after a solve", () => { + assert.equal( + transitionModelState("solved", "prepare", { configurationChanged: false }), + "solved", + ); + assert.equal( + transitionModelState("solved", "prepare", { configurationChanged: true }), + "prepared", + ); +}); + +test("dispose is idempotent and disposed models reject every other operation", () => { + assert.equal(transitionModelState("disposed", "dispose"), "disposed"); + + for (const operation of operations) { + if (operation !== "dispose") { + assert.throws( + () => transitionModelState("disposed", operation), + NecStateError, + ); + } + } +}); diff --git a/packages/necpp-wasm/tsconfig.json b/packages/necpp-wasm/tsconfig.json new file mode 100644 index 00000000..2a0b8b6d --- /dev/null +++ b/packages/necpp-wasm/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2022", + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "test-d/**/*.ts"] +} diff --git a/src/wasm_api_contract_tb.cpp b/src/wasm_api_contract_tb.cpp new file mode 100644 index 00000000..1f103350 --- /dev/null +++ b/src/wasm_api_contract_tb.cpp @@ -0,0 +1,216 @@ +#include +#include + +#include "c_geometry.h" +#include "electromag.h" +#include "nec_context.h" +#include "nec_radiation_pattern.h" +#include "nec_results.h" +#include "nec_structure_currents.h" + +#include +#include +#include +#include + +namespace { + +constexpr nec_float kFrequencyMHz = 300.0; +constexpr nec_float kWireRadiusM = 0.001; +constexpr int kSegments = 11; +constexpr int kFeedSegment = 6; + +void add_z_dipole(nec_context& model, int tag, nec_float x_m) +{ + model.get_geometry()->wire( + tag, + kSegments, + x_m, 0.0, -0.25, + x_m, 0.0, 0.25, + kWireRadiusM, + 1.0, + 1.0); +} + +void finish_free_space_geometry(nec_context& model) +{ + model.geometry_complete(0); + model.fr_card(0, 1, kFrequencyMHz, 0.0); +} + +void add_voltage_source(nec_context& model, int tag, nec_complex voltage) +{ + model.ex_card( + EXCITATION_VOLTAGE, + tag, + kFeedSegment, + 0, + voltage.real(), + voltage.imag(), + 0.0, + 0.0, + 0.0, + 0.0); +} + +bool finite_complex(nec_complex value) +{ + return std::isfinite(value.real()) && std::isfinite(value.imag()); +} + +void solve_center_fed_dipole(nec_context& model) +{ + model.initialize(); + add_z_dipole(model, 1, 0.0); + finish_free_space_geometry(model); + add_voltage_source(model, 1, nec_complex(1.0, 0.0)); + model.xq_card(0); +} + +} // namespace + +TEST_CASE("WP0 canonical center-fed dipole runs through the native API", + "[wasm_api][canonical]") +{ + nec_context model; + solve_center_fed_dipole(model); + + nec_antenna_input* input = model.get_input_parameters(0); + REQUIRE(input != nullptr); + const std::vector expected_tags{1}; + const std::vector expected_segments{kFeedSegment}; + REQUIRE(input->get_tag() == expected_tags); + REQUIRE(input->get_segment() == expected_segments); + REQUIRE(input->get_voltage().size() == 1); + REQUIRE(input->get_current().size() == 1); + REQUIRE(finite_complex(input->get_current()[0])); + REQUIRE(model.get_impedance_real() > 0.0); + REQUIRE(std::isfinite(model.get_impedance_imag())); +} + +TEST_CASE("WP0 canonical coupled dipoles run through the native API", + "[wasm_api][canonical]") +{ + nec_context model; + model.initialize(); + add_z_dipole(model, 1, 0.0); + add_z_dipole(model, 2, 0.20); + finish_free_space_geometry(model); + add_voltage_source(model, 1, nec_complex(1.0, 0.0)); + model.xq_card(0); + + nec_structure_currents* currents = model.get_structure_currents(0); + REQUIRE(currents != nullptr); + REQUIRE(currents->get_current().size() == 2 * kSegments); + + const nec_complex driven_current = currents->get_current()[kFeedSegment - 1]; + const nec_complex coupled_current = currents->get_current()[kSegments + kFeedSegment - 1]; + REQUIRE(finite_complex(driven_current)); + REQUIRE(finite_complex(coupled_current)); + REQUIRE(std::abs(driven_current) > 1.0e-8); + REQUIRE(std::abs(coupled_current) > 1.0e-8); +} + +TEST_CASE("WP0 canonical four-element array runs through the native API", + "[wasm_api][canonical]") +{ + nec_context model; + model.initialize(); + + constexpr std::array x_positions{ + -0.45, -0.15, 0.15, 0.45, + }; + for (std::size_t index = 0; index < x_positions.size(); ++index) + add_z_dipole(model, static_cast(index) + 1, x_positions[index]); + + finish_free_space_geometry(model); + + const nec_float phase_step_rad = pi() / 6.0; + for (std::size_t index = 0; index < x_positions.size(); ++index) { + add_voltage_source( + model, + static_cast(index) + 1, + std::polar(1.0, phase_step_rad * static_cast(index))); + } + model.xq_card(0); + + nec_antenna_input* input = model.get_input_parameters(0); + REQUIRE(input != nullptr); + const std::vector expected_tags{1, 2, 3, 4}; + // The legacy result object reports absolute segment numbers even though the + // EX inputs above are tag-relative. The future facade converts results back + // to the stable PortDefinition order and addressing documented for WP0. + const std::vector expected_segments{6, 17, 28, 39}; + REQUIRE(input->get_tag() == expected_tags); + REQUIRE(input->get_segment() == expected_segments); + REQUIRE(input->get_voltage().size() == 4); + REQUIRE(input->get_current().size() == 4); + for (std::size_t index = 0; index < input->get_current().size(); ++index) { + INFO("port index " << index); + REQUIRE(finite_complex(input->get_voltage()[index])); + REQUIRE(finite_complex(input->get_current()[index])); + REQUIRE(std::abs(input->get_current()[index]) > 1.0e-8); + } +} + +TEST_CASE("WP0 port current is positive into the antenna", + "[wasm_api][numerical_contract]") +{ + nec_context model; + solve_center_fed_dipole(model); + nec_antenna_input* input = model.get_input_parameters(0); + REQUIRE(input != nullptr); + + const nec_complex voltage = input->get_voltage()[0]; + const nec_complex current = input->get_current()[0]; + const nec_float reported_power_w = input->get_power()[0]; + const nec_float contract_power_w = + 0.5 * std::real(voltage * std::conj(current)); + const nec_complex impedance = voltage / current; + + REQUIRE(voltage == nec_complex(1.0, 0.0)); + REQUIRE(current.real() > 0.0); + REQUIRE(reported_power_w > 0.0); + REQUIRE(reported_power_w == Catch::Approx(contract_power_w).epsilon(1.0e-12)); + REQUIRE(impedance.real() == Catch::Approx(model.get_impedance_real()).epsilon(1.0e-12)); + REQUIRE(impedance.imag() == Catch::Approx(model.get_impedance_imag()).epsilon(1.0e-12)); +} + +TEST_CASE("WP0 far-field range follows exp(-j k R) over R", + "[wasm_api][numerical_contract]") +{ + nec_context model; + model.initialize(); + add_z_dipole(model, 1, 0.0); + finish_free_space_geometry(model); + add_voltage_source(model, 1, nec_complex(1.0, 0.0)); + + constexpr nec_float radius_1_m = 1.0; + constexpr nec_float radius_2_m = 1.25; + model.rp_card( + 0, 1, 1, 0, 0, 0, 0, + 90.0, 0.0, 0.0, 0.0, radius_1_m, 0.0); + model.rp_card( + 0, 1, 1, 0, 0, 0, 0, + 90.0, 0.0, 0.0, 0.0, radius_2_m, 0.0); + + nec_radiation_pattern* field_1 = model.get_radiation_pattern(0); + nec_radiation_pattern* field_2 = model.get_radiation_pattern(1); + REQUIRE(field_1 != nullptr); + REQUIRE(field_2 != nullptr); + + const nec_complex e_theta_1 = field_1->get_e_theta()(0, 0); + const nec_complex e_theta_2 = field_2->get_e_theta()(0, 0); + REQUIRE(std::abs(e_theta_1) > 1.0e-12); + REQUIRE(finite_complex(e_theta_1)); + REQUIRE(finite_complex(e_theta_2)); + + const nec_float wavelength_m = em::get_wavelength(kFrequencyMHz * 1.0e6); + const nec_float phase_delta_rad = + -two_pi() * (radius_2_m - radius_1_m) / wavelength_m; + const nec_complex expected_ratio = + (radius_1_m / radius_2_m) * std::polar(1.0, phase_delta_rad); + const nec_complex actual_ratio = e_theta_2 / e_theta_1; + + REQUIRE(std::abs(actual_ratio - expected_ratio) < 1.0e-10); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d127ecd6..25233980 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,7 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/nec_context_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec2cpp_tb.cpp ${CMAKE_SOURCE_DIR}/src/radiation_input_tb.cpp + ${CMAKE_SOURCE_DIR}/src/wasm_api_contract_tb.cpp ) # Test runner = Catch2 main + test sources + nec2cpp.cpp (main renamed) + the From de0c4029a95f4895516c8a74ed64814e7a64f2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 10:44:22 +0200 Subject: [PATCH 17/46] WP1 --- docs/ts_engine_plan.md | 39 +++++ docs/wp1-native-engine.md | 64 +++++++ src/CMakeLists.txt | 2 + src/nec_context.cpp | 102 +++++++++++ src/nec_context.h | 40 ++++- src/nec_radiation_pattern.h | 6 +- src/nec_results.h | 37 +++- src/nec_stateful_model.cpp | 316 ++++++++++++++++++++++++++++++++++ src/nec_stateful_model.h | 157 +++++++++++++++++ src/nec_stateful_model_tb.cpp | 245 ++++++++++++++++++++++++++ tests/CMakeLists.txt | 17 +- 11 files changed, 1011 insertions(+), 14 deletions(-) create mode 100644 docs/wp1-native-engine.md create mode 100644 src/nec_stateful_model.cpp create mode 100644 src/nec_stateful_model.h create mode 100644 src/nec_stateful_model_tb.cpp diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 6915214f..ea907ccd 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -150,6 +150,8 @@ The next open package on the critical path is WP1. ## WP1 — Stateful native solver layer +**Status: complete (2026-08-28).** + Introduce an explicit stateful C++ layer above `nec_context`. It should not depend on parsing or formatting a NEC deck. Capabilities: @@ -184,6 +186,43 @@ DoD: - Cache invalidation is covered by deterministic unit tests. - The legacy string/deck API remains operational. +### WP1 progress + +- Added the installed native [`nec_stateful_model`](../src/nec_stateful_model.h) + layer above `nec_context`. It owns geometry, ordered tag-relative ports, + loads, ground, lifecycle state, preparation, retained factorization, exact + simultaneous complex voltage solves, latest port currents, and deterministic + factorization/solve generations without generating or parsing a deck. +- Split matrix fill/factorization from excitation in `nec_context` through a + narrow stateful hook. The direct excitation hook accepts exact zero volts, + bypassing the legacy EX-card near-zero substitution while leaving the card + and deck paths unchanged. +- Added explicit result replacement to `nec_results`. A new consumer solve + deletes prior input/field results before retaining its replacement; changing + only the far-field grid replaces just the radiation pattern and preserves + the latest port solution. +- Configuration mutations conservatively invalidate prepared state. Repeating + the same preparation is a no-op; frequency, ground, and load changes advance + the factorization generation; excitation changes advance only the solve + generation; far-field grid changes advance neither. +- Stored electromagnetic medium parameters per `nec_context` and reactivate + them at geometry, preparation, solve, and simulation boundaries. This makes + sequentially interleaved contexts deterministic. The remaining concurrency + boundary and static-state audit are documented in + [`wp1-native-engine.md`](wp1-native-engine.md). +- Added seven native Catch2 cases covering deck-free construction/solve, + exact-zero multi-port excitation, cache invalidation, far-field reuse, + duplicate/missing ports, interleaved contexts, and 1,000 repeated solves. + The stress case holds the factorization generation at one, advances the + solve generation to 1,000, and retains one native result. +- Validation completed on Windows/MSVC: all 77 legacy native cases pass (738 + assertions), all seven WP1 cases pass (54 assertions), the CLI smoke test + passes, and the strict TypeScript lifecycle/type tests remain green. CTest + registers the legacy and WP1 partitions independently so each has a bounded + timeout appropriate to its workload. + +The next open package on the critical path is WP2. + --- ## WP2 — Multi-port solving and impedance matrices diff --git a/docs/wp1-native-engine.md b/docs/wp1-native-engine.md new file mode 100644 index 00000000..95cd4319 --- /dev/null +++ b/docs/wp1-native-engine.md @@ -0,0 +1,64 @@ +# WP1 native stateful engine + +`nec_stateful_model` is the deck-free native solver layer used by the future +C/WASM ABI. It owns a single `nec_context`; callers construct geometry and +register ports programmatically, then explicitly prepare and repeatedly solve +against a retained interaction-matrix factorization. + +The public header is [`src/nec_stateful_model.h`](../src/nec_stateful_model.h). +It is part of the installed C++ development surface. The lower-level hooks in +`nec_context` exist only to split preparation from excitation and to preserve +the legacy NEC card API. + +## Cache and result ownership + +The factorization generation advances only after a successful matrix fill and +factorization. An unchanged `prepare()` is a true no-op, including from the +solved state. Changing frequency, ground, or loads invalidates prepared data; +the next successful preparation advances the generation. A voltage solve +advances only the solve generation. + +Each consumer solve replaces the previous native result collection. A raw WP1 +far-field calculation retains the current antenna-input result and replaces +only the prior radiation-pattern result. Thus repeated solves and repeated +field grids have bounded native ownership. WP3 will copy the complex field +components into its stable bulk result type; the raw WP1 radiation-pattern +reference is deliberately temporary. + +Exact zero-valued voltage sources are preserved by the stateful excitation +hook. This differs intentionally from the legacy `EX` card compatibility path, +which continues replacing a near-zero source voltage with one volt. + +## Mutable global-state audit + +Most solver state—including geometry, ground, loads, factorized matrices, +sources, currents, and results—is already owned by `nec_context`. The mutable +process-wide electromagnetic permittivity and permeability were the one +model-configurable exception. WP1 now stores those settings on each context and +reactivates them synchronously at every numerical operation boundary. The +interleaved-context test alternates models at different frequencies and proves +that returning to the first context reproduces its current without a refactor. + +Some numerical helper functions also contain lazily initialized, read-only +lookup tables. Their values do not vary by model after initialization, but the +native library does not promise concurrent calls on different contexts from +multiple threads. Sequential interleaving is supported. Browser parallelism +must use the separate modular Emscripten instances/workers planned by WP5 and +WP6; each worker/module has its own WASM globals. A future pthread-enabled build +would require a separate thread-safety review before sharing one module across +concurrent solves. + +## Verification + +The WP1 Catch2 cases live in +[`src/nec_stateful_model_tb.cpp`](../src/nec_stateful_model_tb.cpp). They cover: + +- programmatic geometry, ordered ports, prepare, and solve without a deck; +- repeated complex voltage excitations, including an exact zero source; +- deterministic frequency, ground, load, excitation, and far-field generations; +- invalid and duplicate ports with unchanged lifecycle state; +- sequentially interleaved independent contexts; +- 1,000 solves with one factorization and a one-result ownership bound. + +The existing deck parser, C API, CLI, native numerical tests, and WASM smoke +path continue to use the legacy `nec_context` card methods. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1876ac38..19f62199 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,6 +24,7 @@ set(NECPP_LIB_SRCS nec_output.cpp nec_radiation_pattern.cpp nec_results.cpp + nec_stateful_model.cpp nec_structure_currents.cpp ) @@ -35,6 +36,7 @@ set(NECPP_PUBLIC_HEADERS nec_ground.h nec_radiation_pattern.h nec_results.h + nec_stateful_model.h nec_structure_currents.h nec_output.h nec_exception.h diff --git a/src/nec_context.cpp b/src/nec_context.cpp index ddac1e4e..0af0c993 100644 --- a/src/nec_context.cpp +++ b/src/nec_context.cpp @@ -68,11 +68,20 @@ nec_context::nec_context() : fnorm(0,0), current_vector(0) { /*structure_currents is a pointer to the "nec_base_result" which takes care of storing and printing of the currents*/ structure_currents = NULL; + + m_medium_permittivity = 8.854e-12; + m_medium_permeability = four_pi() * 1.0e-7; // allocate the ground grid initialize(); } +void nec_context::activate_medium_parameters() +{ + em::constants::permittivity = m_medium_permittivity; + em::constants::permeability = m_medium_permeability; +} + nec_context::~nec_context() { } @@ -293,10 +302,101 @@ This function prepares for a calculation by calling calc_prepare(). */ void nec_context::geometry_complete(int gpflag) { DEBUG_TRACE("geometry_complete()"); + activate_medium_parameters(); m_geometry->geometry_complete(this, gpflag); calc_prepare(); } +void nec_context::stateful_prepare_frequency(nec_float frequency_mhz) +{ + if (!(frequency_mhz > 0.0) || !std::isfinite(frequency_mhz)) + throw nec_exception("FREQUENCY MUST BE A POSITIVE FINITE VALUE IN MHZ"); + + activate_medium_parameters(); + fr_card(0, 1, frequency_mhz, 0.0); + + const int64_t matrix_size = + m_geometry->n_plus_2m * (m_geometry->np + 3 * m_geometry->mp); + cm.resize(matrix_size); + + nop = int32_t(neq / npeq); + symmetry_array.resize(nop * nop); + fblock(npeq, neq, matrix_size, m_geometry->m_ipsym); + + _wavelength = em::get_wavelength(1.0e6 * freq_mhz); + m_geometry->frequency_scale(freq_mhz); + processing_state = PROCESSING_STRUCTURE_LOADING; + structure_segment_loading(); + + // Stateful solves own their result lifetime and do not request the verbose + // structure-current result produced by the legacy PT default. + pt_card(-1, 0, 0, 0); + m_near = -1; + ifar = -1; + processing_state = PROCESSING_EXCITATION_SETUP; + ntsol = 0; +} + +void nec_context::stateful_solve_voltage_sources( + const std::vector& absolute_segments, + const std::vector& voltages) +{ + if (absolute_segments.empty() || absolute_segments.size() != voltages.size()) + throw nec_exception("STATEFUL VOLTAGE SOURCE DIMENSIONS DO NOT MATCH"); + + activate_medium_parameters(); + init_voltage_sources(); + voltage_source_count = static_cast(absolute_segments.size()); + source_segment_array.resize(voltage_source_count); + source_voltage_array.resize(voltage_source_count); + + for (int index = 0; index < voltage_source_count; ++index) { + const int segment = absolute_segments[static_cast(index)]; + if (segment <= 0 || segment > m_geometry->n_segments) + throw nec_exception("STATEFUL VOLTAGE SOURCE SEGMENT IS OUT OF RANGE"); + const nec_complex voltage = voltages[static_cast(index)]; + if (!std::isfinite(voltage.real()) || !std::isfinite(voltage.imag())) + throw nec_exception("STATEFUL VOLTAGE SOURCE MUST BE FINITE"); + source_segment_array[index] = segment; + source_voltage_array[index] = voltage; + } + + m_excitation_type = EXCITATION_VOLTAGE; + masym = 0; + iped = 0; + iflow = 5; + ntsol = 0; + m_near = -1; + ifar = -1; + processing_state = PROCESSING_EXCITATION_SETUP; + nthic = 1; + nphic = 1; + inc = 1; + nprint = 0; + + excitation_return result = excitation_process_inner(1); + if (result != FREQ_PHASE_COMPLETE) + throw nec_exception("UNEXPECTED STATEFUL EXCITATION LOOP CONTROL RESULT"); + + // The latest currents are now available for a subsequent far-field-only + // pass without repeating excitation or factorization. + processing_state = PROCESSING_NEAR_FIELD; +} + +void nec_context::stateful_clear_loads() +{ + nload = 0; + ldtyp.resize(0); + ldtag.resize(0); + ldtagf.resize(0); + ldtagt.resize(0); + zlr.resize(0); + zli.resize(0); + zlc.resize(0); + iflow = 3; + reset_processing_to_structure_loading(); +} + /*! Add a wire to the geometry, All co-ordinates are in meters. @@ -1031,6 +1131,8 @@ void nec_context::pl_card(const char* ploutput_filename, int itmp1, int itmp2, i void nec_context::simulate(bool far_field_flag) { DEBUG_TRACE("simulate(" << far_field_flag << ")"); + activate_medium_parameters(); + /* Allocate the normalization buffer */ if ( iped ) fnorm.resize(nfrq,4); diff --git a/src/nec_context.h b/src/nec_context.h index 9e4a9191..1311d385 100644 --- a/src/nec_context.h +++ b/src/nec_context.h @@ -87,6 +87,32 @@ class nec_context void calc_prepare(); + + /*! Fill and factor the interaction matrix without executing an excitation. + * + * This is the narrow native hook used by nec_stateful_model. Unlike an FR + * card followed by XQ, it stops at excitation setup so subsequent voltage + * right-hand sides reuse the retained LU factorization. + */ + void stateful_prepare_frequency(nec_float frequency_mhz); + + /*! Solve one exact simultaneous voltage-source right-hand side. + * + * Segment numbers are absolute and one-based. Zero-valued sources are + * retained exactly; this intentionally bypasses ex_card()'s legacy + * near-zero-to-one substitution. + */ + void stateful_solve_voltage_sources( + const std::vector& absolute_segments, + const std::vector& voltages); + + /*! Clear load cards without relying on LD card sequencing state. */ + void stateful_clear_loads(); + + /*! Result ownership helpers for the stateful layer. */ + void stateful_clear_results() { m_results.clear(); } + void stateful_clear_results(enum nec_result_type type) { m_results.erase(type); } + size_t stateful_result_count() const { return m_results.size(); } void reset_processing_to_structure_loading() { switch (processing_state) { @@ -376,8 +402,10 @@ class nec_context From these parameters a speed of light is chosen. */ void medium_parameters(nec_float permittivity, nec_float permeability) { - em::constants::permittivity = permittivity; - em::constants::permeability = permeability; + if (!(permittivity > 0.0) || !(permeability > 0.0)) + throw nec_exception("MEDIUM PERMITTIVITY AND PERMEABILITY MUST BE POSITIVE"); + m_medium_permittivity = permittivity; + m_medium_permeability = permeability; } @@ -977,6 +1005,14 @@ class nec_context nec_float xpr1, xpr2, xpr3, xpr4, xpr5, xpr7; nec_structure_currents* structure_currents; + + // The numerical kernels still read electromagnetic constants through the + // legacy em namespace. Keeping the requested values on each context and + // activating them at operation boundaries prevents sequentially + // interleaved models from contaminating one another. + nec_float m_medium_permittivity; + nec_float m_medium_permeability; + void activate_medium_parameters(); void load(); diff --git a/src/nec_radiation_pattern.h b/src/nec_radiation_pattern.h index 2cd60188..4593add6 100644 --- a/src/nec_radiation_pattern.h +++ b/src/nec_radiation_pattern.h @@ -135,7 +135,7 @@ class nec_radiation_pattern : public nec_base_result /*! \brief Return a complex array for the electric field E(THETA) */ - complex_array get_e_theta() { + complex_array get_e_theta() const { return _e_theta; } @@ -153,11 +153,11 @@ class nec_radiation_pattern : public nec_base_result /*! \brief Return a complex array for the electric field E(PHI) */ - complex_array get_e_phi() { + complex_array get_e_phi() const { return _e_phi; } - complex_array get_e_r() { + complex_array get_e_r() const { return _e_r; } diff --git a/src/nec_results.h b/src/nec_results.h index c3883939..efb408b7 100644 --- a/src/nec_results.h +++ b/src/nec_results.h @@ -689,11 +689,7 @@ class nec_results { // On destruction we write to a file. ~nec_results() { - // write_to_file(); - for (int i=0;i<_n;i++) { - delete _results[i]; - _results[i] = NULL; - } + clear(); } void add(nec_base_result* br) { @@ -701,6 +697,37 @@ class nec_results { _results.push_back(br); _n++; } + + /*! Delete every retained result. + * + * The deck-oriented API deliberately accumulates results across frequency + * sweeps. Stateful consumers instead call this before a new consumer + * solve so the native collection has bounded size. + */ + void clear() { + for (nec_base_result* result : _results) + delete result; + _results.clear(); + _n = 0; + } + + /*! Delete retained results of one type, preserving all other types. */ + void erase(enum nec_result_type result_type) { + auto it = _results.begin(); + while (it != _results.end()) { + if ((*it)->get_result_type() == result_type) { + delete *it; + it = _results.erase(it); + } else { + ++it; + } + } + _n = static_cast(_results.size()); + } + + size_t size() const { + return _results.size(); + } /*!\brief Get the nth result that matches the specified result type \param index The zero-based index for the result diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp new file mode 100644 index 00000000..f5f2ae3d --- /dev/null +++ b/src/nec_stateful_model.cpp @@ -0,0 +1,316 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#include "nec_stateful_model.h" + +#include "c_geometry.h" +#include "nec_context.h" +#include "nec_exception.h" +#include "nec_radiation_pattern.h" +#include "nec_results.h" + +#include +#include +#include +#include + +namespace { + +void fail(const char* operation, const char* reason) +{ + nec_exception error("STATEFUL MODEL "); + error.append(operation); + error.append(": "); + error.append(reason); + throw error; +} + +bool finite(nec_float value) +{ + return std::isfinite(value); +} + +} // namespace + +nec_stateful_model::nec_stateful_model() + : m_context(std::make_unique()) +{ +} + +nec_stateful_model::~nec_stateful_model() = default; +nec_stateful_model::nec_stateful_model(nec_stateful_model&&) noexcept = default; +nec_stateful_model& nec_stateful_model::operator=(nec_stateful_model&&) noexcept = default; + +void nec_stateful_model::require_state( + nec_model_state expected, const char* operation) const +{ + if (m_state != expected) + fail(operation, "ILLEGAL LIFECYCLE STATE"); +} + +void nec_stateful_model::require_configurable(const char* operation) const +{ + if (m_state != nec_model_state::geometry_complete && + m_state != nec_model_state::prepared && + m_state != nec_model_state::solved) + fail(operation, "REQUIRES COMPLETED GEOMETRY"); +} + +void nec_stateful_model::invalidate_factorization() +{ + m_configuration_dirty = true; + m_frequency_mhz = 0.0; + m_port_currents.clear(); + m_context->stateful_clear_results(); + m_state = nec_model_state::geometry_complete; +} + +void nec_stateful_model::add_wire(const nec_wire_definition& wire) +{ + if (m_state != nec_model_state::empty && + m_state != nec_model_state::geometry_building) + fail("ADD WIRE", "GEOMETRY IS ALREADY COMPLETE"); + if (wire.tag <= 0 || wire.segments <= 0) + fail("ADD WIRE", "TAG AND SEGMENT COUNT MUST BE POSITIVE"); + if (!finite(wire.x1) || !finite(wire.y1) || !finite(wire.z1) || + !finite(wire.x2) || !finite(wire.y2) || !finite(wire.z2) || + !finite(wire.radius_m) || !(wire.radius_m > 0.0)) + fail("ADD WIRE", "COORDINATES AND POSITIVE RADIUS MUST BE FINITE"); + if (wire.x1 == wire.x2 && wire.y1 == wire.y2 && wire.z1 == wire.z2) + fail("ADD WIRE", "ENDPOINTS MUST BE DISTINCT"); + + m_context->wire( + wire.tag, wire.segments, + wire.x1, wire.y1, wire.z1, + wire.x2, wire.y2, wire.z2, + wire.radius_m, 1.0, 1.0); + m_state = nec_model_state::geometry_building; +} + +void nec_stateful_model::complete_geometry(nec_ground_connection connection) +{ + require_state(nec_model_state::geometry_building, "COMPLETE GEOMETRY"); + const int flag = static_cast(connection); + if (flag < 0 || flag > 2) + fail("COMPLETE GEOMETRY", "UNKNOWN GROUND CONNECTION MODE"); + m_context->geometry_complete(flag); + m_state = nec_model_state::geometry_complete; + m_configuration_dirty = true; +} + +void nec_stateful_model::define_ports( + const std::vector& ports) +{ + require_state(nec_model_state::geometry_complete, "DEFINE PORTS"); + if (ports.empty()) + fail("DEFINE PORTS", "AT LEAST ONE PORT IS REQUIRED"); + + std::set> unique_ports; + std::set unique_absolute_segments; + std::vector absolute_segments; + absolute_segments.reserve(ports.size()); + + for (const nec_port_definition& port : ports) { + if (port.tag <= 0 || port.segment <= 0) + fail("DEFINE PORTS", "TAG AND SEGMENT MUST BE POSITIVE"); + if (!unique_ports.emplace(port.tag, port.segment).second) + fail("DEFINE PORTS", "DUPLICATE TAG/SEGMENT PAIR"); + + const int64_t absolute = + m_context->get_geometry()->get_segment_number(port.tag, port.segment); + if (!unique_absolute_segments.insert(static_cast(absolute)).second) + fail("DEFINE PORTS", "PORTS RESOLVE TO THE SAME PHYSICAL SEGMENT"); + absolute_segments.push_back(static_cast(absolute)); + } + + m_ports = ports; + m_absolute_port_segments = std::move(absolute_segments); +} + +void nec_stateful_model::validate_load_target( + const nec_load_definition& load) const +{ + if (load.tag < 0 || load.first_segment < 0 || load.last_segment < 0) + fail("ADD LOAD", "SEGMENT SELECTION CANNOT BE NEGATIVE"); + if (load.first_segment == 0 && load.last_segment != 0) + fail("ADD LOAD", "LAST SEGMENT REQUIRES A FIRST SEGMENT"); + if (load.first_segment != 0 && load.last_segment != 0 && + load.last_segment < load.first_segment) + fail("ADD LOAD", "SEGMENT RANGE IS REVERSED"); + + c_geometry* geometry = m_context->get_geometry(); + if (load.tag == 0) { + const int first = load.first_segment == 0 ? 1 : load.first_segment; + const int last = load.last_segment == 0 + ? (load.first_segment == 0 ? static_cast(geometry->n_segments) : first) + : load.last_segment; + if (first <= 0 || last > geometry->n_segments) + fail("ADD LOAD", "ABSOLUTE SEGMENT TARGET IS OUT OF RANGE"); + } else { + const int first = load.first_segment == 0 ? 1 : load.first_segment; + geometry->get_segment_number(load.tag, first); + if (load.last_segment != 0) + geometry->get_segment_number(load.tag, load.last_segment); + } +} + +void nec_stateful_model::add_load(const nec_load_definition& load) +{ + require_configurable("ADD LOAD"); + validate_load_target(load); + const int load_kind = static_cast(load.kind); + if (load_kind < 0 || load_kind > 5) + fail("ADD LOAD", "UNKNOWN LOAD KIND"); + if (!finite(load.value1) || !finite(load.value2) || !finite(load.value3)) + fail("ADD LOAD", "LOAD VALUES MUST BE FINITE"); + + const int last_segment = + load.first_segment != 0 && load.last_segment == 0 + ? load.first_segment + : load.last_segment; + + m_context->ld_card( + load_kind, load.tag, + load.first_segment, last_segment, + load.value1, load.value2, load.value3); + invalidate_factorization(); +} + +void nec_stateful_model::clear_loads() +{ + require_configurable("CLEAR LOADS"); + m_context->stateful_clear_loads(); + invalidate_factorization(); +} + +void nec_stateful_model::set_ground(const nec_ground_definition& ground) +{ + require_configurable("SET GROUND"); + + int ground_type = -1; + switch (ground.kind) { + case nec_ground_kind::free_space: + break; + case nec_ground_kind::perfect: + ground_type = 1; + break; + case nec_ground_kind::finite_reflection_coefficient: + ground_type = 0; + break; + case nec_ground_kind::finite_sommerfeld_norton: + ground_type = 2; + break; + default: + fail("SET GROUND", "UNKNOWN GROUND KIND"); + } + + if (ground_type == 0 || ground_type == 2) { + if (!finite(ground.relative_permittivity) || + !finite(ground.conductivity_s_per_m) || + !(ground.relative_permittivity > 0.0) || + !(ground.conductivity_s_per_m > 0.0)) + fail("SET GROUND", "FINITE GROUND PARAMETERS MUST BE POSITIVE AND FINITE"); + } + + m_context->gn_card( + ground_type, 0, + ground.relative_permittivity, ground.conductivity_s_per_m, + 0.0, 0.0, 0.0, 0.0); + invalidate_factorization(); +} + +void nec_stateful_model::prepare(nec_float frequency_mhz) +{ + require_configurable("PREPARE"); + if (m_ports.empty()) + fail("PREPARE", "PORTS HAVE NOT BEEN DEFINED"); + if (!finite(frequency_mhz) || !(frequency_mhz > 0.0)) + fail("PREPARE", "FREQUENCY MUST BE POSITIVE AND FINITE"); + + if (!m_configuration_dirty && m_frequency_mhz == frequency_mhz) + return; + + // A failed fill/factorization cannot leave the old prepared state exposed: + // the context matrix may already have been overwritten. The geometry and + // environment remain reusable for a later prepare attempt. + m_context->stateful_clear_results(); + m_configuration_dirty = true; + m_frequency_mhz = 0.0; + m_port_currents.clear(); + m_state = nec_model_state::geometry_complete; + m_context->stateful_prepare_frequency(frequency_mhz); + m_frequency_mhz = frequency_mhz; + ++m_factorization_generation; + m_configuration_dirty = false; + m_port_currents.clear(); + m_state = nec_model_state::prepared; +} + +const std::vector& nec_stateful_model::solve_port_voltages( + const std::vector& voltages) +{ + if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) + fail("SOLVE PORT VOLTAGES", "MODEL IS NOT PREPARED"); + if (voltages.size() != m_ports.size()) + fail("SOLVE PORT VOLTAGES", "VOLTAGE COUNT MUST MATCH PORT COUNT"); + for (const nec_complex voltage : voltages) { + if (!finite(voltage.real()) || !finite(voltage.imag())) + fail("SOLVE PORT VOLTAGES", "VOLTAGES MUST BE FINITE"); + } + + m_context->stateful_clear_results(); + try { + m_context->stateful_solve_voltage_sources(m_absolute_port_segments, voltages); + } catch (...) { + // Back-substitution does not alter the retained LU factorization. A + // failed solve therefore discards only the consumer solution. + m_port_currents.clear(); + m_state = nec_model_state::prepared; + throw; + } + + nec_antenna_input* input = m_context->get_input_parameters(0); + if (input == nullptr) + fail("SOLVE PORT VOLTAGES", "ENGINE DID NOT RETURN PORT INPUT DATA"); + m_port_currents = input->get_current(); + if (m_port_currents.size() != m_ports.size()) + fail("SOLVE PORT VOLTAGES", "ENGINE RETURNED THE WRONG PORT COUNT"); + + ++m_solve_generation; + m_state = nec_model_state::solved; + return m_port_currents; +} + +const nec_radiation_pattern& nec_stateful_model::compute_far_field( + const nec_far_field_grid& grid) +{ + require_state(nec_model_state::solved, "COMPUTE FAR FIELD"); + if (!finite(grid.radius_m) || !(grid.radius_m > 0.0) || + !finite(grid.theta_start_deg) || !finite(grid.theta_step_deg) || + !finite(grid.phi_start_deg) || !finite(grid.phi_step_deg) || + grid.theta_count <= 0 || grid.phi_count <= 0) + fail("COMPUTE FAR FIELD", "GRID VALUES ARE INVALID"); + + m_context->stateful_clear_results(RESULT_RADIATION_PATTERN); + m_context->rp_card( + 0, grid.theta_count, grid.phi_count, + 0, 0, 0, 0, + grid.theta_start_deg, grid.phi_start_deg, + grid.theta_step_deg, grid.phi_step_deg, + grid.radius_m, 0.0); + + nec_radiation_pattern* result = m_context->get_radiation_pattern(0); + if (result == nullptr) + fail("COMPUTE FAR FIELD", "ENGINE DID NOT RETURN A RADIATION PATTERN"); + return *result; +} + +size_t nec_stateful_model::retained_result_count() const +{ + return m_context->stateful_result_count(); +} diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h new file mode 100644 index 00000000..8f4fb290 --- /dev/null +++ b/src/nec_stateful_model.h @@ -0,0 +1,157 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#pragma once + +#include "common.h" + +#include +#include +#include +#include + +class nec_context; +class nec_radiation_pattern; + +enum class nec_model_state { + empty, + geometry_building, + geometry_complete, + prepared, + solved, +}; + +struct nec_wire_definition { + int tag = 0; + int segments = 0; + nec_float x1 = 0.0; + nec_float y1 = 0.0; + nec_float z1 = 0.0; + nec_float x2 = 0.0; + nec_float y2 = 0.0; + nec_float z2 = 0.0; + nec_float radius_m = 0.0; +}; + +enum class nec_ground_connection { + none = 0, + interpolate = 1, + zero_current = 2, +}; + +struct nec_port_definition { + int tag = 0; + int segment = 0; +}; + +enum class nec_load_kind { + series_rlc = 0, + parallel_rlc = 1, + distributed_series_rlc = 2, + distributed_parallel_rlc = 3, + impedance = 4, + conductivity = 5, +}; + +struct nec_load_definition { + nec_load_kind kind = nec_load_kind::series_rlc; + int tag = 0; + int first_segment = 0; + int last_segment = 0; + nec_float value1 = 0.0; + nec_float value2 = 0.0; + nec_float value3 = 0.0; +}; + +enum class nec_ground_kind { + free_space, + perfect, + finite_reflection_coefficient, + finite_sommerfeld_norton, +}; + +struct nec_ground_definition { + nec_ground_kind kind = nec_ground_kind::free_space; + nec_float relative_permittivity = 0.0; + nec_float conductivity_s_per_m = 0.0; +}; + +struct nec_far_field_grid { + nec_float radius_m = 1.0; + nec_float theta_start_deg = 0.0; + int theta_count = 1; + nec_float theta_step_deg = 0.0; + nec_float phi_start_deg = 0.0; + int phi_count = 1; + nec_float phi_step_deg = 0.0; +}; + +/*! A stateful, deck-free native solver above nec_context. + * + * The class owns one context and one retained interaction-matrix + * factorization. Configuration mutations invalidate the factorization; + * voltage and far-field changes do not. + */ +class nec_stateful_model { +public: + nec_stateful_model(); + ~nec_stateful_model(); + + nec_stateful_model(const nec_stateful_model&) = delete; + nec_stateful_model& operator=(const nec_stateful_model&) = delete; + nec_stateful_model(nec_stateful_model&&) noexcept; + nec_stateful_model& operator=(nec_stateful_model&&) noexcept; + + nec_model_state state() const { return m_state; } + + void add_wire(const nec_wire_definition& wire); + void complete_geometry( + nec_ground_connection connection = nec_ground_connection::none); + void define_ports(const std::vector& ports); + + void add_load(const nec_load_definition& load); + void clear_loads(); + void set_ground(const nec_ground_definition& ground); + + void prepare(nec_float frequency_mhz); + const std::vector& solve_port_voltages( + const std::vector& voltages); + + /*! Calculate a raw NEC radiation-pattern result for the latest solution. + * + * WP3 supplies the stable copied complex-field API. This WP1 hook exists + * to exercise far-field sampling against the retained factorization. The + * reference remains valid until the next solve or far-field call. + */ + const nec_radiation_pattern& compute_far_field(const nec_far_field_grid& grid); + + const std::vector& ports() const { return m_ports; } + const std::vector& port_currents() const { return m_port_currents; } + nec_float frequency_mhz() const { return m_frequency_mhz; } + uint64_t factorization_generation() const { return m_factorization_generation; } + uint64_t solve_generation() const { return m_solve_generation; } + + /*! Diagnostic used to prove result replacement stays bounded. */ + size_t retained_result_count() const; + +private: + void require_state(nec_model_state expected, const char* operation) const; + void require_configurable(const char* operation) const; + void invalidate_factorization(); + void validate_load_target(const nec_load_definition& load) const; + + std::unique_ptr m_context; + nec_model_state m_state = nec_model_state::empty; + std::vector m_ports; + std::vector m_absolute_port_segments; + std::vector m_port_currents; + nec_float m_frequency_mhz = 0.0; + uint64_t m_factorization_generation = 0; + uint64_t m_solve_generation = 0; + bool m_configuration_dirty = true; +}; diff --git a/src/nec_stateful_model_tb.cpp b/src/nec_stateful_model_tb.cpp new file mode 100644 index 00000000..8984aeac --- /dev/null +++ b/src/nec_stateful_model_tb.cpp @@ -0,0 +1,245 @@ +#include +#include + +#include "nec_exception.h" +#include "nec_radiation_pattern.h" +#include "nec_stateful_model.h" + +#include +#include +#include +#include +#include + +namespace { + +nec_wire_definition dipole_wire(int tag = 1, nec_float x_m = 0.0) +{ + return { + tag, 11, + x_m, 0.0, -0.25, + x_m, 0.0, 0.25, + 0.001, + }; +} + +void build_dipole(nec_stateful_model& model) +{ + model.add_wire(dipole_wire()); + model.complete_geometry(); + model.define_ports({{1, 6}}); +} + +nec_complex solve_dipole(nec_stateful_model& model, nec_float frequency_mhz) +{ + model.prepare(frequency_mhz); + return model.solve_port_voltages({nec_complex(1.0, 0.0)})[0]; +} + +bool finite_complex(nec_complex value) +{ + return std::isfinite(value.real()) && std::isfinite(value.imag()); +} + +class scoped_cout_sink { +public: + scoped_cout_sink() + : previous(std::cout.rdbuf(sink.rdbuf())) + { + } + + ~scoped_cout_sink() + { + std::cout.rdbuf(previous); + } + +private: + std::ostringstream sink; + std::streambuf* previous; +}; + +} // namespace + +TEST_CASE("WP1 stateful model constructs and solves without a deck", + "[wasm_api][wp1][stateful]") +{ + nec_stateful_model model; + REQUIRE(model.state() == nec_model_state::empty); + model.add_wire(dipole_wire()); + REQUIRE(model.state() == nec_model_state::geometry_building); + model.complete_geometry(); + REQUIRE(model.state() == nec_model_state::geometry_complete); + model.define_ports({{1, 6}}); + + model.prepare(300.0); + REQUIRE(model.state() == nec_model_state::prepared); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 0); + + const std::vector* currents_ptr = nullptr; + try { + currents_ptr = &model.solve_port_voltages({nec_complex(1.0, 0.0)}); + } catch (const nec_exception& error) { + FAIL(error.get_message()); + } + const std::vector& currents = *currents_ptr; + REQUIRE(model.state() == nec_model_state::solved); + REQUIRE(currents.size() == 1); + REQUIRE(finite_complex(currents[0])); + REQUIRE(currents[0].real() > 0.0); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.retained_result_count() == 1); +} + +TEST_CASE("WP1 repeated excitations retain the factorization and exact zero sources", + "[wasm_api][wp1][cache]") +{ + nec_stateful_model model; + model.add_wire(dipole_wire(1, 0.0)); + model.add_wire(dipole_wire(2, 0.20)); + model.complete_geometry(); + model.define_ports({{1, 6}, {2, 6}}); + model.prepare(300.0); + + const std::vector first = model.solve_port_voltages({ + nec_complex(1.0, 0.0), + nec_complex(0.0, 0.0), + }); + const std::vector second = model.solve_port_voltages({ + nec_complex(0.5, 0.25), + nec_complex(-0.2, 0.1), + }); + + REQUIRE(first.size() == 2); + REQUIRE(second.size() == 2); + REQUIRE(finite_complex(first[0])); + REQUIRE(finite_complex(first[1])); + REQUIRE(std::abs(first[1]) > 1.0e-8); + REQUIRE(second != first); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 2); + REQUIRE(model.retained_result_count() == 1); +} + +TEST_CASE("WP1 preparation invalidation has deterministic generations", + "[wasm_api][wp1][cache]") +{ + nec_stateful_model model; + build_dipole(model); + + model.prepare(300.0); + model.solve_port_voltages({nec_complex(1.0, 0.0)}); + model.prepare(300.0); + REQUIRE(model.state() == nec_model_state::solved); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + + model.prepare(301.0); + REQUIRE(model.state() == nec_model_state::prepared); + REQUIRE(model.factorization_generation() == 2); + REQUIRE(model.solve_generation() == 1); + + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); + REQUIRE(model.state() == nec_model_state::geometry_complete); + model.prepare(301.0); + REQUIRE(model.factorization_generation() == 3); + + model.add_load({ + nec_load_kind::impedance, + 1, 6, 0, + 10.0, 5.0, 0.0, + }); + model.prepare(301.0); + REQUIRE(model.factorization_generation() == 4); + + model.clear_loads(); + model.prepare(301.0); + REQUIRE(model.factorization_generation() == 5); +} + +TEST_CASE("WP1 far-field grids do not invalidate the factorization", + "[wasm_api][wp1][cache][far_field]") +{ + nec_stateful_model model; + build_dipole(model); + solve_dipole(model, 300.0); + + const nec_radiation_pattern& first = model.compute_far_field({ + 1.0, 0.0, 3, 45.0, 0.0, 2, 90.0, + }); + REQUIRE(first.get_e_theta().size() == 6); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.retained_result_count() == 2); + + const nec_radiation_pattern& second = model.compute_far_field({ + 2.0, 10.0, 2, 20.0, 15.0, 3, 30.0, + }); + REQUIRE(second.get_e_theta().size() == 6); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.retained_result_count() == 2); +} + +TEST_CASE("WP1 invalid and duplicate ports fail without changing state", + "[wasm_api][wp1][ports]") +{ + nec_stateful_model duplicate; + duplicate.add_wire(dipole_wire()); + duplicate.complete_geometry(); + REQUIRE_THROWS_AS( + duplicate.define_ports({{1, 6}, {1, 6}}), + nec_exception); + REQUIRE(duplicate.state() == nec_model_state::geometry_complete); + + nec_stateful_model missing; + missing.add_wire(dipole_wire()); + missing.complete_geometry(); + REQUIRE_THROWS_AS(missing.define_ports({{9, 1}}), nec_exception); + REQUIRE(missing.state() == nec_model_state::geometry_complete); +} + +TEST_CASE("WP1 interleaved contexts do not contaminate one another", + "[wasm_api][wp1][isolation]") +{ + nec_stateful_model first; + nec_stateful_model second; + build_dipole(first); + build_dipole(second); + + const nec_complex first_before = solve_dipole(first, 300.0); + const nec_complex second_current = solve_dipole(second, 450.0); + const nec_complex first_after = + first.solve_port_voltages({nec_complex(1.0, 0.0)})[0]; + + REQUIRE(finite_complex(second_current)); + REQUIRE(first_after.real() == Catch::Approx(first_before.real()).epsilon(1.0e-12)); + REQUIRE(first_after.imag() == Catch::Approx(first_before.imag()).epsilon(1.0e-12)); + REQUIRE(first.factorization_generation() == 1); + REQUIRE(second.factorization_generation() == 1); +} + +TEST_CASE("WP1 one thousand solves keep native results bounded", + "[wasm_api][wp1][cache][stress]") +{ + nec_stateful_model model; + build_dipole(model); + model.prepare(300.0); + + { + // Bounds-checking builds trace every solve. Keep the mandated stress + // test from flooding CI logs while retaining all 1,000 executions. + scoped_cout_sink silence_debug_trace; + for (int iteration = 0; iteration < 1000; ++iteration) { + const nec_float phase = static_cast(iteration) * 0.01; + model.solve_port_voltages({std::polar(1.0, phase)}); + } + } + + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1000); + REQUIRE(model.retained_result_count() == 1); + REQUIRE(model.port_currents().size() == 1); + REQUIRE(finite_complex(model.port_currents()[0])); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 25233980..4b0b0982 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,7 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/nec2cpp_tb.cpp ${CMAKE_SOURCE_DIR}/src/radiation_input_tb.cpp ${CMAKE_SOURCE_DIR}/src/wasm_api_contract_tb.cpp + ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_tb.cpp ) # Test runner = Catch2 main + test sources + nec2cpp.cpp (main renamed) + the @@ -67,6 +68,7 @@ add_executable(nec2++_tests ${CMAKE_SOURCE_DIR}/src/nec_output.cpp ${CMAKE_SOURCE_DIR}/src/nec_radiation_pattern.cpp ${CMAKE_SOURCE_DIR}/src/nec_results.cpp + ${CMAKE_SOURCE_DIR}/src/nec_stateful_model.cpp ${CMAKE_SOURCE_DIR}/src/nec_structure_currents.cpp ) @@ -88,12 +90,19 @@ if(UNIX AND NOT APPLE) target_link_libraries(nec2++_tests PRIVATE m) endif() -# Run all test cases. +# Keep the long-established numerical suite and WP1's explicit 1,000-solve +# stress suite on independent timeout budgets. Otherwise a slow numerical +# fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests -s) + COMMAND nec2++_tests "~[wp1]") +add_test(NAME necpp_wp1 + COMMAND nec2++_tests "[wp1]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of -# blocking the CI runner for its full timeout ceiling. -set_tests_properties(necpp_unit PROPERTIES TIMEOUT 300) +# blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a +# 1,000-excitation retained-factorization stress case; it completes in a few +# seconds on Windows. The legacy numerical aggregate needs a wider allowance. +set_tests_properties(necpp_unit PROPERTIES TIMEOUT 480) +set_tests_properties(necpp_wp1 PROPERTIES TIMEOUT 180) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From 6ed0c64fb2996e2e0db104c9b6164cd5dacf5fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 11:04:32 +0200 Subject: [PATCH 18/46] WP2 --- docs/ts_engine_plan.md | 39 ++++- docs/wp2-port-engine.md | 79 +++++++++ src/CMakeLists.txt | 1 + src/nec_port_matrix.cpp | 85 ++++++++++ src/nec_port_matrix.h | 30 ++++ src/nec_stateful_model.cpp | 258 ++++++++++++++++++++++++++-- src/nec_stateful_model.h | 78 +++++++++ src/nec_stateful_model_wp2_tb.cpp | 272 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 7 +- 9 files changed, 830 insertions(+), 19 deletions(-) create mode 100644 docs/wp2-port-engine.md create mode 100644 src/nec_port_matrix.cpp create mode 100644 src/nec_port_matrix.h create mode 100644 src/nec_stateful_model_wp2_tb.cpp diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index ea907ccd..48171c92 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -221,12 +221,14 @@ DoD: registers the legacy and WP1 partitions independently so each has a bounded timeout appropriate to its workload. -The next open package on the critical path is WP2. +WP2 builds on this retained-factorization layer below. --- ## WP2 — Multi-port solving and impedance matrices +**Status: complete (2026-08-28).** + Add a port-oriented numerical engine: ```cpp @@ -301,6 +303,41 @@ DoD: - Arbitrary simultaneous complex voltage and current excitations are supported. - All returned port quantities have stable ordering matching `definePorts()`. +### WP2 progress + +- Extended [`nec_stateful_model`](../src/nec_stateful_model.h) with dense + row-major port matrices, cached admittance and impedance results, detailed + voltage-driven solutions, and current-driven solutions using + \(\mathbf V=\mathbf Z\mathbf I\). The original WP1 current-only voltage + return remains as a compatibility wrapper. +- Admittance extraction performs one exact unit-voltage back-substitution per + port and stores the current responses as columns of \(\mathbf Y\). It never + refactors the NEC interaction matrix. Results are cached until frequency, + ground, or loads invalidate the prepared configuration. +- Added SVD-based inversion with a two-norm condition estimate and a controlled + diagnostic for singular matrices or estimates above \(10^{12}\). Both + \(\mathbf Z\) and \(\mathbf Y\), frequency, and factorization generation are + retained in the matrix result. +- Internal basis solves preserve the public lifecycle and latest consumer + solution. If a solution existed, its exact simultaneous voltage excitation + is restored without advancing the public solve generation; from `prepared`, + matrix extraction leaves no arbitrary basis result behind. +- Detailed port results contain requested drive values, achieved voltages and + currents, active impedances, per-port time-average powers, frequency, and + deterministic generations. An exactly zero achieved current produces the + specified `NaN + jNaN` active impedance. +- Added seven WP2 Catch2 cases covering one-port legacy impedance agreement, + two-port reciprocity, \(\mathbf Z\mathbf Y\approx\mathbf I\), arbitrary + voltage prediction, arbitrary current achievement, weight-dependent active + impedance, cache/frequency behavior, exact-zero current drive, and controlled + singular/ill-conditioned inversion. +- Validation completed on Windows/MSVC: the WP2 partition passes all 65 + assertions, all WP1 cases including the 1,000-solve stress test remain green, + the 77-case legacy/WP0 partition and CLI smoke test pass, all production + native targets build, and the strict TypeScript tests remain green. + +The next open package on the critical path is WP3. + --- ## WP3 — Complex far-field API diff --git a/docs/wp2-port-engine.md b/docs/wp2-port-engine.md new file mode 100644 index 00000000..42111cc4 --- /dev/null +++ b/docs/wp2-port-engine.md @@ -0,0 +1,79 @@ +# WP2 multi-port numerical engine + +WP2 extends the deck-free [`nec_stateful_model`](../src/nec_stateful_model.h) +with port admittance and impedance matrices plus arbitrary simultaneous voltage +and current drives. It remains a native C++ layer; WP4 will expose these results +through the versioned C/WASM ABI. + +## Matrix extraction and cache + +`compute_admittance_matrix()` applies an exact one-volt source to each port in +turn, holds every other registered port at zero volts, and stores the resulting +port currents as one column of \(\mathbf Y\). Matrices are dense and row-major: + +```text +index(row, column) = row * portCount + column +``` + +The row and column order is exactly the order supplied to `define_ports()`. +Every basis excitation reuses the LU factorization retained by WP1. Matrix +extraction therefore advances neither the factorization generation nor the +public solve generation. The extracted matrix is cached for the prepared +configuration; frequency, ground, or load invalidation clears both Y and Z. + +Internal basis solves are not consumer solutions. Starting from `prepared`, +the context's temporary results are removed and the model remains prepared. If +a consumer solution already exists, the model re-executes its saved simultaneous +voltage excitation after extraction, then restores the saved public metadata and +generation. A subsequent far-field request therefore still observes the same +consumer excitation. + +## Inversion and conditioning + +`compute_impedance_matrix()` computes \(\mathbf Z=\mathbf Y^{-1}\) with a +complex Jacobi SVD. The reported condition estimate is the singular-value +ratio \(\sigma_\max/\sigma_\min\), i.e. a two-norm estimate. Empty, malformed, +nonfinite, singular, or nonfinite inversions throw a controlled `nec_exception`. +The default maximum accepted condition estimate is \(10^{12}\); larger values +also fail diagnostically instead of returning an unreliable Z matrix. + +The inversion seam is implemented in `nec_port_matrix.cpp`. It is kept separate +from antenna geometry so the singular and ill-conditioned paths have +deterministic unit tests rather than depending on a degenerate physical model. + +## Port solutions + +`solve_port_voltages_detailed()` performs one simultaneous exact voltage-source +solve and returns the requested voltages, achieved voltages and currents, +active impedances, powers, frequency, and cache generations. The WP1 +`solve_port_voltages()` method remains as a current-vector compatibility +wrapper over the detailed solve. + +`solve_port_currents()` first forms the required source voltages using + +\[ +\mathbf V=\mathbf Z\mathbf I +\] + +and then performs one simultaneous voltage-source solve. Its result retains +the requested currents separately from the achieved NEC currents. Active +impedance is \(V_i/I_i\), and time-average input power is +\(\tfrac12\operatorname{Re}(V_i I_i^*)\). An exactly zero achieved current is +represented by `NaN + jNaN` active impedance as required by the public API +contract. + +## Verification + +The WP2 Catch2 cases are in +[`nec_stateful_model_wp2_tb.cpp`](../src/nec_stateful_model_wp2_tb.cpp). They +cover: + +- one-port Z agreement with the legacy NEC input impedance; +- reciprocal two-port mutual impedance; +- \(\mathbf Z\mathbf Y\) identity and row-major column extraction; +- direct NEC currents versus \(\mathbf Y\mathbf V\) for arbitrary complex V; +- achieved currents after applying \(\mathbf Z\mathbf I\); +- weight-dependent active impedance and the port power convention; +- matrix cache reuse, frequency invalidation, and generation behavior; +- exact-zero current drive and its NaN active-impedance sentinel; +- deterministic singular and over-limit condition diagnostics. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 19f62199..f08e1ceb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ set(NECPP_LIB_SRCS nec_exception.cpp nec_ground.cpp nec_output.cpp + nec_port_matrix.cpp nec_radiation_pattern.cpp nec_results.cpp nec_stateful_model.cpp diff --git a/src/nec_port_matrix.cpp b/src/nec_port_matrix.cpp new file mode 100644 index 00000000..60aae591 --- /dev/null +++ b/src/nec_port_matrix.cpp @@ -0,0 +1,85 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#include "nec_port_matrix.h" + +#include "nec_exception.h" + +#include + +#include +#include + +namespace { + +[[noreturn]] void conditioning_failure(const char* reason) +{ + nec_exception error("PORT MATRIX CONDITIONING ERROR: "); + error.append(reason); + throw error; +} + +} // namespace + +nec_port_matrix_inverse nec_invert_port_matrix( + const std::vector& values, + size_t order, + nec_float maximum_condition_estimate) +{ + if (order == 0 || + order > std::numeric_limits::max() / order || + order > static_cast(std::numeric_limits::max()) || + values.size() != order * order) + conditioning_failure("MATRIX MUST BE NONEMPTY AND SQUARE"); + if (!std::isfinite(maximum_condition_estimate) || + !(maximum_condition_estimate >= 1.0)) + conditioning_failure("CONDITION LIMIT MUST BE FINITE AND AT LEAST ONE"); + for (const nec_complex value : values) { + if (!std::isfinite(value.real()) || !std::isfinite(value.imag())) + conditioning_failure("MATRIX CONTAINS A NONFINITE VALUE"); + } + + using matrix_type = Eigen::Matrix; + const Eigen::Index dimension = static_cast(order); + matrix_type matrix(dimension, dimension); + for (size_t row = 0; row < order; ++row) + for (size_t column = 0; column < order; ++column) + matrix(static_cast(row), static_cast(column)) = + values[row * order + column]; + + Eigen::JacobiSVD decomposition( + matrix, Eigen::ComputeFullU | Eigen::ComputeFullV); + const auto& singular_values = decomposition.singularValues(); + const nec_float largest = singular_values(0); + const nec_float smallest = singular_values(dimension - 1); + + if (!std::isfinite(largest) || !std::isfinite(smallest) || + !(largest > 0.0) || !(smallest > 0.0)) + conditioning_failure("MATRIX IS SINGULAR"); + + const nec_float condition_estimate = largest / smallest; + if (!std::isfinite(condition_estimate) || + condition_estimate > maximum_condition_estimate) + conditioning_failure("MATRIX EXCEEDS THE CONDITION LIMIT"); + + const matrix_type inverse = decomposition.solve( + matrix_type::Identity(dimension, dimension)); + nec_port_matrix_inverse result; + result.condition_estimate = condition_estimate; + result.values.resize(values.size()); + for (size_t row = 0; row < order; ++row) { + for (size_t column = 0; column < order; ++column) { + const nec_complex value = inverse( + static_cast(row), static_cast(column)); + if (!std::isfinite(value.real()) || !std::isfinite(value.imag())) + conditioning_failure("INVERSION PRODUCED A NONFINITE VALUE"); + result.values[row * order + column] = value; + } + } + return result; +} diff --git a/src/nec_port_matrix.h b/src/nec_port_matrix.h new file mode 100644 index 00000000..b0aeec4b --- /dev/null +++ b/src/nec_port_matrix.h @@ -0,0 +1,30 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#pragma once + +#include "common.h" + +#include +#include + +struct nec_port_matrix_inverse { + std::vector values; + nec_float condition_estimate = 0.0; +}; + +/*! Invert a square row-major port matrix with a controlled condition limit. + * + * This is an internal numerical seam kept separate from the stateful model so + * singular-matrix behavior can be tested deterministically without relying on + * a geometrically degenerate NEC fixture. + */ +nec_port_matrix_inverse nec_invert_port_matrix( + const std::vector& values, + size_t order, + nec_float maximum_condition_estimate = 1.0e12); diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index f5f2ae3d..305b3956 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -11,10 +11,14 @@ #include "c_geometry.h" #include "nec_context.h" #include "nec_exception.h" +#include "nec_port_matrix.h" #include "nec_radiation_pattern.h" #include "nec_results.h" +#include #include +#include +#include #include #include #include @@ -65,11 +69,27 @@ void nec_stateful_model::invalidate_factorization() { m_configuration_dirty = true; m_frequency_mhz = 0.0; - m_port_currents.clear(); + clear_matrix_cache(); + clear_consumer_solution(); m_context->stateful_clear_results(); m_state = nec_model_state::geometry_complete; } +void nec_stateful_model::clear_matrix_cache() +{ + m_admittance_matrix = {}; + m_impedance_result = {}; + m_has_admittance_matrix = false; + m_has_impedance_result = false; +} + +void nec_stateful_model::clear_consumer_solution() +{ + m_port_currents.clear(); + m_last_port_solution = {}; + m_has_port_solution = false; +} + void nec_stateful_model::add_wire(const nec_wire_definition& wire) { if (m_state != nec_model_state::empty && @@ -130,6 +150,8 @@ void nec_stateful_model::define_ports( m_ports = ports; m_absolute_port_segments = std::move(absolute_segments); + clear_matrix_cache(); + clear_consumer_solution(); } void nec_stateful_model::validate_load_target( @@ -241,17 +263,79 @@ void nec_stateful_model::prepare(nec_float frequency_mhz) m_context->stateful_clear_results(); m_configuration_dirty = true; m_frequency_mhz = 0.0; - m_port_currents.clear(); + clear_matrix_cache(); + clear_consumer_solution(); m_state = nec_model_state::geometry_complete; m_context->stateful_prepare_frequency(frequency_mhz); m_frequency_mhz = frequency_mhz; ++m_factorization_generation; m_configuration_dirty = false; - m_port_currents.clear(); + clear_consumer_solution(); m_state = nec_model_state::prepared; } -const std::vector& nec_stateful_model::solve_port_voltages( +void nec_stateful_model::execute_voltage_solve( + const std::vector& voltages, + std::vector& achieved_voltages, + std::vector& achieved_currents) +{ + m_context->stateful_clear_results(); + m_context->stateful_solve_voltage_sources(m_absolute_port_segments, voltages); + + nec_antenna_input* input = m_context->get_input_parameters(0); + if (input == nullptr) + fail("PORT VOLTAGE SOLVE", "ENGINE DID NOT RETURN PORT INPUT DATA"); + achieved_voltages = input->get_voltage(); + achieved_currents = input->get_current(); + if (achieved_voltages.size() != m_ports.size() || + achieved_currents.size() != m_ports.size()) + fail("PORT VOLTAGE SOLVE", "ENGINE RETURNED THE WRONG PORT COUNT"); + for (size_t index = 0; index < m_ports.size(); ++index) { + if (!finite(achieved_voltages[index].real()) || + !finite(achieved_voltages[index].imag()) || + !finite(achieved_currents[index].real()) || + !finite(achieved_currents[index].imag())) + fail("PORT VOLTAGE SOLVE", "ENGINE RETURNED A NONFINITE PORT VALUE"); + } +} + +const nec_port_solution& nec_stateful_model::finish_consumer_solve( + nec_port_drive drive, + const std::vector& requested, + std::vector achieved_voltages, + std::vector achieved_currents) +{ + const nec_float nan = std::numeric_limits::quiet_NaN(); + nec_port_solution solution; + solution.drive = drive; + solution.requested = requested; + solution.voltages = std::move(achieved_voltages); + solution.currents = std::move(achieved_currents); + solution.active_impedances.reserve(m_ports.size()); + solution.powers_w.reserve(m_ports.size()); + + for (size_t index = 0; index < m_ports.size(); ++index) { + const nec_complex voltage = solution.voltages[index]; + const nec_complex current = solution.currents[index]; + solution.active_impedances.push_back( + current == nec_complex(0.0, 0.0) + ? nec_complex(nan, nan) + : voltage / current); + solution.powers_w.push_back( + 0.5 * std::real(voltage * std::conj(current))); + } + + solution.frequency_mhz = m_frequency_mhz; + solution.factorization_generation = m_factorization_generation; + solution.solve_generation = ++m_solve_generation; + m_last_port_solution = std::move(solution); + m_port_currents = m_last_port_solution.currents; + m_has_port_solution = true; + m_state = nec_model_state::solved; + return m_last_port_solution; +} + +const nec_port_solution& nec_stateful_model::solve_port_voltages_detailed( const std::vector& voltages) { if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) @@ -263,29 +347,169 @@ const std::vector& nec_stateful_model::solve_port_voltages( fail("SOLVE PORT VOLTAGES", "VOLTAGES MUST BE FINITE"); } - m_context->stateful_clear_results(); + std::vector achieved_voltages; + std::vector achieved_currents; try { - m_context->stateful_solve_voltage_sources(m_absolute_port_segments, voltages); + execute_voltage_solve(voltages, achieved_voltages, achieved_currents); } catch (...) { - // Back-substitution does not alter the retained LU factorization. A - // failed solve therefore discards only the consumer solution. - m_port_currents.clear(); + // Back-substitution does not alter the retained LU factorization. A failed + // consumer solve discards only the consumer-visible solution. + clear_consumer_solution(); m_state = nec_model_state::prepared; throw; } - nec_antenna_input* input = m_context->get_input_parameters(0); - if (input == nullptr) - fail("SOLVE PORT VOLTAGES", "ENGINE DID NOT RETURN PORT INPUT DATA"); - m_port_currents = input->get_current(); - if (m_port_currents.size() != m_ports.size()) - fail("SOLVE PORT VOLTAGES", "ENGINE RETURNED THE WRONG PORT COUNT"); + return finish_consumer_solve( + nec_port_drive::voltage, voltages, + std::move(achieved_voltages), std::move(achieved_currents)); +} - ++m_solve_generation; - m_state = nec_model_state::solved; +const std::vector& nec_stateful_model::solve_port_voltages( + const std::vector& voltages) +{ + solve_port_voltages_detailed(voltages); return m_port_currents; } +void nec_stateful_model::restore_after_internal_solves( + bool had_solution, const nec_port_solution& saved_solution) +{ + if (!had_solution) { + m_context->stateful_clear_results(); + clear_consumer_solution(); + m_state = nec_model_state::prepared; + return; + } + + std::vector restored_voltages; + std::vector restored_currents; + try { + execute_voltage_solve( + saved_solution.voltages, restored_voltages, restored_currents); + } catch (...) { + m_context->stateful_clear_results(); + clear_consumer_solution(); + m_state = nec_model_state::prepared; + throw; + } + + m_last_port_solution = saved_solution; + m_port_currents = saved_solution.currents; + m_has_port_solution = true; + m_state = nec_model_state::solved; +} + +const nec_complex_matrix& nec_stateful_model::compute_admittance_matrix() +{ + if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) + fail("COMPUTE ADMITTANCE MATRIX", "MODEL IS NOT PREPARED"); + if (m_has_admittance_matrix) + return m_admittance_matrix; + + const bool had_solution = m_has_port_solution; + const nec_port_solution saved_solution = m_last_port_solution; + const size_t order = m_ports.size(); + nec_complex_matrix admittance; + admittance.rows = order; + admittance.columns = order; + admittance.values.assign(order * order, nec_complex(0.0, 0.0)); + + try { + std::vector basis_voltages(order, nec_complex(0.0, 0.0)); + std::vector achieved_voltages; + std::vector achieved_currents; + for (size_t column = 0; column < order; ++column) { + std::fill(basis_voltages.begin(), basis_voltages.end(), nec_complex(0.0, 0.0)); + basis_voltages[column] = nec_complex(1.0, 0.0); + execute_voltage_solve( + basis_voltages, achieved_voltages, achieved_currents); + for (size_t row = 0; row < order; ++row) + admittance.values[row * order + column] = achieved_currents[row]; + } + } catch (...) { + const std::exception_ptr failure = std::current_exception(); + try { + restore_after_internal_solves(had_solution, saved_solution); + } catch (...) { + // The retained factorization is still usable, but no stale consumer + // result may be exposed if restoration itself fails. + } + std::rethrow_exception(failure); + } + restore_after_internal_solves(had_solution, saved_solution); + + m_admittance_matrix = std::move(admittance); + m_has_admittance_matrix = true; + return m_admittance_matrix; +} + +const nec_impedance_result& nec_stateful_model::compute_impedance_matrix() +{ + if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) + fail("COMPUTE IMPEDANCE MATRIX", "MODEL IS NOT PREPARED"); + if (m_has_impedance_result) + return m_impedance_result; + + const nec_complex_matrix& admittance = compute_admittance_matrix(); + const nec_port_matrix_inverse inverse = nec_invert_port_matrix( + admittance.values, admittance.rows); + + nec_impedance_result result; + result.admittance = admittance; + result.impedance.rows = admittance.rows; + result.impedance.columns = admittance.columns; + result.impedance.values = inverse.values; + result.condition_estimate = inverse.condition_estimate; + result.frequency_mhz = m_frequency_mhz; + result.factorization_generation = m_factorization_generation; + m_impedance_result = std::move(result); + m_has_impedance_result = true; + return m_impedance_result; +} + +const nec_port_solution& nec_stateful_model::solve_port_currents( + const std::vector& currents) +{ + if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) + fail("SOLVE PORT CURRENTS", "MODEL IS NOT PREPARED"); + if (currents.size() != m_ports.size()) + fail("SOLVE PORT CURRENTS", "CURRENT COUNT MUST MATCH PORT COUNT"); + for (const nec_complex current : currents) { + if (!finite(current.real()) || !finite(current.imag())) + fail("SOLVE PORT CURRENTS", "CURRENTS MUST BE FINITE"); + } + + const nec_complex_matrix& impedance = compute_impedance_matrix().impedance; + std::vector required_voltages( + currents.size(), nec_complex(0.0, 0.0)); + for (size_t row = 0; row < impedance.rows; ++row) { + for (size_t column = 0; column < impedance.columns; ++column) + required_voltages[row] += impedance.at(row, column) * currents[column]; + } + + std::vector achieved_voltages; + std::vector achieved_currents; + try { + execute_voltage_solve( + required_voltages, achieved_voltages, achieved_currents); + } catch (...) { + clear_consumer_solution(); + m_state = nec_model_state::prepared; + throw; + } + + return finish_consumer_solve( + nec_port_drive::current, currents, + std::move(achieved_voltages), std::move(achieved_currents)); +} + +const nec_port_solution& nec_stateful_model::last_port_solution() const +{ + if (m_state != nec_model_state::solved || !m_has_port_solution) + fail("LAST PORT SOLUTION", "NO CONSUMER SOLUTION IS AVAILABLE"); + return m_last_port_solution; +} + const nec_radiation_pattern& nec_stateful_model::compute_far_field( const nec_far_field_grid& grid) { diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index 8f4fb290..f3efc66c 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -13,6 +13,7 @@ #include #include #include +#include #include class nec_context; @@ -91,6 +92,46 @@ struct nec_far_field_grid { nec_float phi_step_deg = 0.0; }; +/*! Dense complex matrix in stable row-major port order. */ +struct nec_complex_matrix { + size_t rows = 0; + size_t columns = 0; + std::vector values; + + const nec_complex& at(size_t row, size_t column) const + { + if (row >= rows || column >= columns) + throw std::out_of_range("NEC port matrix index is out of range"); + return values.at(row * columns + column); + } +}; + +struct nec_impedance_result { + nec_complex_matrix impedance; + nec_complex_matrix admittance; + nec_float condition_estimate = 0.0; + nec_float frequency_mhz = 0.0; + uint64_t factorization_generation = 0; +}; + +enum class nec_port_drive { + voltage, + current, +}; + +/*! Complete quantities for one consumer-visible simultaneous port solve. */ +struct nec_port_solution { + nec_port_drive drive = nec_port_drive::voltage; + std::vector requested; + std::vector voltages; + std::vector currents; + std::vector active_impedances; + std::vector powers_w; + nec_float frequency_mhz = 0.0; + uint64_t factorization_generation = 0; + uint64_t solve_generation = 0; +}; + /*! A stateful, deck-free native solver above nec_context. * * The class owns one context and one retained interaction-matrix @@ -119,9 +160,27 @@ class nec_stateful_model { void set_ground(const nec_ground_definition& ground); void prepare(nec_float frequency_mhz); + + /*! Backward-compatible WP1 current-only voltage solve. */ const std::vector& solve_port_voltages( const std::vector& voltages); + /*! Simultaneous voltage solve with all WP2 port quantities. */ + const nec_port_solution& solve_port_voltages_detailed( + const std::vector& voltages); + + /*! Extract Y by unit-voltage basis solves, preserving public solution state. */ + const nec_complex_matrix& compute_admittance_matrix(); + + /*! Return cached row-major Z and Y matrices for the prepared configuration. */ + const nec_impedance_result& compute_impedance_matrix(); + + /*! Convert requested currents through V=ZI and execute one voltage solve. */ + const nec_port_solution& solve_port_currents( + const std::vector& currents); + + const nec_port_solution& last_port_solution() const; + /*! Calculate a raw NEC radiation-pattern result for the latest solution. * * WP3 supplies the stable copied complex-field API. This WP1 hook exists @@ -144,14 +203,33 @@ class nec_stateful_model { void require_configurable(const char* operation) const; void invalidate_factorization(); void validate_load_target(const nec_load_definition& load) const; + void clear_matrix_cache(); + void clear_consumer_solution(); + void execute_voltage_solve( + const std::vector& voltages, + std::vector& achieved_voltages, + std::vector& achieved_currents); + void restore_after_internal_solves( + bool had_solution, const nec_port_solution& saved_solution); + const nec_port_solution& finish_consumer_solve( + nec_port_drive drive, + const std::vector& requested, + std::vector achieved_voltages, + std::vector achieved_currents); std::unique_ptr m_context; nec_model_state m_state = nec_model_state::empty; std::vector m_ports; std::vector m_absolute_port_segments; std::vector m_port_currents; + nec_complex_matrix m_admittance_matrix; + nec_impedance_result m_impedance_result; + nec_port_solution m_last_port_solution; nec_float m_frequency_mhz = 0.0; uint64_t m_factorization_generation = 0; uint64_t m_solve_generation = 0; bool m_configuration_dirty = true; + bool m_has_admittance_matrix = false; + bool m_has_impedance_result = false; + bool m_has_port_solution = false; }; diff --git a/src/nec_stateful_model_wp2_tb.cpp b/src/nec_stateful_model_wp2_tb.cpp new file mode 100644 index 00000000..ea22b838 --- /dev/null +++ b/src/nec_stateful_model_wp2_tb.cpp @@ -0,0 +1,272 @@ +#include +#include + +#include "nec_context.h" +#include "nec_exception.h" +#include "nec_port_matrix.h" +#include "nec_stateful_model.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr nec_float kFrequencyMHz = 300.0; +constexpr int kSegments = 11; +constexpr int kFeedSegment = 6; + +nec_wire_definition dipole_wire(int tag, nec_float x_m) +{ + return { + tag, kSegments, + x_m, 0.0, -0.25, + x_m, 0.0, 0.25, + 0.001, + }; +} + +void build_dipoles(nec_stateful_model& model, size_t count) +{ + for (size_t index = 0; index < count; ++index) + model.add_wire(dipole_wire(static_cast(index + 1), 0.20 * index)); + model.complete_geometry(); + + std::vector ports; + for (size_t index = 0; index < count; ++index) + ports.push_back({static_cast(index + 1), kFeedSegment}); + model.define_ports(ports); + model.prepare(kFrequencyMHz); +} + +std::vector multiply( + const nec_complex_matrix& matrix, + const std::vector& vector) +{ + std::vector product(matrix.rows, nec_complex(0.0, 0.0)); + for (size_t row = 0; row < matrix.rows; ++row) + for (size_t column = 0; column < matrix.columns; ++column) + product[row] += matrix.at(row, column) * vector[column]; + return product; +} + +nec_float relative_error( + const std::vector& first, + const std::vector& second) +{ + REQUIRE(first.size() == second.size()); + nec_float difference_squared = 0.0; + nec_float first_squared = 0.0; + nec_float second_squared = 0.0; + for (size_t index = 0; index < first.size(); ++index) { + difference_squared += std::norm(first[index] - second[index]); + first_squared += std::norm(first[index]); + second_squared += std::norm(second[index]); + } + return std::sqrt(difference_squared) / + std::max({nec_float(1.0), std::sqrt(first_squared), std::sqrt(second_squared)}); +} + +nec_complex legacy_dipole_impedance() +{ + nec_context model; + model.initialize(); + model.wire( + 1, kSegments, + 0.0, 0.0, -0.25, + 0.0, 0.0, 0.25, + 0.001, 1.0, 1.0); + model.geometry_complete(0); + model.fr_card(0, 1, kFrequencyMHz, 0.0); + model.ex_card( + EXCITATION_VOLTAGE, + 1, kFeedSegment, 0, + 1.0, 0.0, 0.0, 0.0, 0.0, 0.0); + model.xq_card(0); + return nec_complex(model.get_impedance_real(), model.get_impedance_imag()); +} + +} // namespace + +TEST_CASE("WP2 one-port Z agrees with the legacy NEC input impedance", + "[wasm_api][wp2][matrix]") +{ + nec_stateful_model model; + build_dipoles(model, 1); + + const nec_impedance_result& matrices = model.compute_impedance_matrix(); + REQUIRE(model.state() == nec_model_state::prepared); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 0); + REQUIRE(model.retained_result_count() == 0); + REQUIRE(matrices.impedance.rows == 1); + REQUIRE(matrices.impedance.columns == 1); + REQUIRE(matrices.admittance.rows == 1); + REQUIRE(matrices.condition_estimate == Catch::Approx(1.0).epsilon(1.0e-14)); + + const nec_complex expected = legacy_dipole_impedance(); + REQUIRE(matrices.impedance.at(0, 0).real() == + Catch::Approx(expected.real()).epsilon(1.0e-12)); + REQUIRE(matrices.impedance.at(0, 0).imag() == + Catch::Approx(expected.imag()).epsilon(1.0e-12)); + + const nec_complex impedance_at_300_mhz = matrices.impedance.at(0, 0); + model.prepare(kFrequencyMHz); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(&model.compute_impedance_matrix() == &matrices); + model.prepare(301.0); + const nec_impedance_result& changed = model.compute_impedance_matrix(); + REQUIRE(changed.factorization_generation == 2); + REQUIRE(changed.frequency_mhz == 301.0); + REQUIRE(changed.impedance.at(0, 0) != impedance_at_300_mhz); +} + +TEST_CASE("WP2 coupled-port matrices are reciprocal inverses in row-major order", + "[wasm_api][wp2][matrix]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + const nec_impedance_result& matrices = model.compute_impedance_matrix(); + + REQUIRE(matrices.impedance.rows == 2); + REQUIRE(matrices.impedance.columns == 2); + REQUIRE(matrices.impedance.values.size() == 4); + REQUIRE(std::abs(matrices.impedance.at(0, 1) - matrices.impedance.at(1, 0)) / + std::max(nec_float(1.0), std::abs(matrices.impedance.at(0, 1))) < 1.0e-8); + + for (size_t column = 0; column < 2; ++column) { + const std::vector admittance_column{ + matrices.admittance.at(0, column), + matrices.admittance.at(1, column), + }; + std::vector identity_column(2, nec_complex(0.0, 0.0)); + identity_column[column] = nec_complex(1.0, 0.0); + REQUIRE(relative_error( + multiply(matrices.impedance, admittance_column), + identity_column) < 1.0e-7); + } + + REQUIRE(matrices.factorization_generation == 1); + REQUIRE(matrices.frequency_mhz == kFrequencyMHz); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 0); +} + +TEST_CASE("WP2 Y predicts arbitrary simultaneous voltage-source currents", + "[wasm_api][wp2][voltage]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + const std::vector voltages{ + nec_complex(0.73, -0.19), + nec_complex(-0.28, 0.41), + }; + const nec_port_solution& direct = model.solve_port_voltages_detailed(voltages); + const std::vector direct_currents = direct.currents; + REQUIRE(direct.drive == nec_port_drive::voltage); + REQUIRE(direct.requested == voltages); + REQUIRE(direct.solve_generation == 1); + + // Matrix extraction performs internal unit solves, then restores the exact + // consumer-visible solution and its public generation. + const nec_complex_matrix& admittance = model.compute_admittance_matrix(); + const std::vector predicted = multiply(admittance, voltages); + REQUIRE(relative_error(predicted, direct_currents) < 1.0e-7); + REQUIRE(model.state() == nec_model_state::solved); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.last_port_solution().currents == direct_currents); + REQUIRE(model.retained_result_count() == 1); +} + +TEST_CASE("WP2 current drive applies ZI in one consumer solve", + "[wasm_api][wp2][current]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + const std::vector requested{ + nec_complex(0.011, -0.003), + nec_complex(-0.004, 0.008), + }; + + const nec_port_solution& solution = model.solve_port_currents(requested); + REQUIRE(solution.drive == nec_port_drive::current); + REQUIRE(solution.requested == requested); + REQUIRE(solution.solve_generation == 1); + REQUIRE(relative_error(solution.currents, requested) < 1.0e-7); + REQUIRE(relative_error( + solution.voltages, + multiply(model.compute_impedance_matrix().impedance, requested)) < 1.0e-7); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + + for (size_t index = 0; index < requested.size(); ++index) { + REQUIRE(solution.active_impedances[index] == + solution.voltages[index] / solution.currents[index]); + REQUIRE(solution.powers_w[index] == Catch::Approx( + 0.5 * std::real( + solution.voltages[index] * std::conj(solution.currents[index]))) + .epsilon(1.0e-12)); + } +} + +TEST_CASE("WP2 active impedance follows simultaneous array weights", + "[wasm_api][wp2][active_impedance]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + + const nec_port_solution first = model.solve_port_voltages_detailed({ + nec_complex(1.0, 0.0), nec_complex(0.0, 0.0), + }); + const nec_port_solution second = model.solve_port_voltages_detailed({ + nec_complex(1.0, 0.0), nec_complex(0.0, 1.0), + }); + + REQUIRE(std::abs(first.active_impedances[0] - second.active_impedances[0]) > 1.0e-6); + REQUIRE(std::abs(first.active_impedances[1] - second.active_impedances[1]) > 1.0e-6); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 2); +} + +TEST_CASE("WP2 zero-current drive reports the documented NaN active impedance", + "[wasm_api][wp2][current][zero]") +{ + nec_stateful_model model; + build_dipoles(model, 1); + + REQUIRE_THROWS_AS(model.solve_port_currents({}), nec_exception); + REQUIRE_THROWS_AS(model.solve_port_currents({nec_complex( + std::numeric_limits::infinity(), 0.0)}), nec_exception); + REQUIRE(model.state() == nec_model_state::prepared); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 0); + + const nec_port_solution& zero = + model.solve_port_currents({nec_complex(0.0, 0.0)}); + REQUIRE(zero.currents[0] == nec_complex(0.0, 0.0)); + REQUIRE(zero.voltages[0] == nec_complex(0.0, 0.0)); + REQUIRE(std::isnan(zero.active_impedances[0].real())); + REQUIRE(std::isnan(zero.active_impedances[0].imag())); + REQUIRE(zero.powers_w[0] == 0.0); + REQUIRE(model.retained_result_count() == 1); +} + +TEST_CASE("WP2 singular and badly conditioned matrices fail diagnostically", + "[wasm_api][wp2][conditioning]") +{ + REQUIRE_THROWS_AS( + nec_invert_port_matrix({ + nec_complex(1.0, 0.0), nec_complex(2.0, 0.0), + nec_complex(2.0, 0.0), nec_complex(4.0, 0.0), + }, 2), + nec_exception); + + REQUIRE_THROWS_AS( + nec_invert_port_matrix({ + nec_complex(1.0, 0.0), nec_complex(0.0, 0.0), + nec_complex(0.0, 0.0), nec_complex(1.0e-13, 0.0), + }, 2), + nec_exception); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4b0b0982..b8df9c3a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -42,6 +42,7 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/radiation_input_tb.cpp ${CMAKE_SOURCE_DIR}/src/wasm_api_contract_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_tb.cpp + ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp2_tb.cpp ) # Test runner = Catch2 main + test sources + nec2cpp.cpp (main renamed) + the @@ -66,6 +67,7 @@ add_executable(nec2++_tests ${CMAKE_SOURCE_DIR}/src/nec_exception.cpp ${CMAKE_SOURCE_DIR}/src/nec_ground.cpp ${CMAKE_SOURCE_DIR}/src/nec_output.cpp + ${CMAKE_SOURCE_DIR}/src/nec_port_matrix.cpp ${CMAKE_SOURCE_DIR}/src/nec_radiation_pattern.cpp ${CMAKE_SOURCE_DIR}/src/nec_results.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model.cpp @@ -94,15 +96,18 @@ endif() # stress suite on independent timeout budgets. Otherwise a slow numerical # fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests "~[wp1]") + COMMAND nec2++_tests "~[wp1]~[wp2]") add_test(NAME necpp_wp1 COMMAND nec2++_tests "[wp1]") +add_test(NAME necpp_wp2 + COMMAND nec2++_tests "[wp2]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of # blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a # 1,000-excitation retained-factorization stress case; it completes in a few # seconds on Windows. The legacy numerical aggregate needs a wider allowance. set_tests_properties(necpp_unit PROPERTIES TIMEOUT 480) set_tests_properties(necpp_wp1 PROPERTIES TIMEOUT 180) +set_tests_properties(necpp_wp2 PROPERTIES TIMEOUT 180) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From c10ab83574b43c3689d3de51a1242d8b614b71b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 11:35:28 +0200 Subject: [PATCH 19/46] WP3 --- docs/local-build-environment.md | 145 ++++++++++++++++ docs/ts_engine_plan.md | 30 ++++ docs/wp1-native-engine.md | 8 +- docs/wp3-complex-far-field.md | 79 +++++++++ src/nec_radiation_pattern.cpp | 5 + src/nec_stateful_model.cpp | 249 +++++++++++++++++++++++--- src/nec_stateful_model.h | 69 +++++++- src/nec_stateful_model_tb.cpp | 9 +- src/nec_stateful_model_wp3_tb.cpp | 280 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 8 +- 10 files changed, 841 insertions(+), 41 deletions(-) create mode 100644 docs/local-build-environment.md create mode 100644 docs/wp3-complex-far-field.md create mode 100644 src/nec_stateful_model_wp3_tb.cpp diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md new file mode 100644 index 00000000..799b242d --- /dev/null +++ b/docs/local-build-environment.md @@ -0,0 +1,145 @@ +# Local build environment notes + +Last verified on 2026-08-28. These notes describe the current Windows +workstation and its existing build trees. Paths under the user's temporary +directory are machine-specific and may disappear after cleanup or reboot. + +## Host environment + +- Repository: `C:\Users\andre\VSCode_Projects\necpp` +- Shell: Windows PowerShell 5.1.26100.9168 +- Docker: 29.7.2 +- Visual Studio: 2022 Community +- MSBuild: 17.14.51.32402 +- MSVC compiler: 19.44.35228.0, x64 +- Project C++ standard: C++17 + +This PowerShell does not accept `&&` as a statement separator. Run host +commands on separate lines and check `$LASTEXITCODE` when the second command +must not run after a failure. `&&` is still valid inside a quoted `sh -lc` +command executed by Docker. + +## Existing Windows/MSVC build + +`build-wp0` is the usable host build tree. Despite its historical name, it is +regenerated from the current source and contains the WP1, WP2, and WP3 test +partitions. Its relevant configuration is: + +- generator: `Visual Studio 17 2022` +- platform: `x64` +- configuration used for testing: `Release` +- compiler: + `C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x64\cl.exe` +- MSBuild: + `C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe` + +`cmake` and `ctest` are not on the PowerShell `PATH`. The existing build cache +currently points to CMake/CTest 3.31.6 here: + +```powershell +$CMakeTools = "C:\Users\andre\AppData\Local\Temp\codex-necpp-cmake\cmake\data\bin" +``` + +Because this is a temporary path, inspect `build-wp0\CMakeCache.txt` entries +`CMAKE_COMMAND` and `CMAKE_CTEST_COMMAND` if it stops working. Installing CMake +normally and adding it to `PATH` is the durable alternative. + +Build the native test runner: + +```powershell +& "$CMakeTools\cmake.exe" --build "build-wp0" ` + --config Release --target "nec2++_tests" --parallel +``` + +Direct MSBuild is a fallback: + +```powershell +& "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ` + "build-wp0\tests\nec2++_tests.vcxproj" ` + /p:Configuration=Release /p:Platform=x64 /m +``` + +When `tests\CMakeLists.txt` adds a new source, the first direct MSBuild +invocation may only regenerate the Visual Studio project while continuing with +the project definition it loaded at startup. Run the build a second time and +confirm the new source appears in the compiler output. + +Run all registered tests: + +```powershell +& "$CMakeTools\ctest.exe" --test-dir "build-wp0" ` + -C Release --output-on-failure -j1 +``` + +Run one Catch2 work-package partition directly: + +```powershell +& "build-wp0\tests\Release\nec2++_tests.exe" "[wp3]" +``` + +Registered CTest entries are `necpp_unit`, `necpp_wp1`, `necpp_wp2`, +`necpp_wp3`, and `necpp_smoke_hertzian_dipole`. The test binary is compiled +with `NEC_ERROR_CHECK=1`, so direct runs can emit substantial solver tracing. + +## Docker native build + +The locally available `emscripten/emsdk:4.0.7` image also contains: + +- CMake 3.22.1 +- Ubuntu g++ 11.4.0 + +It can provide a clean Linux/GCC cross-check without installing host CMake. +Explicitly select `g++`; otherwise the Emscripten image is intended primarily +for `emcc`/`em++`. + +The current Linux build tree is `build-wp3-linux`. It is a Release, +static-library, non-LTO build configured with container path `/src`: + +```powershell +docker run --rm -v "${PWD}:/src" -w /src ` + emscripten/emsdk:4.0.7 sh -lc ` + "cmake -S . -B build-wp3-linux ` + -DNECPP_BUILD_TESTS=ON ` + -DBUILD_SHARED_LIBS=OFF ` + -DNECPP_ENABLE_LTO=OFF ` + -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_CXX_COMPILER=g++ && + cmake --build build-wp3-linux -j2 && + ctest --test-dir build-wp3-linux --output-on-failure -j1" +``` + +The first configure downloads Catch2 v3.7.1, so a fresh build needs network +access. Build directories matching `build-*` are ignored by Git. + +## WASM build + +Use the repository's pinned PowerShell wrapper rather than reconstructing the +Emscripten flags manually: + +```powershell +.\scripts\build_wasm_docker.ps1 +``` + +It uses `emscripten/emsdk:4.0.7`, TypeScript 5.8.3, and a container-local build +directory under `/tmp`. Building on the container filesystem is intentional: +Emscripten link steps can fail when writing intermediate files directly to a +Windows bind mount. Successful artifacts are copied to: + +```text +wasm/nec2pp.js +wasm/nec2pp.wasm +wasm/nec2pp.d.ts +``` + +The wrapper also runs `scripts/wasm_smoke_test.mjs`. Generated WASM artifacts +and all `build-*` directories are ignored by Git. + +## Known-good verification + +The WP3 implementation was verified with: + +- Windows/MSVC: all five CTest entries passed, including the CLI smoke test; +- focused WP3: 6 cases and 206 assertions passed; +- Linux/GCC in Docker: all five CTest entries passed; +- both native production executables, `nec2++` and `nec2diff`, built + successfully in the Docker Release build. diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 48171c92..1c9d5c5f 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -342,6 +342,8 @@ The next open package on the critical path is WP3. ## WP3 — Complex far-field API +**Status: complete (2026-08-28).** + Expose bulk far-field results from the most recent current solution: ```ts @@ -390,6 +392,34 @@ DoD: - Returned data contains enough metadata to interpret every sample without external assumptions. - No formatted NEC report parsing occurs. +### WP3 progress + +- Replaced the temporary raw radiation-pattern return from + [`nec_stateful_model`](../src/nec_stateful_model.h) with copied complex + far-field results containing radius, frequency, theta/phi axes, and + theta-fast \(E_\theta\)/\(E_\phi\) vectors in V/m. +- Added voltage- and current-normalized embedded fields in stable port-major + order. Current bases use columns of the cached impedance matrix, so arbitrary + current weights superpose directly. +- Internal embedded-field solves preserve a prior consumer solution, public + lifecycle state, factorization generation, and solve generation. Starting + from `prepared`, they leave no arbitrary basis result behind. +- Added explicit exact-zero handling so a zero excitation returns finite exact + zero fields without entering NEC's gain-normalization calculations. +- Added six WP3 Catch2 cases covering copied metadata and indexing, the + complex radial propagation law, voltage and current basis superposition, + solution restoration, exact-zero output, and center-fed dipole + nulls/symmetry, plus deterministic zero output for ground-skipped angles. +- Added [`docs/wp3-complex-far-field.md`](wp3-complex-far-field.md) documenting + field ownership, layouts, normalizations, lifecycle behavior, and + beamforming equations. Native-to-WASM equality remains an ABI integration + assertion for WP4 because the stateful C boundary does not exist yet. +- Validation completed on Windows/MSVC and Linux/GCC: all six WP3 cases pass + with 206 assertions, the WP0/WP1/WP2 partitions remain green, both native + production targets build, and the legacy CLI smoke test passes. + +The next open package on the critical path is WP4. + --- ## WP4 — Stable C/WASM ABI diff --git a/docs/wp1-native-engine.md b/docs/wp1-native-engine.md index 95cd4319..314617ff 100644 --- a/docs/wp1-native-engine.md +++ b/docs/wp1-native-engine.md @@ -18,12 +18,12 @@ solved state. Changing frequency, ground, or loads invalidates prepared data; the next successful preparation advances the generation. A voltage solve advances only the solve generation. -Each consumer solve replaces the previous native result collection. A raw WP1 +Each consumer solve replaces the previous native result collection. A far-field calculation retains the current antenna-input result and replaces only the prior radiation-pattern result. Thus repeated solves and repeated -field grids have bounded native ownership. WP3 will copy the complex field -components into its stable bulk result type; the raw WP1 radiation-pattern -reference is deliberately temporary. +field grids have bounded native ownership. WP3 now copies the temporary +radiation-pattern components into the stable bulk result type documented in +[`wp3-complex-far-field.md`](wp3-complex-far-field.md). Exact zero-valued voltage sources are preserved by the stateful excitation hook. This differs intentionally from the legacy `EX` card compatibility path, diff --git a/docs/wp3-complex-far-field.md b/docs/wp3-complex-far-field.md new file mode 100644 index 00000000..643ed799 --- /dev/null +++ b/docs/wp3-complex-far-field.md @@ -0,0 +1,79 @@ +# WP3 complex far-field engine + +WP3 replaces the temporary raw `nec_radiation_pattern` view on +[`nec_stateful_model`](../src/nec_stateful_model.h) with copied bulk complex +fields. It remains a native C++ layer; WP4 will expose the model-owned +contiguous buffers through the versioned C/WASM ABI. + +## Combined far fields + +`compute_far_field()` samples the latest consumer solution and returns: + +- the positive radius and prepared frequency; +- separate theta and phi coordinate axes in degrees; +- complex `E_theta` and `E_phi` vectors in V/m. + +The field vectors contain `theta_deg.size() * phi_deg.size()` entries. Theta +varies fastest: + +```text +sampleIndex = phiIndex * thetaCount + thetaIndex +``` + +The requested positive radius is passed to NEC's far-field calculation, so +every value includes the documented `exp(-j k R) / R` propagation factor. An +exactly zero consumer excitation is handled explicitly and returns exact +complex zeros without entering NEC's gain normalization path. + +The native result is a deep copy of NEC's temporary radiation-pattern arrays. +It therefore survives replacement of the underlying native result collection. +The future TypeScript facade will immediately copy the WP4 buffers again into +JavaScript-owned typed arrays. + +## Embedded far fields + +`compute_embedded_far_fields()` returns one basis field per registered port. +Its outer dimension follows the exact `define_ports()` order: + +```text +embeddedIndex = portIndex * samplesPerPort + sampleIndex +``` + +Two normalizations are available: + +- `unit_voltage` (default): one volt at the selected port and zero volts at + every other port; +- `unit_current`: one requested ampere into the selected port and zero + requested amperes at every other port. The engine obtains each voltage basis + from the corresponding column of the cached impedance matrix. + +Fields can therefore be combined without another native field calculation: + +\[ +E_\theta=\sum_n w_n E_{\theta,n},\qquad +E_\phi=\sum_n w_n E_{\phi,n}. +\] + +Voltage weights are used with the voltage-normalized basis; current weights +are used with the current-normalized basis. + +Embedded calculations are internal basis solves. They do not advance the +factorization or solve generations. Starting from `prepared`, they remove the +temporary results and remain prepared. If a consumer solution exists, its +simultaneous voltage excitation, port quantities, state, and public generation +are restored after the basis calculation. + +## Verification + +The WP3 Catch2 cases are in +[`nec_stateful_model_wp3_tb.cpp`](../src/nec_stateful_model_wp3_tb.cpp). They +cover: + +- copied axes, finite complex buffers, and theta-fast indexing; +- the exact complex `exp(-j k DeltaR) R1/R2` radial law; +- voltage-normalized embedded-field superposition; +- current-normalized embedded-field superposition and consumer-solution + restoration; +- exact-zero fields without NaNs; +- center-fed dipole axial nulls and mirror symmetry; +- deterministic zero entries for angles NEC skips below a ground plane. diff --git a/src/nec_radiation_pattern.cpp b/src/nec_radiation_pattern.cpp index 9604d373..45979d8e 100644 --- a/src/nec_radiation_pattern.cpp +++ b/src/nec_radiation_pattern.cpp @@ -78,6 +78,11 @@ nec_radiation_pattern::nec_radiation_pattern(int in_n_theta, int in_n_phi, _e_theta.resize(n_theta, n_phi); _e_phi.resize(n_theta, n_phi); _e_r.resize(n_theta, n_phi); + // Ground-backed RP calculations intentionally skip theta samples below the + // ground plane. Keep those entries deterministic for bulk field consumers. + _e_theta.setConstant(nec_complex(0.0, 0.0)); + _e_phi.setConstant(nec_complex(0.0, 0.0)); + _e_r.setConstant(nec_complex(0.0, 0.0)); _ifar = in_ifar; _wavelength = in_wavelength; diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index 305b3956..1a24e886 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -34,13 +34,95 @@ void fail(const char* operation, const char* reason) throw error; } -bool finite(nec_float value) +bool finite_value(nec_float value) { return std::isfinite(value); } +size_t checked_field_sample_count( + const nec_far_field_grid& grid, const char* operation) +{ + if (!finite_value(grid.radius_m) || !(grid.radius_m > 0.0) || + !finite_value(grid.theta_start_deg) || !finite_value(grid.theta_step_deg) || + !finite_value(grid.phi_start_deg) || !finite_value(grid.phi_step_deg) || + grid.theta_count <= 0 || grid.phi_count <= 0) + fail(operation, "GRID VALUES ARE INVALID"); + + const size_t theta_count = static_cast(grid.theta_count); + const size_t phi_count = static_cast(grid.phi_count); + if (theta_count > std::numeric_limits::max() / phi_count) + fail(operation, "GRID SAMPLE COUNT OVERFLOWS"); + const size_t sample_count = theta_count * phi_count; + if (sample_count > std::vector().max_size()) + fail(operation, "GRID SAMPLE COUNT IS TOO LARGE"); + return sample_count; +} + +void populate_field_axes( + const nec_far_field_grid& grid, + std::vector& theta_deg, + std::vector& phi_deg, + const char* operation) +{ + theta_deg.resize(static_cast(grid.theta_count)); + phi_deg.resize(static_cast(grid.phi_count)); + for (size_t index = 0; index < theta_deg.size(); ++index) { + theta_deg[index] = + grid.theta_start_deg + static_cast(index) * grid.theta_step_deg; + if (!finite_value(theta_deg[index])) + fail(operation, "THETA AXIS CONTAINS A NONFINITE VALUE"); + } + for (size_t index = 0; index < phi_deg.size(); ++index) { + phi_deg[index] = + grid.phi_start_deg + static_cast(index) * grid.phi_step_deg; + if (!finite_value(phi_deg[index])) + fail(operation, "PHI AXIS CONTAINS A NONFINITE VALUE"); + } +} + +size_t checked_angular_index( + size_t theta_count, size_t phi_count, + size_t theta_index, size_t phi_index) +{ + if (theta_index >= theta_count || phi_index >= phi_count) + throw std::out_of_range("NEC far-field index is out of range"); + return phi_index * theta_count + theta_index; +} + } // namespace +const nec_complex& nec_far_field_result::e_theta_at( + size_t theta_index, size_t phi_index) const +{ + return e_theta.at(checked_angular_index( + theta_deg.size(), phi_deg.size(), theta_index, phi_index)); +} + +const nec_complex& nec_far_field_result::e_phi_at( + size_t theta_index, size_t phi_index) const +{ + return e_phi.at(checked_angular_index( + theta_deg.size(), phi_deg.size(), theta_index, phi_index)); +} + +const nec_complex& nec_embedded_far_field_result::e_theta_at( + size_t port_index, size_t theta_index, size_t phi_index) const +{ + if (port_index >= ports.size()) + throw std::out_of_range("NEC embedded-field port index is out of range"); + return e_theta.at(port_index * samples_per_port + checked_angular_index( + theta_deg.size(), phi_deg.size(), theta_index, phi_index)); +} + +const nec_complex& nec_embedded_far_field_result::e_phi_at( + size_t port_index, size_t theta_index, size_t phi_index) const +{ + if (port_index >= ports.size()) + throw std::out_of_range("NEC embedded-field port index is out of range"); + return e_phi.at(port_index * samples_per_port + checked_angular_index( + theta_deg.size(), phi_deg.size(), theta_index, phi_index)); +} + nec_stateful_model::nec_stateful_model() : m_context(std::make_unique()) { @@ -97,9 +179,9 @@ void nec_stateful_model::add_wire(const nec_wire_definition& wire) fail("ADD WIRE", "GEOMETRY IS ALREADY COMPLETE"); if (wire.tag <= 0 || wire.segments <= 0) fail("ADD WIRE", "TAG AND SEGMENT COUNT MUST BE POSITIVE"); - if (!finite(wire.x1) || !finite(wire.y1) || !finite(wire.z1) || - !finite(wire.x2) || !finite(wire.y2) || !finite(wire.z2) || - !finite(wire.radius_m) || !(wire.radius_m > 0.0)) + if (!finite_value(wire.x1) || !finite_value(wire.y1) || !finite_value(wire.z1) || + !finite_value(wire.x2) || !finite_value(wire.y2) || !finite_value(wire.z2) || + !finite_value(wire.radius_m) || !(wire.radius_m > 0.0)) fail("ADD WIRE", "COORDINATES AND POSITIVE RADIUS MUST BE FINITE"); if (wire.x1 == wire.x2 && wire.y1 == wire.y2 && wire.z1 == wire.z2) fail("ADD WIRE", "ENDPOINTS MUST BE DISTINCT"); @@ -188,7 +270,9 @@ void nec_stateful_model::add_load(const nec_load_definition& load) const int load_kind = static_cast(load.kind); if (load_kind < 0 || load_kind > 5) fail("ADD LOAD", "UNKNOWN LOAD KIND"); - if (!finite(load.value1) || !finite(load.value2) || !finite(load.value3)) + if (!finite_value(load.value1) || + !finite_value(load.value2) || + !finite_value(load.value3)) fail("ADD LOAD", "LOAD VALUES MUST BE FINITE"); const int last_segment = @@ -232,8 +316,8 @@ void nec_stateful_model::set_ground(const nec_ground_definition& ground) } if (ground_type == 0 || ground_type == 2) { - if (!finite(ground.relative_permittivity) || - !finite(ground.conductivity_s_per_m) || + if (!finite_value(ground.relative_permittivity) || + !finite_value(ground.conductivity_s_per_m) || !(ground.relative_permittivity > 0.0) || !(ground.conductivity_s_per_m > 0.0)) fail("SET GROUND", "FINITE GROUND PARAMETERS MUST BE POSITIVE AND FINITE"); @@ -251,7 +335,7 @@ void nec_stateful_model::prepare(nec_float frequency_mhz) require_configurable("PREPARE"); if (m_ports.empty()) fail("PREPARE", "PORTS HAVE NOT BEEN DEFINED"); - if (!finite(frequency_mhz) || !(frequency_mhz > 0.0)) + if (!finite_value(frequency_mhz) || !(frequency_mhz > 0.0)) fail("PREPARE", "FREQUENCY MUST BE POSITIVE AND FINITE"); if (!m_configuration_dirty && m_frequency_mhz == frequency_mhz) @@ -291,10 +375,10 @@ void nec_stateful_model::execute_voltage_solve( achieved_currents.size() != m_ports.size()) fail("PORT VOLTAGE SOLVE", "ENGINE RETURNED THE WRONG PORT COUNT"); for (size_t index = 0; index < m_ports.size(); ++index) { - if (!finite(achieved_voltages[index].real()) || - !finite(achieved_voltages[index].imag()) || - !finite(achieved_currents[index].real()) || - !finite(achieved_currents[index].imag())) + if (!finite_value(achieved_voltages[index].real()) || + !finite_value(achieved_voltages[index].imag()) || + !finite_value(achieved_currents[index].real()) || + !finite_value(achieved_currents[index].imag())) fail("PORT VOLTAGE SOLVE", "ENGINE RETURNED A NONFINITE PORT VALUE"); } } @@ -343,7 +427,7 @@ const nec_port_solution& nec_stateful_model::solve_port_voltages_detailed( if (voltages.size() != m_ports.size()) fail("SOLVE PORT VOLTAGES", "VOLTAGE COUNT MUST MATCH PORT COUNT"); for (const nec_complex voltage : voltages) { - if (!finite(voltage.real()) || !finite(voltage.imag())) + if (!finite_value(voltage.real()) || !finite_value(voltage.imag())) fail("SOLVE PORT VOLTAGES", "VOLTAGES MUST BE FINITE"); } @@ -475,7 +559,7 @@ const nec_port_solution& nec_stateful_model::solve_port_currents( if (currents.size() != m_ports.size()) fail("SOLVE PORT CURRENTS", "CURRENT COUNT MUST MATCH PORT COUNT"); for (const nec_complex current : currents) { - if (!finite(current.real()) || !finite(current.imag())) + if (!finite_value(current.real()) || !finite_value(current.imag())) fail("SOLVE PORT CURRENTS", "CURRENTS MUST BE FINITE"); } @@ -510,17 +594,27 @@ const nec_port_solution& nec_stateful_model::last_port_solution() const return m_last_port_solution; } -const nec_radiation_pattern& nec_stateful_model::compute_far_field( - const nec_far_field_grid& grid) +nec_far_field_result nec_stateful_model::calculate_far_field( + const nec_far_field_grid& grid, + const std::vector& currents) { - require_state(nec_model_state::solved, "COMPUTE FAR FIELD"); - if (!finite(grid.radius_m) || !(grid.radius_m > 0.0) || - !finite(grid.theta_start_deg) || !finite(grid.theta_step_deg) || - !finite(grid.phi_start_deg) || !finite(grid.phi_step_deg) || - grid.theta_count <= 0 || grid.phi_count <= 0) - fail("COMPUTE FAR FIELD", "GRID VALUES ARE INVALID"); + const size_t sample_count = + checked_field_sample_count(grid, "COMPUTE FAR FIELD"); + nec_far_field_result copied; + copied.radius_m = grid.radius_m; + copied.frequency_mhz = m_frequency_mhz; + populate_field_axes( + grid, copied.theta_deg, copied.phi_deg, "COMPUTE FAR FIELD"); + copied.e_theta.assign(sample_count, nec_complex(0.0, 0.0)); + copied.e_phi.assign(sample_count, nec_complex(0.0, 0.0)); m_context->stateful_clear_results(RESULT_RADIATION_PATTERN); + const bool zero_excitation = std::all_of( + currents.begin(), currents.end(), + [](nec_complex current) { return current == nec_complex(0.0, 0.0); }); + if (zero_excitation) + return copied; + m_context->rp_card( 0, grid.theta_count, grid.phi_count, 0, 0, 0, 0, @@ -531,7 +625,116 @@ const nec_radiation_pattern& nec_stateful_model::compute_far_field( nec_radiation_pattern* result = m_context->get_radiation_pattern(0); if (result == nullptr) fail("COMPUTE FAR FIELD", "ENGINE DID NOT RETURN A RADIATION PATTERN"); - return *result; + const complex_array e_theta = result->get_e_theta(); + const complex_array e_phi = result->get_e_phi(); + if (static_cast(e_theta.size()) != sample_count || + static_cast(e_phi.size()) != sample_count) + fail("COMPUTE FAR FIELD", "ENGINE RETURNED THE WRONG FIELD SAMPLE COUNT"); + + for (size_t index = 0; index < sample_count; ++index) { + copied.e_theta[index] = e_theta[static_cast(index)]; + copied.e_phi[index] = e_phi[static_cast(index)]; + if (!finite_value(copied.e_theta[index].real()) || + !finite_value(copied.e_theta[index].imag()) || + !finite_value(copied.e_phi[index].real()) || + !finite_value(copied.e_phi[index].imag())) + fail("COMPUTE FAR FIELD", "ENGINE RETURNED A NONFINITE FIELD VALUE"); + } + return copied; +} + +const nec_far_field_result& nec_stateful_model::compute_far_field( + const nec_far_field_grid& grid) +{ + require_state(nec_model_state::solved, "COMPUTE FAR FIELD"); + nec_far_field_result result = calculate_far_field(grid, m_port_currents); + m_far_field_result = std::move(result); + return m_far_field_result; +} + +const nec_embedded_far_field_result& +nec_stateful_model::compute_embedded_far_fields( + const nec_far_field_grid& grid, + nec_embedded_field_normalization normalization) +{ + if (m_state != nec_model_state::prepared && m_state != nec_model_state::solved) + fail("COMPUTE EMBEDDED FAR FIELDS", "MODEL IS NOT PREPARED"); + + const size_t samples_per_port = + checked_field_sample_count(grid, "COMPUTE EMBEDDED FAR FIELDS"); + switch (normalization) { + case nec_embedded_field_normalization::unit_voltage: + case nec_embedded_field_normalization::unit_current: + break; + default: + fail("COMPUTE EMBEDDED FAR FIELDS", "UNKNOWN NORMALIZATION"); + } + if (m_ports.size() > + std::vector().max_size() / samples_per_port) + fail("COMPUTE EMBEDDED FAR FIELDS", "EMBEDDED SAMPLE COUNT IS TOO LARGE"); + + const nec_complex_matrix* impedance = nullptr; + if (normalization == nec_embedded_field_normalization::unit_current) + impedance = &compute_impedance_matrix().impedance; + + const bool had_solution = m_has_port_solution; + const nec_port_solution saved_solution = m_last_port_solution; + const size_t embedded_sample_count = m_ports.size() * samples_per_port; + nec_embedded_far_field_result embedded; + embedded.radius_m = grid.radius_m; + embedded.frequency_mhz = m_frequency_mhz; + embedded.ports = m_ports; + embedded.normalization = normalization; + embedded.samples_per_port = samples_per_port; + populate_field_axes( + grid, embedded.theta_deg, embedded.phi_deg, + "COMPUTE EMBEDDED FAR FIELDS"); + embedded.e_theta.assign( + embedded_sample_count, nec_complex(0.0, 0.0)); + embedded.e_phi.assign( + embedded_sample_count, nec_complex(0.0, 0.0)); + + try { + std::vector basis_voltages( + m_ports.size(), nec_complex(0.0, 0.0)); + std::vector achieved_voltages; + std::vector achieved_currents; + for (size_t port_index = 0; port_index < m_ports.size(); ++port_index) { + std::fill( + basis_voltages.begin(), basis_voltages.end(), + nec_complex(0.0, 0.0)); + if (normalization == nec_embedded_field_normalization::unit_voltage) { + basis_voltages[port_index] = nec_complex(1.0, 0.0); + } else { + for (size_t row = 0; row < impedance->rows; ++row) + basis_voltages[row] = impedance->at(row, port_index); + } + + execute_voltage_solve( + basis_voltages, achieved_voltages, achieved_currents); + const nec_far_field_result basis = + calculate_far_field(grid, achieved_currents); + const size_t offset = port_index * samples_per_port; + std::copy( + basis.e_theta.begin(), basis.e_theta.end(), + embedded.e_theta.begin() + static_cast(offset)); + std::copy( + basis.e_phi.begin(), basis.e_phi.end(), + embedded.e_phi.begin() + static_cast(offset)); + } + } catch (...) { + const std::exception_ptr failure = std::current_exception(); + try { + restore_after_internal_solves(had_solution, saved_solution); + } catch (...) { + // Never expose an arbitrary basis solution if restoration fails. + } + std::rethrow_exception(failure); + } + restore_after_internal_solves(had_solution, saved_solution); + + m_embedded_far_field_result = std::move(embedded); + return m_embedded_far_field_result; } size_t nec_stateful_model::retained_result_count() const diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index f3efc66c..a31d3e90 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -17,8 +17,6 @@ #include class nec_context; -class nec_radiation_pattern; - enum class nec_model_state { empty, geometry_building, @@ -92,6 +90,53 @@ struct nec_far_field_grid { nec_float phi_step_deg = 0.0; }; +/*! Copied complex far fields in theta-fast angular order. + * + * Sample index is phi_index * theta_deg.size() + theta_index. Fields are + * volts per metre and include the exp(-j k R) / R propagation factor. + */ +struct nec_far_field_result { + nec_float radius_m = 1.0; + nec_float frequency_mhz = 0.0; + std::vector theta_deg; + std::vector phi_deg; + std::vector e_theta; + std::vector e_phi; + + size_t sample_count() const { return e_theta.size(); } + + const nec_complex& e_theta_at(size_t theta_index, size_t phi_index) const; + const nec_complex& e_phi_at(size_t theta_index, size_t phi_index) const; +}; + +enum class nec_embedded_field_normalization { + unit_voltage, + unit_current, +}; + +/*! One copied complex far-field basis per port in stable port order. + * + * Embedded index is port_index * samples_per_port plus the theta-fast sample + * index used by nec_far_field_result. + */ +struct nec_embedded_far_field_result { + nec_float radius_m = 1.0; + nec_float frequency_mhz = 0.0; + std::vector theta_deg; + std::vector phi_deg; + std::vector ports; + nec_embedded_field_normalization normalization = + nec_embedded_field_normalization::unit_voltage; + size_t samples_per_port = 0; + std::vector e_theta; + std::vector e_phi; + + const nec_complex& e_theta_at( + size_t port_index, size_t theta_index, size_t phi_index) const; + const nec_complex& e_phi_at( + size_t port_index, size_t theta_index, size_t phi_index) const; +}; + /*! Dense complex matrix in stable row-major port order. */ struct nec_complex_matrix { size_t rows = 0; @@ -181,13 +226,18 @@ class nec_stateful_model { const nec_port_solution& last_port_solution() const; - /*! Calculate a raw NEC radiation-pattern result for the latest solution. + /*! Copy complex far fields for the latest consumer solution. */ + const nec_far_field_result& compute_far_field(const nec_far_field_grid& grid); + + /*! Copy one voltage- or current-normalized complex field basis per port. * - * WP3 supplies the stable copied complex-field API. This WP1 hook exists - * to exercise far-field sampling against the retained factorization. The - * reference remains valid until the next solve or far-field call. + * Internal solves preserve a prior consumer solution and public generation; + * from prepared state the model remains prepared. */ - const nec_radiation_pattern& compute_far_field(const nec_far_field_grid& grid); + const nec_embedded_far_field_result& compute_embedded_far_fields( + const nec_far_field_grid& grid, + nec_embedded_field_normalization normalization = + nec_embedded_field_normalization::unit_voltage); const std::vector& ports() const { return m_ports; } const std::vector& port_currents() const { return m_port_currents; } @@ -211,6 +261,9 @@ class nec_stateful_model { std::vector& achieved_currents); void restore_after_internal_solves( bool had_solution, const nec_port_solution& saved_solution); + nec_far_field_result calculate_far_field( + const nec_far_field_grid& grid, + const std::vector& currents); const nec_port_solution& finish_consumer_solve( nec_port_drive drive, const std::vector& requested, @@ -225,6 +278,8 @@ class nec_stateful_model { nec_complex_matrix m_admittance_matrix; nec_impedance_result m_impedance_result; nec_port_solution m_last_port_solution; + nec_far_field_result m_far_field_result; + nec_embedded_far_field_result m_embedded_far_field_result; nec_float m_frequency_mhz = 0.0; uint64_t m_factorization_generation = 0; uint64_t m_solve_generation = 0; diff --git a/src/nec_stateful_model_tb.cpp b/src/nec_stateful_model_tb.cpp index 8984aeac..ecb3ed88 100644 --- a/src/nec_stateful_model_tb.cpp +++ b/src/nec_stateful_model_tb.cpp @@ -2,7 +2,6 @@ #include #include "nec_exception.h" -#include "nec_radiation_pattern.h" #include "nec_stateful_model.h" #include @@ -165,18 +164,18 @@ TEST_CASE("WP1 far-field grids do not invalidate the factorization", build_dipole(model); solve_dipole(model, 300.0); - const nec_radiation_pattern& first = model.compute_far_field({ + const nec_far_field_result& first = model.compute_far_field({ 1.0, 0.0, 3, 45.0, 0.0, 2, 90.0, }); - REQUIRE(first.get_e_theta().size() == 6); + REQUIRE(first.e_theta.size() == 6); REQUIRE(model.factorization_generation() == 1); REQUIRE(model.solve_generation() == 1); REQUIRE(model.retained_result_count() == 2); - const nec_radiation_pattern& second = model.compute_far_field({ + const nec_far_field_result& second = model.compute_far_field({ 2.0, 10.0, 2, 20.0, 15.0, 3, 30.0, }); - REQUIRE(second.get_e_theta().size() == 6); + REQUIRE(second.e_theta.size() == 6); REQUIRE(model.factorization_generation() == 1); REQUIRE(model.solve_generation() == 1); REQUIRE(model.retained_result_count() == 2); diff --git a/src/nec_stateful_model_wp3_tb.cpp b/src/nec_stateful_model_wp3_tb.cpp new file mode 100644 index 00000000..8b3082df --- /dev/null +++ b/src/nec_stateful_model_wp3_tb.cpp @@ -0,0 +1,280 @@ +#include +#include + +#include "electromag.h" +#include "nec_exception.h" +#include "nec_stateful_model.h" + +#include +#include +#include +#include + +namespace { + +constexpr nec_float kFrequencyMHz = 300.0; +constexpr int kSegments = 11; +constexpr int kFeedSegment = 6; + +nec_wire_definition dipole_wire(int tag, nec_float x_m) +{ + return { + tag, kSegments, + x_m, 0.0, -0.25, + x_m, 0.0, 0.25, + 0.001, + }; +} + +void build_dipoles(nec_stateful_model& model, size_t count) +{ + for (size_t index = 0; index < count; ++index) + model.add_wire(dipole_wire(static_cast(index + 1), 0.20 * index)); + model.complete_geometry(); + + std::vector ports; + for (size_t index = 0; index < count; ++index) + ports.push_back({static_cast(index + 1), kFeedSegment}); + model.define_ports(ports); + model.prepare(kFrequencyMHz); +} + +bool finite_complex(nec_complex value) +{ + return std::isfinite(value.real()) && std::isfinite(value.imag()); +} + +nec_float relative_error( + const std::vector& first, + const std::vector& second) +{ + REQUIRE(first.size() == second.size()); + nec_float difference_squared = 0.0; + nec_float first_squared = 0.0; + nec_float second_squared = 0.0; + for (size_t index = 0; index < first.size(); ++index) { + REQUIRE(finite_complex(first[index])); + REQUIRE(finite_complex(second[index])); + difference_squared += std::norm(first[index] - second[index]); + first_squared += std::norm(first[index]); + second_squared += std::norm(second[index]); + } + return std::sqrt(difference_squared) / + std::max({nec_float(1.0), std::sqrt(first_squared), std::sqrt(second_squared)}); +} + +std::vector superpose( + const std::vector& embedded, + size_t samples_per_port, + const std::vector& weights) +{ + std::vector combined( + samples_per_port, nec_complex(0.0, 0.0)); + for (size_t port = 0; port < weights.size(); ++port) { + for (size_t sample = 0; sample < samples_per_port; ++sample) + combined[sample] += + weights[port] * embedded[port * samples_per_port + sample]; + } + return combined; +} + +const nec_far_field_grid kFieldGrid{ + 1.0, + 30.0, 5, 30.0, + 0.0, 3, 90.0, +}; + +} // namespace + +TEST_CASE("WP3 copied far fields retain axes, theta-fast order, and range phase", + "[wasm_api][wp3][far_field]") +{ + nec_stateful_model model; + build_dipoles(model, 1); + model.solve_port_voltages({nec_complex(1.0, 0.0)}); + + const nec_far_field_result field_1 = model.compute_far_field({ + 1.0, + 30.0, 3, 30.0, + 10.0, 2, 40.0, + }); + REQUIRE(field_1.radius_m == 1.0); + REQUIRE(field_1.frequency_mhz == kFrequencyMHz); + const std::vector expected_theta{30.0, 60.0, 90.0}; + const std::vector expected_phi{10.0, 50.0}; + REQUIRE(field_1.theta_deg == expected_theta); + REQUIRE(field_1.phi_deg == expected_phi); + REQUIRE(field_1.e_theta.size() == 6); + REQUIRE(field_1.e_phi.size() == 6); + for (size_t phi = 0; phi < field_1.phi_deg.size(); ++phi) { + for (size_t theta = 0; theta < field_1.theta_deg.size(); ++theta) { + const size_t sample = phi * field_1.theta_deg.size() + theta; + REQUIRE(field_1.e_theta_at(theta, phi) == field_1.e_theta[sample]); + REQUIRE(field_1.e_phi_at(theta, phi) == field_1.e_phi[sample]); + REQUIRE(finite_complex(field_1.e_theta[sample])); + REQUIRE(finite_complex(field_1.e_phi[sample])); + } + } + REQUIRE_THROWS_AS(field_1.e_theta_at(3, 0), std::out_of_range); + REQUIRE_THROWS_AS(field_1.e_phi_at(0, 2), std::out_of_range); + + const nec_far_field_result field_2 = model.compute_far_field({ + 2.0, + 30.0, 3, 30.0, + 10.0, 2, 40.0, + }); + const nec_float wavelength_m = + em::get_wavelength(kFrequencyMHz * 1.0e6); + const nec_complex expected_ratio = + 0.5 * std::polar(1.0, -two_pi() / wavelength_m); + for (size_t sample = 0; sample < field_1.sample_count(); ++sample) { + if (std::abs(field_1.e_theta[sample]) > 1.0e-12) + REQUIRE(std::abs( + field_2.e_theta[sample] / field_1.e_theta[sample] - + expected_ratio) < 1.0e-10); + if (std::abs(field_1.e_phi[sample]) > 1.0e-12) + REQUIRE(std::abs( + field_2.e_phi[sample] / field_1.e_phi[sample] - + expected_ratio) < 1.0e-10); + } + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); +} + +TEST_CASE("WP3 voltage-normalized embedded fields superpose to the direct field", + "[wasm_api][wp3][embedded][voltage]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + + const nec_embedded_far_field_result embedded = + model.compute_embedded_far_fields(kFieldGrid); + REQUIRE(model.state() == nec_model_state::prepared); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 0); + REQUIRE(model.retained_result_count() == 0); + REQUIRE(embedded.normalization == + nec_embedded_field_normalization::unit_voltage); + REQUIRE(embedded.ports.size() == 2); + REQUIRE(embedded.samples_per_port == 15); + REQUIRE(embedded.e_theta.size() == 30); + REQUIRE(embedded.e_phi.size() == 30); + REQUIRE(embedded.e_theta_at(1, 2, 1) == + embedded.e_theta[embedded.samples_per_port + 7]); + + const std::vector voltages{ + nec_complex(0.73, -0.19), + nec_complex(-0.28, 0.41), + }; + model.solve_port_voltages_detailed(voltages); + const nec_far_field_result direct = model.compute_far_field(kFieldGrid); + REQUIRE(relative_error( + superpose(embedded.e_theta, embedded.samples_per_port, voltages), + direct.e_theta) < 1.0e-7); + REQUIRE(relative_error( + superpose(embedded.e_phi, embedded.samples_per_port, voltages), + direct.e_phi) < 1.0e-7); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); +} + +TEST_CASE("WP3 current-normalized embedded fields preserve and reproduce a solution", + "[wasm_api][wp3][embedded][current]") +{ + nec_stateful_model model; + build_dipoles(model, 2); + const std::vector currents{ + nec_complex(0.011, -0.003), + nec_complex(-0.004, 0.008), + }; + const nec_port_solution saved = model.solve_port_currents(currents); + const nec_far_field_result direct = model.compute_far_field(kFieldGrid); + + const nec_embedded_far_field_result embedded = + model.compute_embedded_far_fields( + kFieldGrid, nec_embedded_field_normalization::unit_current); + REQUIRE(embedded.normalization == + nec_embedded_field_normalization::unit_current); + REQUIRE(model.state() == nec_model_state::solved); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.last_port_solution().requested == saved.requested); + REQUIRE(model.last_port_solution().voltages == saved.voltages); + REQUIRE(model.last_port_solution().currents == saved.currents); + REQUIRE(model.retained_result_count() == 1); + + REQUIRE(relative_error( + superpose(embedded.e_theta, embedded.samples_per_port, currents), + direct.e_theta) < 1.0e-7); + REQUIRE(relative_error( + superpose(embedded.e_phi, embedded.samples_per_port, currents), + direct.e_phi) < 1.0e-7); +} + +TEST_CASE("WP3 exact-zero excitation returns finite exact-zero fields", + "[wasm_api][wp3][far_field][zero]") +{ + nec_stateful_model model; + build_dipoles(model, 1); + model.solve_port_voltages({nec_complex(0.0, 0.0)}); + + const nec_far_field_result& field = model.compute_far_field(kFieldGrid); + REQUIRE(field.sample_count() == 15); + REQUIRE(std::all_of( + field.e_theta.begin(), field.e_theta.end(), + [](nec_complex value) { return value == nec_complex(0.0, 0.0); })); + REQUIRE(std::all_of( + field.e_phi.begin(), field.e_phi.end(), + [](nec_complex value) { return value == nec_complex(0.0, 0.0); })); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.solve_generation() == 1); + REQUIRE(model.retained_result_count() == 1); +} + +TEST_CASE("WP3 center-fed dipole fields have axial nulls and mirror symmetry", + "[wasm_api][wp3][far_field][symmetry]") +{ + nec_stateful_model model; + build_dipoles(model, 1); + model.solve_port_voltages({nec_complex(1.0, 0.0)}); + const nec_far_field_result& field = model.compute_far_field({ + 1.0, + 0.0, 5, 45.0, + 0.0, 1, 0.0, + }); + + const nec_float broadside = std::abs(field.e_theta_at(2, 0)); + REQUIRE(broadside > 1.0e-8); + REQUIRE(std::abs(field.e_theta_at(0, 0)) < broadside * 1.0e-10); + REQUIRE(std::abs(field.e_theta_at(4, 0)) < broadside * 1.0e-10); + REQUIRE(std::abs(field.e_theta_at(1, 0) - field.e_theta_at(3, 0)) < + broadside * 1.0e-8); + for (const nec_complex e_phi : field.e_phi) + REQUIRE(std::abs(e_phi) < broadside * 1.0e-10); +} + +TEST_CASE("WP3 ground-skipped angles have deterministic zero field entries", + "[wasm_api][wp3][far_field][ground]") +{ + nec_stateful_model model; + model.add_wire({ + 1, kSegments, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.25, + 0.001, + }); + model.complete_geometry(nec_ground_connection::interpolate); + model.define_ports({{1, kFeedSegment}}); + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); + model.prepare(kFrequencyMHz); + model.solve_port_voltages({nec_complex(1.0, 0.0)}); + + const nec_far_field_result& field = model.compute_far_field({ + 1.0, + 80.0, 2, 40.0, + 0.0, 1, 0.0, + }); + REQUIRE(std::abs(field.e_theta_at(0, 0)) > 1.0e-12); + REQUIRE(field.e_theta_at(1, 0) == nec_complex(0.0, 0.0)); + REQUIRE(field.e_phi_at(1, 0) == nec_complex(0.0, 0.0)); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b8df9c3a..97b71bf5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,7 +29,7 @@ set(CATCH_INSTALL_HELPERS OFF CACHE BOOL "" FORCE) set(BUILD_SHARED_LIBS OFF) FetchContent_MakeAvailable(Catch2) -# All 7 test files (including math_util_tb.cpp, which the old Makefile +# All test files (including math_util_tb.cpp, which the old Makefile # excluded for historical reasons — it has a legitimate [nec_3vector] test). set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/safe_array_tb.cpp @@ -43,6 +43,7 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/wasm_api_contract_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp2_tb.cpp + ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp3_tb.cpp ) # Test runner = Catch2 main + test sources + nec2cpp.cpp (main renamed) + the @@ -96,11 +97,13 @@ endif() # stress suite on independent timeout budgets. Otherwise a slow numerical # fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests "~[wp1]~[wp2]") + COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]") add_test(NAME necpp_wp1 COMMAND nec2++_tests "[wp1]") add_test(NAME necpp_wp2 COMMAND nec2++_tests "[wp2]") +add_test(NAME necpp_wp3 + COMMAND nec2++_tests "[wp3]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of # blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a # 1,000-excitation retained-factorization stress case; it completes in a few @@ -108,6 +111,7 @@ add_test(NAME necpp_wp2 set_tests_properties(necpp_unit PROPERTIES TIMEOUT 480) set_tests_properties(necpp_wp1 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp2 PROPERTIES TIMEOUT 180) +set_tests_properties(necpp_wp3 PROPERTIES TIMEOUT 180) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From 07761ff7a100c4a1403ff3d49464af6515b9f0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 12:17:49 +0200 Subject: [PATCH 20/46] WP4 --- CMakeLists.txt | 2 +- INSTALL.md | 36 +- docs/local-build-environment.md | 18 +- docs/ts_engine_plan.md | 30 +- docs/wasm-api.md | 6 +- docs/wp4-stable-wasm-abi.md | 98 +++ scripts/build_wasm_inner.sh | 2 +- scripts/wasm_smoke_test.mjs | 288 ++++++-- src/CMakeLists.txt | 6 +- src/nec_deck.cpp | 95 ++- src/nec_deck.h | 12 + src/nec_wasm.cpp | 148 ---- src/necpp_wasm_v1.cpp | 1182 +++++++++++++++++++++++++++++++ src/necpp_wasm_v1.h | 216 ++++++ src/necpp_wasm_v1_c_tb.c | 290 ++++++++ src/necpp_wasm_v1_tb.cpp | 133 ++++ tests/CMakeLists.txt | 8 +- 17 files changed, 2335 insertions(+), 235 deletions(-) create mode 100644 docs/wp4-stable-wasm-abi.md delete mode 100644 src/nec_wasm.cpp create mode 100644 src/necpp_wasm_v1.cpp create mode 100644 src/necpp_wasm_v1.h create mode 100644 src/necpp_wasm_v1_c_tb.c create mode 100644 src/necpp_wasm_v1_tb.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f780968..64c9bfb9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,7 +17,7 @@ project(necpp VERSION 2.3.4 DESCRIPTION "NEC2++ antenna modelling library and tools" HOMEPAGE_URL "https://github.com/tmolteno/necpp" - LANGUAGES CXX) + LANGUAGES C CXX) # --- C++ standard ---------------------------------------------------------- # C++17 is required by the codebase (structured bindings, etc.). diff --git a/INSTALL.md b/INSTALL.md index 534e9130..ea817a45 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -102,8 +102,8 @@ build from inside WSL. If you prefer WSL, enable *Settings → Resources → WSL Integration* for your distro so `docker` works inside Ubuntu, then use the `.sh` script from there. -Both produce `wasm/nec2pp.js` + `wasm/nec2pp.wasm` exposing a C API -(`nec_create_context`, `nec_process_input`, `nec_get_output`, …). +Both produce `wasm/nec2pp.js` + `wasm/nec2pp.wasm` exposing the versioned +`necpp_wasm_v1_*` C API declared in `src/necpp_wasm_v1.h`. The module is an ES module factory. Runtime helpers and C exports are available on the initialized module object: @@ -112,25 +112,31 @@ on the initialized module object: import createNecModule from "./wasm/nec2pp.js"; const module = await createNecModule(); -const context = module._nec_create_context(); +const encoded = new TextEncoder().encode(necInputText); +const input = module._malloc(encoded.length); +module.HEAPU8.set(encoded, input); +const deck = module._necpp_wasm_v1_deck_create(); try { - const status = module.ccall( - "nec_process_input", "number", ["number", "string"], - [context, necInputText]); - const length = module._nec_get_output_length(context); - const output = module.UTF8ToString( - module._nec_get_output(context), length); + const status = module._necpp_wasm_v1_deck_process( + deck, input, encoded.length); + const length = module._necpp_wasm_v1_deck_output_length(deck); + const pointer = module._necpp_wasm_v1_deck_output(deck); + const output = new TextDecoder().decode( + module.HEAPU8.subarray(pointer, pointer + length)); if (status !== 0) - throw new Error(output); + throw new Error("NEC deck solve failed"); } finally { - module._nec_delete_context(context); + module._free(input); + module._necpp_wasm_v1_deck_delete(deck); } ``` -`nec_process_input` consumes the supplied complete deck and returns `0` on -success. Parse and solver failures return a negative status with a controlled -message available through `nec_get_output`; C++ exceptions do not cross the -WASM boundary. +`necpp_wasm_v1_deck_process` consumes an explicit UTF-8 pointer and byte +length and returns `0` on success. Failures return a stable positive status +with a controlled message available through +`necpp_wasm_v1_deck_last_error`; C++ exceptions do not cross the WASM +boundary. Stateful geometry, matrix, excitation, and complex far-field calls +use the separate model handle documented in `docs/wp4-stable-wasm-abi.md`. ## Using the library from another project diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 799b242d..13802074 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -22,7 +22,7 @@ command executed by Docker. ## Existing Windows/MSVC build `build-wp0` is the usable host build tree. Despite its historical name, it is -regenerated from the current source and contains the WP1, WP2, and WP3 test +regenerated from the current source and contains the WP1 through WP4 test partitions. Its relevant configuration is: - generator: `Visual Studio 17 2022` @@ -74,11 +74,11 @@ Run all registered tests: Run one Catch2 work-package partition directly: ```powershell -& "build-wp0\tests\Release\nec2++_tests.exe" "[wp3]" +& "build-wp0\tests\Release\nec2++_tests.exe" "[wp4]" ``` Registered CTest entries are `necpp_unit`, `necpp_wp1`, `necpp_wp2`, -`necpp_wp3`, and `necpp_smoke_hertzian_dipole`. The test binary is compiled +`necpp_wp3`, `necpp_wp4`, and `necpp_smoke_hertzian_dipole`. The test binary is compiled with `NEC_ERROR_CHECK=1`, so direct runs can emit substantial solver tracing. ## Docker native build @@ -136,10 +136,12 @@ and all `build-*` directories are ignored by Git. ## Known-good verification -The WP3 implementation was verified with: +The WP4 implementation was verified with: -- Windows/MSVC: all five CTest entries passed, including the CLI smoke test; -- focused WP3: 6 cases and 206 assertions passed; -- Linux/GCC in Docker: all five CTest entries passed; +- Windows/MSVC: all six CTest entries passed, including the CLI smoke test; +- focused WP4: the C caller contract and native bulk-buffer comparison passed; +- Linux/GCC in Docker: all six CTest entries passed; - both native production executables, `nec2++` and `nec2diff`, built - successfully in the Docker Release build. + successfully in the Docker Release build; +- Emscripten 4.0.7: the versioned ABI matrix, solve, combined/embedded field, + memory-growth, controlled-error, and complete-deck smoke paths passed. diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 1c9d5c5f..d54ac480 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -418,7 +418,7 @@ DoD: with 206 assertions, the WP0/WP1/WP2 partitions remain green, both native production targets build, and the legacy CLI smoke test passes. -The next open package on the critical path is WP4. +The WP4 boundary built on these results is summarized below. --- @@ -466,6 +466,34 @@ DoD: - The public JS layer contains no C++ ownership concepts. - ABI versioning allows future additions without silently changing existing signatures. +### WP4 progress + +- Added the C-compatible + [`necpp_wasm_v1.h`](../src/necpp_wasm_v1.h) boundary with opaque stateful + model and complete-deck handles. Every symbol is versioned and ABI/engine + version getters are available. +- Contained all native exceptions and added stable state, input, geometry, + port, conditioning, solver, and runtime status categories with a diagnostic + string retained independently by each handle. +- Added pointer-plus-length port and complex-drive inputs. Matrices, complete + port solutions, combined complex fields, and embedded complex fields are + copied into model-owned split binary64 buffers with explicit lengths and + scalar metadata. +- Restricted the generated Emscripten surface to the versioned ABI, + `_malloc`/`_free`, and the required `HEAPU8`, `HEAP32`, and `HEAPF64` views. + The unversioned deck ABI and `ccall`/`cwrap` helpers are no longer exported. +- Added a contract test compiled as C and a native-to-ABI bulk-buffer + comparison in the `[wp4]` partition. They cover controlled failures, + lifecycle and invalidation behavior, every result family, both drive and + embedded normalization modes, deck compatibility, and repeated cleanup. +- Expanded the Docker smoke test to perform real matrix, solve, combined-field, + embedded-field, and deck operations through direct WASM exports. It also + forces memory growth after copying a field result. +- Added [`docs/wp4-stable-wasm-abi.md`](wp4-stable-wasm-abi.md) as the ABI, + ownership, status, and Emscripten export reference. + +The next open package on the critical path is WP5. + --- ## WP5 — TypeScript facade diff --git a/docs/wasm-api.md b/docs/wasm-api.md index afedfc22..a9975efe 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,8 +1,8 @@ # `@necpp/wasm` API and numerical contract -Status: WP0 specification, 2026-08-28. This document is normative for the -stateful native layer, C/WASM ABI, and handwritten TypeScript facade that -follow. The committed TypeScript surface is in +Status: normative specification, updated through WP4 on 2026-08-28. The +stateful native layer and versioned C/WASM ABI are implemented; the handwritten +TypeScript facade follows in WP5. The committed TypeScript surface is in [`packages/necpp-wasm/src`](../packages/necpp-wasm/src). ## Package and runtime boundary diff --git a/docs/wp4-stable-wasm-abi.md b/docs/wp4-stable-wasm-abi.md new file mode 100644 index 00000000..f841bcfb --- /dev/null +++ b/docs/wp4-stable-wasm-abi.md @@ -0,0 +1,98 @@ +# WP4 stable C/WASM ABI + +WP4 exposes the deck-free engine through +[`necpp_wasm_v1.h`](../src/necpp_wasm_v1.h). The header is valid C and is also +the complete contract exported by the Emscripten build. C++ classes, standard +library objects, exceptions, raw `std::complex` storage, and solver internals +do not cross this boundary. + +## Versioning and errors + +Every symbol starts with `necpp_wasm_v1_`. `necpp_wasm_v1_abi_version()` +returns `1`; later incompatible ABIs must use a new symbol prefix rather than +change an existing signature. `necpp_wasm_v1_engine_version()` returns the +configured NEC2++ release version. + +Stateful calculations return one of the stable status values: + +- `OK`; +- `STATE_ERROR`; +- `INPUT_ERROR`; +- `GEOMETRY_ERROR`; +- `PORT_ERROR`; +- `CONDITIONING_ERROR`; +- `SOLVER_ERROR`; +- `RUNTIME_ERROR`. + +No exception is allowed to cross an ABI function. Each model retains its own +last status and diagnostic string. A successful status-returning operation +clears the previous diagnostic. Creation reports allocation failure with a +null handle; deletion accepts a null handle, which makes cleanup safe after +partial initialization. + +## Inputs and results + +Ports use parallel `int32_t` tag and segment arrays. Complex drives use +parallel binary64 real and imaginary arrays. All arrays are pointer plus +length; null pointers, wrong lengths, nonfinite values, and illegal lifecycle +calls return controlled statuses without trapping the WASM runtime. + +Calculations copy native complex results into model-owned split binary64 +buffers. `necpp_wasm_v1_result_buffer()` selects a documented buffer kind and +`necpp_wasm_v1_result_buffer_length()` gives its element count. Matrix values +remain row-major, far-field samples remain theta-fast, and embedded fields +remain port-major. Scalar accessors provide matrix order, grid dimensions, +frequency, normalization, condition estimate, and generations. + +Returned pointers are borrowed. A successful operation replacing the same +result category, configuration invalidation, or model deletion can invalidate +them. A JavaScript wrapper must copy them immediately from `HEAPF64`; it must +not retain a WASM heap view across a call or memory growth. + +The model handle separately retains: + +- ordered port tag and segment arrays; +- impedance and admittance buffers; +- the latest consumer port solution; +- the latest combined far field; +- the latest embedded far-field basis. + +Unrelated result categories do not alias one another. Environment changes +clear every calculated ABI result. A same-frequency no-op `prepare` preserves +the native and ABI caches. + +## Complete-deck compatibility + +The independent `necpp_wasm_v1_deck_*` handle accepts a UTF-8 pointer and +explicit byte length and returns the formatted report through a borrowed +string buffer. It exists for the future `runDeck()` facade and does not share +state with a deck-free model. Empty input and embedded NUL bytes are input +errors. Text, geometry, and non-executing card validation failures are input +errors; failures while an execution or field card is running are solver errors. + +## Emscripten surface + +The generated module exports only: + +- the documented `necpp_wasm_v1_*` functions; +- `_malloc` and `_free`; +- `HEAPU8`, `HEAP32`, and `HEAPF64`. + +The old unversioned deck functions and convenience runtime methods such as +`ccall` and `cwrap` are not exported. The build remains modular ES6 with +memory growth enabled. + +## Verification + +The C contract test is compiled as C, linked to the C++ engine, and invoked by +the `[wp4]` Catch2 partition. It covers every operation family, null and +dimension failures, invalid geometry and port addressing, status/message +retention, matrix and field metadata, both drive types, both embedded +normalizations, configuration invalidation, deck compatibility, and repeated +create/delete cleanup. A separate comparison checks split ABI matrix, current, +and complex far-field buffers against direct native results at `1e-12`. + +The Docker WASM smoke test performs a real one-port matrix extraction, voltage +solve, combined field, embedded field, controlled invalid calls, and deck +solve through direct versioned exports. It copies a field result, forces WASM +memory growth, and verifies that the JavaScript-owned copy remains valid. diff --git a/scripts/build_wasm_inner.sh b/scripts/build_wasm_inner.sh index b2929121..d3850a8e 100644 --- a/scripts/build_wasm_inner.sh +++ b/scripts/build_wasm_inner.sh @@ -34,7 +34,7 @@ LINK_FLAGS="-O3 -flto \ -sINVOKE_RUN=0 \ -sEXIT_RUNTIME=0 \ -sALLOW_MEMORY_GROWTH=1 \ --sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,lengthBytesUTF8 \ +-sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAP32,HEAPF64 \ -sDISABLE_EXCEPTION_CATCHING=0 \ --emit-tsd nec2pp.d.ts" diff --git a/scripts/wasm_smoke_test.mjs b/scripts/wasm_smoke_test.mjs index 36d1be72..9ece486a 100644 --- a/scripts/wasm_smoke_test.mjs +++ b/scripts/wasm_smoke_test.mjs @@ -12,66 +12,272 @@ const { default: createNecModule } = await import( ); const module = await createNecModule(); -const context = module._nec_create_context(); -if (!context) { - throw new Error("nec_create_context returned null"); -} +const OK = 0; +const STATE_ERROR = 1; +const INPUT_ERROR = 2; + +const IMPEDANCE_REAL = 0; +const IMPEDANCE_IMAG = 1; +const SOLUTION_CURRENTS_REAL = 8; +const SOLUTION_CURRENTS_IMAG = 9; +const FAR_FIELD_E_THETA_REAL = 15; +const FAR_FIELD_E_THETA_IMAG = 16; +const FAR_FIELD_E_PHI_REAL = 17; +const FAR_FIELD_E_PHI_IMAG = 18; +const EMBEDDED_E_THETA_REAL = 21; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +const check = (condition, message) => { + if (!condition) { + throw new Error(message); + } +}; -const processInput = (deck) => module.ccall( - "nec_process_input", - "number", - ["number", "string"], - [context, deck], +const decodeBytes = (pointer, length) => decoder.decode( + module.HEAPU8.subarray(pointer, pointer + length), ); -const getOutput = () => { - const length = module._nec_get_output_length(context); - const pointer = module._nec_get_output(context); - return { length, text: module.UTF8ToString(pointer, length) }; +const decodeCString = (pointer) => { + let end = pointer; + while (module.HEAPU8[end] !== 0) { + end += 1; + } + return decodeBytes(pointer, end - pointer); +}; +const allocateFloat64 = (values) => { + const pointer = module._malloc(values.length * Float64Array.BYTES_PER_ELEMENT); + check(pointer !== 0, "WASM allocation failed"); + module.HEAPF64.set(values, pointer / Float64Array.BYTES_PER_ELEMENT); + return pointer; }; +const allocateInt32 = (values) => { + const pointer = module._malloc(values.length * Int32Array.BYTES_PER_ELEMENT); + check(pointer !== 0, "WASM allocation failed"); + module.HEAP32.set(values, pointer / Int32Array.BYTES_PER_ELEMENT); + return pointer; +}; +const allocateBytes = (values) => { + const pointer = module._malloc(values.length); + check(pointer !== 0, "WASM allocation failed"); + module.HEAPU8.set(values, pointer); + return pointer; +}; +const copyResult = (model, kind) => { + const length = module._necpp_wasm_v1_result_buffer_length(model, kind); + const pointer = module._necpp_wasm_v1_result_buffer(model, kind); + check(length === 0 || pointer !== 0, `result buffer ${kind} is null`); + return new Float64Array( + module.HEAPF64.buffer, + pointer, + length, + ).slice(); +}; +const modelError = (model) => decodeCString( + module._necpp_wasm_v1_last_error(model), +); const validDeck = `CM WASM STRING INPUT SMOKE TEST CE -GW 0 9 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 +GW 1 11 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 GE 0 FR 0 1 0 0 300.0 -EX 0 0 5 0 1.0 0.0 +EX 0 1 6 0 1.0 0.0 XQ EN `; +check(module._necpp_wasm_v1_abi_version() === 1, "unexpected ABI version"); +check( + decodeCString(module._necpp_wasm_v1_engine_version()).length > 0, + "empty engine version", +); +check(typeof module._malloc === "function", "_malloc was not exported"); +check(typeof module._free === "function", "_free was not exported"); +check(module._nec_create_context === undefined, "legacy ABI leaked into module"); + +const model = module._necpp_wasm_v1_model_create(); +check(model !== 0, "model_create returned null"); +let tagsPointer = 0; +let segmentsPointer = 0; +let realPointer = 0; +let imagPointer = 0; try { - const validStatus = processInput(validDeck); - if (validStatus !== 0) { - throw new Error(`valid deck returned ${validStatus}: ${getOutput().text}`); - } + check( + module._necpp_wasm_v1_prepare(model, 300) === STATE_ERROR, + "illegal lifecycle call did not return a state error", + ); + check(modelError(model).length > 0, "state error message is empty"); + check( + module._necpp_wasm_v1_add_wire( + model, 1, 11, + 0, 0, 0, + 0, 0, 0, + 0.001, + ) === INPUT_ERROR, + "invalid geometry did not return a controlled input error", + ); + check( + module._necpp_wasm_v1_add_wire( + model, 1, 11, + 0, 0, -0.25, + 0, 0, 0.25, + 0.001, + ) === OK, + `addWire failed: ${modelError(model)}`, + ); + check( + module._necpp_wasm_v1_complete_geometry(model, 0) === OK, + `completeGeometry failed: ${modelError(model)}`, + ); - const validOutput = getOutput(); - if (validOutput.length <= 0 || validOutput.text.length <= 0) { - throw new Error("valid deck produced empty output"); - } - if (!validOutput.text.includes("WASM STRING INPUT SMOKE TEST")) { - throw new Error("supplied string marker was not consumed"); - } - if (!validOutput.text.includes("ANTENNA INPUT PARAMETERS")) { - throw new Error("valid deck did not produce the expected NEC result marker"); - } + tagsPointer = allocateInt32(new Int32Array([1])); + segmentsPointer = allocateInt32(new Int32Array([6])); + check( + module._necpp_wasm_v1_define_ports( + model, tagsPointer, segmentsPointer, 1, + ) === OK, + `definePorts failed: ${modelError(model)}`, + ); + check( + module._necpp_wasm_v1_prepare(model, 300) === OK, + `prepare failed: ${modelError(model)}`, + ); + check( + module._necpp_wasm_v1_compute_impedance(model) === OK, + `computeImpedance failed: ${modelError(model)}`, + ); + check(module._necpp_wasm_v1_impedance_order(model) === 1, "wrong matrix order"); + const impedanceReal = copyResult(model, IMPEDANCE_REAL); + const impedanceImag = copyResult(model, IMPEDANCE_IMAG); + check( + impedanceReal.length === 1 && + Number.isFinite(impedanceReal[0]) && + Number.isFinite(impedanceImag[0]) && + impedanceReal[0] > 0, + "invalid one-port impedance", + ); + + realPointer = allocateFloat64(new Float64Array([1])); + imagPointer = allocateFloat64(new Float64Array([0])); + check( + module._necpp_wasm_v1_solve_voltages( + model, realPointer, imagPointer, 1, + ) === OK, + `solveVoltages failed: ${modelError(model)}`, + ); + check(module._necpp_wasm_v1_solution_count(model) === 1, "wrong solution size"); + const currentReal = copyResult(model, SOLUTION_CURRENTS_REAL); + const currentImag = copyResult(model, SOLUTION_CURRENTS_IMAG); + check( + Number.isFinite(currentReal[0]) && Number.isFinite(currentImag[0]), + "nonfinite port current", + ); + + check( + module._necpp_wasm_v1_compute_far_field( + model, + 1, + 0, 3, 45, + 0, 2, 90, + ) === OK, + `computeFarField failed: ${modelError(model)}`, + ); + check( + module._necpp_wasm_v1_far_field_theta_count(model) === 3 && + module._necpp_wasm_v1_far_field_phi_count(model) === 2, + "wrong far-field dimensions", + ); + const copiedFields = [ + copyResult(model, FAR_FIELD_E_THETA_REAL), + copyResult(model, FAR_FIELD_E_THETA_IMAG), + copyResult(model, FAR_FIELD_E_PHI_REAL), + copyResult(model, FAR_FIELD_E_PHI_IMAG), + ]; + check( + copiedFields.every( + (field) => field.length === 6 && field.every(Number.isFinite), + ), + "invalid far-field buffers", + ); + + const oldHeap = module.HEAPU8.buffer; + const growthPointer = module._malloc(oldHeap.byteLength + 65536); + check(growthPointer !== 0, "memory-growth allocation failed"); + check(module.HEAPU8.buffer !== oldHeap, "WASM memory did not grow"); + module._free(growthPointer); + check( + copiedFields.every((field) => field.every(Number.isFinite)), + "JavaScript result copies changed after WASM memory growth", + ); + + check( + module._necpp_wasm_v1_compute_embedded_far_fields( + model, + 1, + 90, 1, 0, + 0, 1, 0, + 0, + ) === OK, + `computeEmbeddedFarFields failed: ${modelError(model)}`, + ); + check( + module._necpp_wasm_v1_embedded_samples_per_port(model) === 1, + "wrong embedded-field dimensions", + ); + check( + copyResult(model, EMBEDDED_E_THETA_REAL).every(Number.isFinite), + "invalid embedded field", + ); +} finally { + if (tagsPointer) module._free(tagsPointer); + if (segmentsPointer) module._free(segmentsPointer); + if (realPointer) module._free(realPointer); + if (imagPointer) module._free(imagPointer); + module._necpp_wasm_v1_model_delete(model); +} + +const deck = module._necpp_wasm_v1_deck_create(); +check(deck !== 0, "deck_create returned null"); +let deckPointer = 0; +try { + const encodedDeck = encoder.encode(validDeck); + deckPointer = allocateBytes(encodedDeck); + const validStatus = module._necpp_wasm_v1_deck_process( + deck, deckPointer, encodedDeck.length, + ); + check( + validStatus === OK, + `valid deck returned ${validStatus}: ` + + decodeCString(module._necpp_wasm_v1_deck_last_error(deck)), + ); + const outputLength = module._necpp_wasm_v1_deck_output_length(deck); + const output = decodeBytes( + module._necpp_wasm_v1_deck_output(deck), + outputLength, + ); + check(output.includes("WASM STRING INPUT SMOKE TEST"), "deck marker missing"); + check(output.includes("ANTENNA INPUT PARAMETERS"), "deck result marker missing"); - let invalidStatus; + const invalidBytes = encoder.encode("CE INVALID INPUT\nBOGUS\nEN\n"); + const invalidPointer = allocateBytes(invalidBytes); try { - invalidStatus = processInput("CE INVALID INPUT\nBOGUS\nEN\n"); + const invalidStatus = module._necpp_wasm_v1_deck_process( + deck, invalidPointer, invalidBytes.length, + ); + check(invalidStatus === INPUT_ERROR, "invalid deck returned wrong status"); + check( + decodeCString(module._necpp_wasm_v1_deck_last_error(deck)).length > 0, + "invalid deck did not retain an error", + ); } catch (error) { throw new Error(`invalid input caused a WASM trap: ${error}`); - } - - const invalidOutput = getOutput(); - if (invalidStatus >= 0) { - throw new Error(`invalid deck unexpectedly returned ${invalidStatus}`); - } - if (!invalidOutput.text.startsWith("Error:")) { - throw new Error(`invalid deck did not return a controlled error: ${invalidOutput.text}`); + } finally { + module._free(invalidPointer); } } finally { - module._nec_delete_context(context); + if (deckPointer) module._free(deckPointer); + module._necpp_wasm_v1_deck_delete(deck); } -console.log("WASM API smoke test passed"); +console.log("WP4 WASM ABI smoke test passed"); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f08e1ceb..008660ab 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,6 +27,7 @@ set(NECPP_LIB_SRCS nec_results.cpp nec_stateful_model.cpp nec_structure_currents.cpp + necpp_wasm_v1.cpp ) # Public-facing headers installed alongside the library. @@ -39,6 +40,7 @@ set(NECPP_PUBLIC_HEADERS nec_results.h nec_stateful_model.h nec_structure_currents.h + necpp_wasm_v1.h nec_output.h nec_exception.h c_evlcom.h @@ -146,7 +148,6 @@ endif() # --- WASM target (opt-in via -DNECPP_BUILD_WASM=ON, needs emcc) ------------ if(NECPP_BUILD_WASM) add_executable(nec2pp_wasm - ${CMAKE_CURRENT_SOURCE_DIR}/nec_wasm.cpp ${NECPP_LIB_SRCS}) target_include_directories(nec2pp_wasm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} @@ -158,8 +159,7 @@ if(NECPP_BUILD_WASM) target_link_options(nec2pp_wasm PRIVATE -fexceptions -sWASM=1 - "-sEXPORTED_FUNCTIONS=[\"_nec_create_context\",\"_nec_delete_context\",\"_nec_process_input\",\"_nec_get_output\",\"_nec_get_output_length\",\"_nec_free\"]" - "-sEXPORTED_RUNTIME_METHODS=[\"ccall\",\"cwrap\",\"UTF8ToString\",\"lengthBytesUTF8\"]" + "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 --no-entry) diff --git a/src/nec_deck.cpp b/src/nec_deck.cpp index 88d0e86b..d781282a 100644 --- a/src/nec_deck.cpp +++ b/src/nec_deck.cpp @@ -7,9 +7,12 @@ #include "nec_exception.h" #include "nec_output.h" +#include #include +#include #include #include +#include namespace { @@ -32,7 +35,7 @@ void read_comments_or_rewind(std::istream& input, nec_output_file& output) char line[LINE_LEN + 1] = {0}; if ((load_line(line, input) == EOF) && (line[0] == '\0')) - throw nec_exception("Error reading input text."); + throw nec_deck_input_exception("Error reading input text."); std::strncpy(mnemonic, line, 2); if ((0 != std::strcmp(mnemonic, "CM")) && @@ -40,7 +43,7 @@ void read_comments_or_rewind(std::istream& input, nec_output_file& output) input.clear(); input.seekg(job_start); if (!input) - throw nec_exception("Unable to rewind NEC input text."); + throw nec_deck_input_exception("Unable to rewind NEC input text."); return; } @@ -52,14 +55,16 @@ void read_comments_or_rewind(std::istream& input, nec_output_file& output) while (0 == std::strcmp(mnemonic, "CM")) { line[0] = '\0'; if ((load_line(line, input) == EOF) && (line[0] == '\0')) - throw nec_exception("Error reading input text (comments not terminated?)."); + throw nec_deck_input_exception( + "Error reading input text (comments not terminated?)."); std::strncpy(mnemonic, line, 2); mnemonic[2] = '\0'; output.line(&line[2]); } if (0 != std::strcmp(mnemonic, "CE")) - throw nec_exception("ERROR: INCORRECT LABEL FOR A COMMENT CARD"); + throw nec_deck_input_exception( + "ERROR: INCORRECT LABEL FOR A COMMENT CARD"); } nec_card read_control_card(std::istream& input) @@ -74,13 +79,13 @@ nec_card read_control_card(std::istream& input) end.mnemonic = "EN"; return end; } - throw nec_exception( + throw nec_deck_input_exception( "COMMAND DATA CARD ERROR: CARD'S MNEMONIC CODE TOO SHORT OR MISSING."); } nec_card card = parse_nec_card(line); if (card.mnemonic == "XT") - throw nec_exception("XT is not supported by the string API."); + throw nec_deck_input_exception("XT is not supported by the string API."); return card; } @@ -95,6 +100,47 @@ void print_control_card(nec_output_file& output, int count, const nec_card& card card.f[3], card.f[4], card.f[5]); } +bool checked_count_product(const int* counts, size_t count) +{ + size_t product = 1; + for (size_t index = 0; index < count; ++index) { + if (counts[index] < 0) + return false; + const size_t value = counts[index] == 0 + ? size_t(1) + : static_cast(counts[index]); + if (product > std::numeric_limits::max() / value) + return false; + product *= value; + } + return product <= std::vector().max_size(); +} + +void validate_execution_card(const nec_card& card) +{ + for (double value : card.f) { + if (!std::isfinite(value)) + throw nec_deck_input_exception( + "EXECUTION CARD CONTAINS A NONFINITE VALUE."); + } + + if (card.mnemonic == "XQ") { + if (card.i[0] < 0 || card.i[0] > 3) + throw nec_deck_input_exception( + "XQ CARD MODE MUST BE BETWEEN ZERO AND THREE."); + } else if (card.mnemonic == "RP") { + const int counts[] = {card.i[1], card.i[2]}; + if (!checked_count_product(counts, 2)) + throw nec_deck_input_exception( + "RP CARD SAMPLE COUNTS MUST BE POSITIVE AND FIT IN MEMORY."); + } else if (card.mnemonic == "NE" || card.mnemonic == "NH") { + const int counts[] = {card.i[1], card.i[2], card.i[3]}; + if (!checked_count_product(counts, 3)) + throw nec_deck_input_exception( + "NEAR-FIELD CARD SAMPLE COUNTS MUST BE POSITIVE AND FIT IN MEMORY."); + } +} + } // namespace void nec_process_deck(const std::string& input_text, @@ -102,7 +148,7 @@ void nec_process_deck(const std::string& input_text, nec_output_file& output) { if (input_text.empty()) - throw nec_exception("NEC input text is empty."); + throw nec_deck_input_exception("NEC input text is empty."); std::istringstream input(input_text); nec_output_flags output_flags; @@ -113,8 +159,14 @@ void nec_process_deck(const std::string& input_text, print_program_header(output); read_comments_or_rewind(input, output); - context.get_geometry()->parse_geometry(&context, input); - context.calc_prepare(); + try { + context.get_geometry()->parse_geometry(&context, input); + context.calc_prepare(); + } catch (const nec_deck_input_exception&) { + throw; + } catch (const nec_exception& error) { + throw nec_deck_input_exception(error.get_message().c_str()); + } output.end_section(); int data_card_count = 0; @@ -129,12 +181,29 @@ void nec_process_deck(const std::string& input_text, return; } if (card.mnemonic == "PL") - throw nec_exception("PL is not supported by the string API."); + throw nec_deck_input_exception( + "PL is not supported by the string API."); const card_handler* handler = find_handler(card.mnemonic); if (nullptr == handler) - throw nec_exception("FAULTY DATA CARD LABEL AFTER GEOMETRY SECTION."); - handler->dispatch(context, card); + throw nec_deck_input_exception( + "FAULTY DATA CARD LABEL AFTER GEOMETRY SECTION."); + + const bool executes_solver = + card.mnemonic == "XQ" || card.mnemonic == "RP" || + card.mnemonic == "NE" || card.mnemonic == "NH"; + if (executes_solver) { + validate_execution_card(card); + handler->dispatch(context, card); + } else { + try { + handler->dispatch(context, card); + } catch (const nec_deck_input_exception&) { + throw; + } catch (const nec_exception& error) { + throw nec_deck_input_exception(error.get_message().c_str()); + } + } } } } @@ -196,7 +265,7 @@ void handle_cp(nec_context& ctx, const nec_card& c) { void handle_pl(nec_context&, const nec_card&) {} void handle_en(nec_context& ctx, const nec_card&) { ctx.all_jobs_completed(); } void handle_wg(nec_context&, const nec_card&) { - throw nec_exception("\"WG\" card, not supported."); + throw nec_deck_input_exception("\"WG\" card, not supported."); } void handle_mp(nec_context& ctx, const nec_card& c) { ctx.medium_parameters(c.f[0], c.f[1]); diff --git a/src/nec_deck.h b/src/nec_deck.h index 285486f6..e42bb11f 100644 --- a/src/nec_deck.h +++ b/src/nec_deck.h @@ -1,11 +1,23 @@ #pragma once +#include "nec_exception.h" + #include #include class nec_context; class nec_output_file; +/* Invalid text/card input, distinct from a failure while executing a solve. */ +class nec_deck_input_exception : public nec_exception +{ +public: + explicit nec_deck_input_exception(const char* message) + : nec_exception(message) + { + } +}; + /* Process one or more complete NEC jobs supplied as text. */ void nec_process_deck(const std::string& input_text, nec_context& context, diff --git a/src/nec_wasm.cpp b/src/nec_wasm.cpp deleted file mode 100644 index 43a1f787..00000000 --- a/src/nec_wasm.cpp +++ /dev/null @@ -1,148 +0,0 @@ -/* - * String-based C ABI for the reusable Emscripten module. - * - * The WASM target intentionally has no CLI main(). JavaScript supplies a - * complete NEC deck to nec_process_input(), then reads the generated report - * with nec_get_output(). Every exported function contains its exceptions so - * no C++ exception can cross the C/WASM boundary. - */ - -#include "nec_context.h" -#include "nec_deck.h" -#include "nec_exception.h" -#include "nec_output.h" - -#include -#include -#include -#include -#include - -struct nec_wasm_context { - std::unique_ptr context; - std::string output_buffer; - bool has_results = false; -}; - -namespace { - -void store_error(nec_wasm_context* context, - const char* prefix, - const char* message) noexcept -{ - if (nullptr == context) - return; - - context->has_results = false; - try { - context->output_buffer.assign(prefix ? prefix : "Error: "); - if (message) - context->output_buffer.append(message); - } catch (...) { - /* An allocation failure while reporting an error must not escape ABI. */ - try { - context->output_buffer.clear(); - } catch (...) { - } - } -} - -} // namespace - -extern "C" { - -nec_wasm_context* nec_create_context(void) noexcept -{ - try { - return new nec_wasm_context(); - } catch (...) { - return nullptr; - } -} - -void nec_delete_context(nec_wasm_context* context) noexcept -{ - try { - delete context; - } catch (...) { - /* C++ destructors are not allowed to escape the C ABI. */ - } -} - -/* - * Process a complete NEC input deck supplied as a UTF-8 C string. - * Returns 0 on success, or a negative error code: - * -1 : null context or input - * -2 : parse/execution error (message stored in output) - */ -int nec_process_input(nec_wasm_context* context, const char* input_text) noexcept -{ - if ((nullptr == context) || (nullptr == input_text)) - return -1; - - try { - context->has_results = false; - context->output_buffer.clear(); - - /* Each call gets a fresh solver so a failed deck cannot poison the next. */ - std::unique_ptr solver(new nec_context()); - std::ostringstream report; - nec_output_file output; - output.set_stream(report); - - nec_process_deck(input_text, *solver, output); - - context->output_buffer = report.str(); - context->context = std::move(solver); - context->has_results = true; - return 0; - } catch (const nec_exception& error) { - try { - const std::string message = error.get_message(); - store_error(context, "Error: ", message.c_str()); - } catch (...) { - store_error(context, "Error: ", "NEC++ exception"); - } - return -2; - } catch (const std::exception& error) { - store_error(context, "Error: ", error.what()); - return -2; - } catch (const char* error) { - store_error(context, "Error: ", error); - return -2; - } catch (...) { - store_error(context, "Error: ", "Unknown exception"); - return -2; - } -} - -/* Returns the output buffer; the caller must not free it. */ -const char* nec_get_output(nec_wasm_context* context) noexcept -{ - try { - return context ? context->output_buffer.c_str() : ""; - } catch (...) { - return ""; - } -} - -int nec_get_output_length(nec_wasm_context* context) noexcept -{ - try { - if (!context) - return 0; - const size_t length = context->output_buffer.size(); - return length > static_cast(INT_MAX) - ? INT_MAX - : static_cast(length); - } catch (...) { - return 0; - } -} - -/* Reserved for a future API returning caller-owned allocations. */ -void nec_free(void*) noexcept -{ -} - -} /* extern "C" */ diff --git a/src/necpp_wasm_v1.cpp b/src/necpp_wasm_v1.cpp new file mode 100644 index 00000000..6c68d0cd --- /dev/null +++ b/src/necpp_wasm_v1.cpp @@ -0,0 +1,1182 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + Versioned exception-safe C/WASM boundary for the stateful NEC engine. +*/ +#include "necpp_wasm_v1.h" + +#include "config.h" +#include "nec_context.h" +#include "nec_deck.h" +#include "nec_exception.h" +#include "nec_output.h" +#include "nec_stateful_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct split_complex_buffer { + std::vector real; + std::vector imag; + + void clear() + { + real.clear(); + imag.clear(); + } + + void assign(const std::vector& values) + { + std::vector next_real; + std::vector next_imag; + next_real.reserve(values.size()); + next_imag.reserve(values.size()); + for (const nec_complex value : values) { + next_real.push_back(value.real()); + next_imag.push_back(value.imag()); + } + real = std::move(next_real); + imag = std::move(next_imag); + } +}; + +struct impedance_buffers { + split_complex_buffer impedance; + split_complex_buffer admittance; + size_t order = 0; + double frequency_mhz = 0.0; + double condition_estimate = 0.0; + uint64_t factorization_generation = 0; + bool available = false; + + void clear() + { + impedance.clear(); + admittance.clear(); + order = 0; + frequency_mhz = 0.0; + condition_estimate = 0.0; + factorization_generation = 0; + available = false; + } +}; + +struct solution_buffers { + split_complex_buffer requested; + split_complex_buffer voltages; + split_complex_buffer currents; + split_complex_buffer active_impedances; + std::vector powers_w; + int32_t drive = NECPP_WASM_V1_DRIVE_VOLTAGE; + double frequency_mhz = 0.0; + uint64_t factorization_generation = 0; + uint64_t solve_generation = 0; + bool available = false; + + void clear() + { + requested.clear(); + voltages.clear(); + currents.clear(); + active_impedances.clear(); + powers_w.clear(); + drive = NECPP_WASM_V1_DRIVE_VOLTAGE; + frequency_mhz = 0.0; + factorization_generation = 0; + solve_generation = 0; + available = false; + } +}; + +struct far_field_buffers { + std::vector theta_deg; + std::vector phi_deg; + split_complex_buffer e_theta; + split_complex_buffer e_phi; + double radius_m = 0.0; + double frequency_mhz = 0.0; + bool available = false; + + void clear() + { + theta_deg.clear(); + phi_deg.clear(); + e_theta.clear(); + e_phi.clear(); + radius_m = 0.0; + frequency_mhz = 0.0; + available = false; + } +}; + +struct embedded_buffers : far_field_buffers { + size_t port_count = 0; + size_t samples_per_port = 0; + int32_t normalization = NECPP_WASM_V1_UNIT_VOLTAGE; + + void clear() + { + far_field_buffers::clear(); + port_count = 0; + samples_per_port = 0; + normalization = NECPP_WASM_V1_UNIT_VOLTAGE; + } +}; + +} // namespace + +struct necpp_wasm_v1_model { + nec_stateful_model native; + int32_t last_status = NECPP_WASM_V1_OK; + std::string last_error; + std::vector port_tags; + std::vector port_segments; + impedance_buffers impedance; + solution_buffers solution; + far_field_buffers far_field; + embedded_buffers embedded; +}; + +struct necpp_wasm_v1_deck { + int32_t last_status = NECPP_WASM_V1_OK; + std::string last_error; + std::string output; +}; + +namespace { + +constexpr const char* kEmergencyError = + "Unable to retain the native error message"; + +bool finite_value(double value) +{ + return std::isfinite(value); +} + +template +int32_t set_error( + Context* context, int32_t status, const char* message) noexcept +{ + if (context == nullptr) + return status; + context->last_status = status; + try { + context->last_error.assign(message == nullptr ? "" : message); + } catch (...) { + try { + context->last_error.clear(); + } catch (...) { + } + } + return status; +} + +template +void clear_error(Context* context) noexcept +{ + if (context == nullptr) + return; + context->last_status = NECPP_WASM_V1_OK; + try { + context->last_error.clear(); + } catch (...) { + } +} + +template +const char* error_text(const Context* context) noexcept +{ + if (context == nullptr) + return "Null NEC context"; + if (!context->last_error.empty()) + return context->last_error.c_str(); + return context->last_status == NECPP_WASM_V1_OK ? "" : kEmergencyError; +} + +template +int32_t invoke( + necpp_wasm_v1_model* model, + int32_t failure_status, + Function&& function) noexcept +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + try { + function(); + clear_error(model); + return NECPP_WASM_V1_OK; + } catch (const std::bad_alloc& error) { + return set_error(model, NECPP_WASM_V1_RUNTIME_ERROR, error.what()); + } catch (const nec_exception& error) { + try { + return set_error(model, failure_status, error.get_message().c_str()); + } catch (...) { + return set_error(model, failure_status, "NEC native exception"); + } + } catch (const std::exception& error) { + return set_error(model, failure_status, error.what()); + } catch (const char* error) { + return set_error(model, failure_status, error); + } catch (...) { + return set_error(model, failure_status, "Unknown native exception"); + } +} + +int32_t fail( + necpp_wasm_v1_model* model, int32_t status, const char* message) noexcept +{ + return model == nullptr + ? NECPP_WASM_V1_RUNTIME_ERROR + : set_error(model, status, message); +} + +bool is_state( + const necpp_wasm_v1_model* model, nec_model_state expected) noexcept +{ + return model != nullptr && model->native.state() == expected; +} + +bool is_configurable(const necpp_wasm_v1_model* model) noexcept +{ + if (model == nullptr) + return false; + const nec_model_state state = model->native.state(); + return state == nec_model_state::geometry_complete || + state == nec_model_state::prepared || + state == nec_model_state::solved; +} + +bool is_prepared(const necpp_wasm_v1_model* model) noexcept +{ + if (model == nullptr) + return false; + const nec_model_state state = model->native.state(); + return state == nec_model_state::prepared || state == nec_model_state::solved; +} + +void reconcile_consumer_results_after_failure(necpp_wasm_v1_model& model) +{ + if (model.native.state() != nec_model_state::solved) { + model.solution.clear(); + model.far_field.clear(); + } +} + +void clear_calculated_results(necpp_wasm_v1_model& model) +{ + model.impedance.clear(); + model.solution.clear(); + model.far_field.clear(); + model.embedded.clear(); +} + +void sync_impedance( + necpp_wasm_v1_model& model, const nec_impedance_result& result) +{ + impedance_buffers next; + next.impedance.assign(result.impedance.values); + next.admittance.assign(result.admittance.values); + next.order = result.impedance.rows; + next.frequency_mhz = result.frequency_mhz; + next.condition_estimate = result.condition_estimate; + next.factorization_generation = result.factorization_generation; + next.available = true; + model.impedance = std::move(next); +} + +void sync_solution( + necpp_wasm_v1_model& model, const nec_port_solution& result) +{ + solution_buffers next; + next.requested.assign(result.requested); + next.voltages.assign(result.voltages); + next.currents.assign(result.currents); + next.active_impedances.assign(result.active_impedances); + next.powers_w.assign(result.powers_w.begin(), result.powers_w.end()); + next.drive = result.drive == nec_port_drive::current + ? NECPP_WASM_V1_DRIVE_CURRENT + : NECPP_WASM_V1_DRIVE_VOLTAGE; + next.frequency_mhz = result.frequency_mhz; + next.factorization_generation = result.factorization_generation; + next.solve_generation = result.solve_generation; + next.available = true; + model.solution = std::move(next); +} + +void sync_far_field( + necpp_wasm_v1_model& model, const nec_far_field_result& result) +{ + far_field_buffers next; + next.theta_deg.assign(result.theta_deg.begin(), result.theta_deg.end()); + next.phi_deg.assign(result.phi_deg.begin(), result.phi_deg.end()); + next.e_theta.assign(result.e_theta); + next.e_phi.assign(result.e_phi); + next.radius_m = result.radius_m; + next.frequency_mhz = result.frequency_mhz; + next.available = true; + model.far_field = std::move(next); +} + +void sync_embedded( + necpp_wasm_v1_model& model, + const nec_embedded_far_field_result& result) +{ + embedded_buffers next; + next.theta_deg.assign(result.theta_deg.begin(), result.theta_deg.end()); + next.phi_deg.assign(result.phi_deg.begin(), result.phi_deg.end()); + next.e_theta.assign(result.e_theta); + next.e_phi.assign(result.e_phi); + next.radius_m = result.radius_m; + next.frequency_mhz = result.frequency_mhz; + next.port_count = result.ports.size(); + next.samples_per_port = result.samples_per_port; + next.normalization = + result.normalization == nec_embedded_field_normalization::unit_current + ? NECPP_WASM_V1_UNIT_CURRENT + : NECPP_WASM_V1_UNIT_VOLTAGE; + next.available = true; + model.embedded = std::move(next); +} + +bool valid_complex_input( + const double* real, const double* imag, size_t count) noexcept +{ + if (count != 0 && (real == nullptr || imag == nullptr)) + return false; + for (size_t index = 0; index < count; ++index) { + if (!finite_value(real[index]) || !finite_value(imag[index])) + return false; + } + return true; +} + +std::vector make_complex_input( + const double* real, const double* imag, size_t count) +{ + std::vector values; + values.reserve(count); + for (size_t index = 0; index < count; ++index) + values.emplace_back(real[index], imag[index]); + return values; +} + +bool valid_grid( + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg) noexcept +{ + if (!finite_value(radius_m) || !(radius_m > 0.0) || + !finite_value(theta_start_deg) || !finite_value(theta_step_deg) || + !finite_value(phi_start_deg) || !finite_value(phi_step_deg) || + theta_count <= 0 || phi_count <= 0) + return false; + const size_t theta = static_cast(theta_count); + const size_t phi = static_cast(phi_count); + if (theta > std::numeric_limits::max() / phi) + return false; + const size_t samples = theta * phi; + if (samples > std::vector().max_size()) + return false; + const double theta_end = theta_start_deg + + static_cast(theta_count - 1) * theta_step_deg; + const double phi_end = phi_start_deg + + static_cast(phi_count - 1) * phi_step_deg; + return finite_value(theta_end) && finite_value(phi_end); +} + +nec_far_field_grid make_grid( + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg) +{ + nec_far_field_grid grid; + grid.radius_m = radius_m; + grid.theta_start_deg = theta_start_deg; + grid.theta_count = theta_count; + grid.theta_step_deg = theta_step_deg; + grid.phi_start_deg = phi_start_deg; + grid.phi_count = phi_count; + grid.phi_step_deg = phi_step_deg; + return grid; +} + +int32_t ensure_impedance(necpp_wasm_v1_model* model) noexcept +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + int32_t status = invoke( + model, NECPP_WASM_V1_SOLVER_ERROR, + [&] { model->native.compute_admittance_matrix(); }); + if (status != NECPP_WASM_V1_OK) { + reconcile_consumer_results_after_failure(*model); + return status; + } + status = invoke( + model, NECPP_WASM_V1_CONDITIONING_ERROR, + [&] { + const nec_impedance_result& result = + model->native.compute_impedance_matrix(); + sync_impedance(*model, result); + }); + if (status != NECPP_WASM_V1_OK) + reconcile_consumer_results_after_failure(*model); + return status; +} + +const std::vector* result_buffer( + const necpp_wasm_v1_model* model, int32_t kind) noexcept +{ + if (model == nullptr) + return nullptr; + switch (kind) { + case NECPP_WASM_V1_IMPEDANCE_REAL: + return model->impedance.available ? &model->impedance.impedance.real : nullptr; + case NECPP_WASM_V1_IMPEDANCE_IMAG: + return model->impedance.available ? &model->impedance.impedance.imag : nullptr; + case NECPP_WASM_V1_ADMITTANCE_REAL: + return model->impedance.available ? &model->impedance.admittance.real : nullptr; + case NECPP_WASM_V1_ADMITTANCE_IMAG: + return model->impedance.available ? &model->impedance.admittance.imag : nullptr; + case NECPP_WASM_V1_SOLUTION_REQUESTED_REAL: + return model->solution.available ? &model->solution.requested.real : nullptr; + case NECPP_WASM_V1_SOLUTION_REQUESTED_IMAG: + return model->solution.available ? &model->solution.requested.imag : nullptr; + case NECPP_WASM_V1_SOLUTION_VOLTAGES_REAL: + return model->solution.available ? &model->solution.voltages.real : nullptr; + case NECPP_WASM_V1_SOLUTION_VOLTAGES_IMAG: + return model->solution.available ? &model->solution.voltages.imag : nullptr; + case NECPP_WASM_V1_SOLUTION_CURRENTS_REAL: + return model->solution.available ? &model->solution.currents.real : nullptr; + case NECPP_WASM_V1_SOLUTION_CURRENTS_IMAG: + return model->solution.available ? &model->solution.currents.imag : nullptr; + case NECPP_WASM_V1_SOLUTION_ACTIVE_IMPEDANCES_REAL: + return model->solution.available + ? &model->solution.active_impedances.real : nullptr; + case NECPP_WASM_V1_SOLUTION_ACTIVE_IMPEDANCES_IMAG: + return model->solution.available + ? &model->solution.active_impedances.imag : nullptr; + case NECPP_WASM_V1_SOLUTION_POWERS_W: + return model->solution.available ? &model->solution.powers_w : nullptr; + case NECPP_WASM_V1_FAR_FIELD_THETA_DEG: + return model->far_field.available ? &model->far_field.theta_deg : nullptr; + case NECPP_WASM_V1_FAR_FIELD_PHI_DEG: + return model->far_field.available ? &model->far_field.phi_deg : nullptr; + case NECPP_WASM_V1_FAR_FIELD_E_THETA_REAL: + return model->far_field.available ? &model->far_field.e_theta.real : nullptr; + case NECPP_WASM_V1_FAR_FIELD_E_THETA_IMAG: + return model->far_field.available ? &model->far_field.e_theta.imag : nullptr; + case NECPP_WASM_V1_FAR_FIELD_E_PHI_REAL: + return model->far_field.available ? &model->far_field.e_phi.real : nullptr; + case NECPP_WASM_V1_FAR_FIELD_E_PHI_IMAG: + return model->far_field.available ? &model->far_field.e_phi.imag : nullptr; + case NECPP_WASM_V1_EMBEDDED_THETA_DEG: + return model->embedded.available ? &model->embedded.theta_deg : nullptr; + case NECPP_WASM_V1_EMBEDDED_PHI_DEG: + return model->embedded.available ? &model->embedded.phi_deg : nullptr; + case NECPP_WASM_V1_EMBEDDED_E_THETA_REAL: + return model->embedded.available ? &model->embedded.e_theta.real : nullptr; + case NECPP_WASM_V1_EMBEDDED_E_THETA_IMAG: + return model->embedded.available ? &model->embedded.e_theta.imag : nullptr; + case NECPP_WASM_V1_EMBEDDED_E_PHI_REAL: + return model->embedded.available ? &model->embedded.e_phi.real : nullptr; + case NECPP_WASM_V1_EMBEDDED_E_PHI_IMAG: + return model->embedded.available ? &model->embedded.e_phi.imag : nullptr; + default: + return nullptr; + } +} + +template +int32_t invoke_deck( + necpp_wasm_v1_deck* deck, + int32_t failure_status, + Function&& function) noexcept +{ + if (deck == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + try { + function(); + clear_error(deck); + return NECPP_WASM_V1_OK; + } catch (const std::bad_alloc& error) { + return set_error(deck, NECPP_WASM_V1_RUNTIME_ERROR, error.what()); + } catch (const nec_deck_input_exception& error) { + try { + return set_error( + deck, NECPP_WASM_V1_INPUT_ERROR, error.get_message().c_str()); + } catch (...) { + return set_error(deck, NECPP_WASM_V1_INPUT_ERROR, + "Invalid NEC deck"); + } + } catch (const nec_exception& error) { + try { + return set_error(deck, failure_status, error.get_message().c_str()); + } catch (...) { + return set_error(deck, failure_status, "NEC native exception"); + } + } catch (const std::exception& error) { + return set_error(deck, failure_status, error.what()); + } catch (const char* error) { + return set_error(deck, failure_status, error); + } catch (...) { + return set_error(deck, failure_status, "Unknown native exception"); + } +} + +} // namespace + +extern "C" { + +uint32_t necpp_wasm_v1_abi_version(void) +{ + return 1; +} + +const char* necpp_wasm_v1_engine_version(void) +{ + return NECPP_VERSION; +} + +necpp_wasm_v1_model* necpp_wasm_v1_model_create(void) +{ + try { + return new necpp_wasm_v1_model(); + } catch (...) { + return nullptr; + } +} + +void necpp_wasm_v1_model_delete(necpp_wasm_v1_model* model) +{ + try { + delete model; + } catch (...) { + } +} + +int32_t necpp_wasm_v1_model_state(const necpp_wasm_v1_model* model) +{ + if (model == nullptr) + return NECPP_WASM_V1_STATE_INVALID; + return static_cast(model->native.state()); +} + +int32_t necpp_wasm_v1_last_status(const necpp_wasm_v1_model* model) +{ + return model == nullptr ? NECPP_WASM_V1_RUNTIME_ERROR : model->last_status; +} + +const char* necpp_wasm_v1_last_error(const necpp_wasm_v1_model* model) +{ + return error_text(model); +} + +int32_t necpp_wasm_v1_add_wire( + necpp_wasm_v1_model* model, + int32_t tag, int32_t segments, + double x1, double y1, double z1, + double x2, double y2, double z2, + double radius_m) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + const nec_model_state state = model->native.state(); + if (state != nec_model_state::empty && + state != nec_model_state::geometry_building) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "addWire is illegal after geometry completion"); + if (tag <= 0 || segments <= 0 || + !finite_value(x1) || !finite_value(y1) || !finite_value(z1) || + !finite_value(x2) || !finite_value(y2) || !finite_value(z2) || + !finite_value(radius_m) || !(radius_m > 0.0) || + (x1 == x2 && y1 == y2 && z1 == z2)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid wire definition"); + + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + nec_wire_definition wire; + wire.tag = tag; + wire.segments = segments; + wire.x1 = x1; + wire.y1 = y1; + wire.z1 = z1; + wire.x2 = x2; + wire.y2 = y2; + wire.z2 = z2; + wire.radius_m = radius_m; + model->native.add_wire(wire); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_complete_geometry( + necpp_wasm_v1_model* model, int32_t ground_connection) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_state(model, nec_model_state::geometry_building)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "completeGeometry requires geometry-building state"); + if (ground_connection < NECPP_WASM_V1_GROUND_CONNECTION_NONE || + ground_connection > NECPP_WASM_V1_GROUND_CONNECTION_ZERO_CURRENT) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Unknown ground connection"); + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + model->native.complete_geometry( + static_cast(ground_connection)); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_define_ports( + necpp_wasm_v1_model* model, + const int32_t* tags, const int32_t* segments, size_t count) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_state(model, nec_model_state::geometry_complete)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "definePorts requires geometry-complete state"); + if (count == 0) + return fail(model, NECPP_WASM_V1_PORT_ERROR, + "At least one port is required"); + if (tags == nullptr || segments == nullptr || + count > static_cast(std::numeric_limits::max())) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid port arrays"); + for (size_t index = 0; index < count; ++index) { + if (tags[index] <= 0 || segments[index] <= 0) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Port tag and segment must be positive integers"); + } + + return invoke(model, NECPP_WASM_V1_PORT_ERROR, [&] { + std::vector ports; + ports.reserve(count); + for (size_t index = 0; index < count; ++index) + ports.push_back({tags[index], segments[index]}); + model->native.define_ports(ports); + model->port_tags.assign(tags, tags + count); + model->port_segments.assign(segments, segments + count); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_add_load( + necpp_wasm_v1_model* model, + int32_t kind, int32_t tag, int32_t first_segment, int32_t last_segment, + double value1, double value2, double value3) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_configurable(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "addLoad requires completed geometry"); + if (kind < NECPP_WASM_V1_LOAD_SERIES_RLC || + kind > NECPP_WASM_V1_LOAD_CONDUCTIVITY || + tag < 0 || first_segment < 0 || last_segment < 0 || + (first_segment == 0 && last_segment != 0) || + (first_segment != 0 && last_segment != 0 && + last_segment < first_segment) || + !finite_value(value1) || !finite_value(value2) || !finite_value(value3)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid load definition"); + + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + nec_load_definition load; + load.kind = static_cast(kind); + load.tag = tag; + load.first_segment = first_segment; + load.last_segment = last_segment; + load.value1 = value1; + load.value2 = value2; + load.value3 = value3; + model->native.add_load(load); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_clear_loads(necpp_wasm_v1_model* model) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_configurable(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "clearLoads requires completed geometry"); + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + model->native.clear_loads(); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_set_ground( + necpp_wasm_v1_model* model, + int32_t kind, double relative_permittivity, double conductivity_s_per_m) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_configurable(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "setGround requires completed geometry"); + if (kind < NECPP_WASM_V1_GROUND_FREE_SPACE || + kind > NECPP_WASM_V1_GROUND_FINITE_SOMMERFELD_NORTON) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, "Unknown ground kind"); + const bool finite_ground = + kind == NECPP_WASM_V1_GROUND_FINITE_REFLECTION_COEFFICIENT || + kind == NECPP_WASM_V1_GROUND_FINITE_SOMMERFELD_NORTON; + if (finite_ground && + (!finite_value(relative_permittivity) || + !finite_value(conductivity_s_per_m) || + !(relative_permittivity > 0.0) || + !(conductivity_s_per_m > 0.0))) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Finite-ground parameters must be positive and finite"); + + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + nec_ground_definition ground; + ground.kind = static_cast(kind); + ground.relative_permittivity = relative_permittivity; + ground.conductivity_s_per_m = conductivity_s_per_m; + model->native.set_ground(ground); + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_prepare( + necpp_wasm_v1_model* model, double frequency_mhz) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_configurable(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "prepare requires completed geometry"); + if (model->port_tags.empty()) + return fail(model, NECPP_WASM_V1_PORT_ERROR, + "Ports have not been defined"); + if (!finite_value(frequency_mhz) || !(frequency_mhz > 0.0)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Frequency must be positive and finite"); + + const uint64_t generation_before = model->native.factorization_generation(); + const int32_t status = invoke(model, NECPP_WASM_V1_SOLVER_ERROR, [&] { + model->native.prepare(frequency_mhz); + if (model->native.factorization_generation() != generation_before) + clear_calculated_results(*model); + }); + if (status != NECPP_WASM_V1_OK) + clear_calculated_results(*model); + return status; +} + +int32_t necpp_wasm_v1_compute_impedance(necpp_wasm_v1_model* model) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_prepared(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "computeImpedanceMatrix requires a prepared model"); + return ensure_impedance(model); +} + +int32_t necpp_wasm_v1_solve_voltages( + necpp_wasm_v1_model* model, + const double* real, const double* imag, size_t count) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_prepared(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "solveVoltages requires a prepared model"); + if (count != model->port_tags.size() || + !valid_complex_input(real, imag, count)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Voltage arrays must be finite and match the port count"); + std::vector voltages; + try { + voltages = make_complex_input(real, imag, count); + } catch (const std::bad_alloc& error) { + return set_error(model, NECPP_WASM_V1_RUNTIME_ERROR, error.what()); + } + bool native_succeeded = false; + const int32_t status = invoke(model, NECPP_WASM_V1_SOLVER_ERROR, [&] { + const nec_port_solution& result = + model->native.solve_port_voltages_detailed(voltages); + native_succeeded = true; + sync_solution(*model, result); + model->far_field.clear(); + }); + if (status != NECPP_WASM_V1_OK && native_succeeded) { + model->solution.clear(); + model->far_field.clear(); + } else if (status != NECPP_WASM_V1_OK) { + reconcile_consumer_results_after_failure(*model); + } + return status; +} + +int32_t necpp_wasm_v1_solve_currents( + necpp_wasm_v1_model* model, + const double* real, const double* imag, size_t count) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_prepared(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "solveCurrents requires a prepared model"); + if (count != model->port_tags.size() || + !valid_complex_input(real, imag, count)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Current arrays must be finite and match the port count"); + std::vector currents; + try { + currents = make_complex_input(real, imag, count); + } catch (const std::bad_alloc& error) { + return set_error(model, NECPP_WASM_V1_RUNTIME_ERROR, error.what()); + } + const int32_t matrix_status = ensure_impedance(model); + if (matrix_status != NECPP_WASM_V1_OK) + return matrix_status; + bool native_succeeded = false; + const int32_t status = invoke(model, NECPP_WASM_V1_SOLVER_ERROR, [&] { + const nec_port_solution& result = + model->native.solve_port_currents(currents); + native_succeeded = true; + sync_solution(*model, result); + model->far_field.clear(); + }); + if (status != NECPP_WASM_V1_OK && native_succeeded) { + model->solution.clear(); + model->far_field.clear(); + } else if (status != NECPP_WASM_V1_OK) { + reconcile_consumer_results_after_failure(*model); + } + return status; +} + +int32_t necpp_wasm_v1_compute_far_field( + necpp_wasm_v1_model* model, + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_state(model, nec_model_state::solved)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "computeFarField requires a consumer solution"); + if (!valid_grid( + radius_m, theta_start_deg, theta_count, theta_step_deg, + phi_start_deg, phi_count, phi_step_deg)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid far-field grid"); + bool native_succeeded = false; + const int32_t status = invoke(model, NECPP_WASM_V1_SOLVER_ERROR, [&] { + const nec_far_field_result& result = model->native.compute_far_field( + make_grid( + radius_m, theta_start_deg, theta_count, theta_step_deg, + phi_start_deg, phi_count, phi_step_deg)); + native_succeeded = true; + sync_far_field(*model, result); + }); + if (status != NECPP_WASM_V1_OK && native_succeeded) + model->far_field.clear(); + return status; +} + +int32_t necpp_wasm_v1_compute_embedded_far_fields( + necpp_wasm_v1_model* model, + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg, + int32_t normalization) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_prepared(model)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "computeEmbeddedFarFields requires a prepared model"); + if (!valid_grid( + radius_m, theta_start_deg, theta_count, theta_step_deg, + phi_start_deg, phi_count, phi_step_deg) || + (normalization != NECPP_WASM_V1_UNIT_VOLTAGE && + normalization != NECPP_WASM_V1_UNIT_CURRENT)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid embedded far-field request"); + const size_t sample_count = + static_cast(theta_count) * static_cast(phi_count); + if (!model->port_tags.empty() && + sample_count > std::numeric_limits::max() / model->port_tags.size()) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Embedded field sample count overflows"); + const size_t embedded_sample_count = sample_count * model->port_tags.size(); + if (embedded_sample_count > std::vector().max_size()) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Embedded field sample count is too large"); + if (normalization == NECPP_WASM_V1_UNIT_CURRENT) { + const int32_t matrix_status = ensure_impedance(model); + if (matrix_status != NECPP_WASM_V1_OK) + return matrix_status; + } + bool native_succeeded = false; + const int32_t status = invoke(model, NECPP_WASM_V1_SOLVER_ERROR, [&] { + const nec_embedded_far_field_result& result = + model->native.compute_embedded_far_fields( + make_grid( + radius_m, theta_start_deg, theta_count, theta_step_deg, + phi_start_deg, phi_count, phi_step_deg), + static_cast(normalization)); + native_succeeded = true; + sync_embedded(*model, result); + }); + if (status != NECPP_WASM_V1_OK && native_succeeded) { + model->embedded.clear(); + } else if (status != NECPP_WASM_V1_OK) { + reconcile_consumer_results_after_failure(*model); + } + return status; +} + +size_t necpp_wasm_v1_port_count(const necpp_wasm_v1_model* model) +{ + return model == nullptr ? 0 : model->port_tags.size(); +} + +const int32_t* necpp_wasm_v1_port_tags(const necpp_wasm_v1_model* model) +{ + return model == nullptr || model->port_tags.empty() + ? nullptr : model->port_tags.data(); +} + +const int32_t* necpp_wasm_v1_port_segments(const necpp_wasm_v1_model* model) +{ + return model == nullptr || model->port_segments.empty() + ? nullptr : model->port_segments.data(); +} + +size_t necpp_wasm_v1_impedance_order(const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->impedance.available + ? model->impedance.order : 0; +} + +double necpp_wasm_v1_impedance_frequency_mhz( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->impedance.available + ? model->impedance.frequency_mhz : 0.0; +} + +double necpp_wasm_v1_impedance_condition_estimate( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->impedance.available + ? model->impedance.condition_estimate : 0.0; +} + +double necpp_wasm_v1_impedance_factorization_generation( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->impedance.available + ? static_cast(model->impedance.factorization_generation) : 0.0; +} + +size_t necpp_wasm_v1_solution_count(const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.voltages.real.size() : 0; +} + +int32_t necpp_wasm_v1_solution_drive(const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.drive : -1; +} + +double necpp_wasm_v1_solution_frequency_mhz( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.frequency_mhz : 0.0; +} + +double necpp_wasm_v1_solution_factorization_generation( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? static_cast(model->solution.factorization_generation) : 0.0; +} + +double necpp_wasm_v1_solution_generation( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? static_cast(model->solution.solve_generation) : 0.0; +} + +double necpp_wasm_v1_far_field_radius_m(const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->far_field.available + ? model->far_field.radius_m : 0.0; +} + +double necpp_wasm_v1_far_field_frequency_mhz( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->far_field.available + ? model->far_field.frequency_mhz : 0.0; +} + +size_t necpp_wasm_v1_far_field_theta_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->far_field.available + ? model->far_field.theta_deg.size() : 0; +} + +size_t necpp_wasm_v1_far_field_phi_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->far_field.available + ? model->far_field.phi_deg.size() : 0; +} + +double necpp_wasm_v1_embedded_radius_m(const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.radius_m : 0.0; +} + +double necpp_wasm_v1_embedded_frequency_mhz( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.frequency_mhz : 0.0; +} + +size_t necpp_wasm_v1_embedded_theta_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.theta_deg.size() : 0; +} + +size_t necpp_wasm_v1_embedded_phi_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.phi_deg.size() : 0; +} + +size_t necpp_wasm_v1_embedded_port_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.port_count : 0; +} + +size_t necpp_wasm_v1_embedded_samples_per_port( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.samples_per_port : 0; +} + +int32_t necpp_wasm_v1_embedded_normalization( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->embedded.available + ? model->embedded.normalization : -1; +} + +const double* necpp_wasm_v1_result_buffer( + const necpp_wasm_v1_model* model, int32_t kind) +{ + const std::vector* buffer = result_buffer(model, kind); + return buffer == nullptr || buffer->empty() ? nullptr : buffer->data(); +} + +size_t necpp_wasm_v1_result_buffer_length( + const necpp_wasm_v1_model* model, int32_t kind) +{ + const std::vector* buffer = result_buffer(model, kind); + return buffer == nullptr ? 0 : buffer->size(); +} + +necpp_wasm_v1_deck* necpp_wasm_v1_deck_create(void) +{ + try { + return new necpp_wasm_v1_deck(); + } catch (...) { + return nullptr; + } +} + +void necpp_wasm_v1_deck_delete(necpp_wasm_v1_deck* deck) +{ + try { + delete deck; + } catch (...) { + } +} + +int32_t necpp_wasm_v1_deck_process( + necpp_wasm_v1_deck* deck, const char* utf8, size_t length) +{ + if (deck == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (utf8 == nullptr || length == 0) + return set_error(deck, NECPP_WASM_V1_INPUT_ERROR, + "A nonempty UTF-8 NEC deck is required"); + if (std::find(utf8, utf8 + length, '\0') != utf8 + length) + return set_error(deck, NECPP_WASM_V1_INPUT_ERROR, + "A NEC deck cannot contain an embedded NUL"); + try { + deck->output.clear(); + } catch (...) { + return set_error(deck, NECPP_WASM_V1_RUNTIME_ERROR, + "Unable to clear the previous deck result"); + } + return invoke_deck(deck, NECPP_WASM_V1_SOLVER_ERROR, [&] { + const std::string input(utf8, length); + std::unique_ptr solver(new nec_context()); + std::ostringstream report; + nec_output_file output; + output.set_stream(report); + nec_process_deck(input.c_str(), *solver, output); + deck->output = report.str(); + }); +} + +int32_t necpp_wasm_v1_deck_last_status(const necpp_wasm_v1_deck* deck) +{ + return deck == nullptr ? NECPP_WASM_V1_RUNTIME_ERROR : deck->last_status; +} + +const char* necpp_wasm_v1_deck_last_error(const necpp_wasm_v1_deck* deck) +{ + return error_text(deck); +} + +const char* necpp_wasm_v1_deck_output(const necpp_wasm_v1_deck* deck) +{ + return deck == nullptr ? "" : deck->output.c_str(); +} + +size_t necpp_wasm_v1_deck_output_length(const necpp_wasm_v1_deck* deck) +{ + return deck == nullptr ? 0 : deck->output.size(); +} + +} /* extern "C" */ diff --git a/src/necpp_wasm_v1.h b/src/necpp_wasm_v1.h new file mode 100644 index 00000000..b9b1ed0d --- /dev/null +++ b/src/necpp_wasm_v1.h @@ -0,0 +1,216 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + Stable C ABI used by the Emscripten module. This header is intentionally + valid C: callers never see a C++ class, exception, or standard-library type. +*/ +#ifndef NECPP_WASM_V1_H +#define NECPP_WASM_V1_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct necpp_wasm_v1_model necpp_wasm_v1_model; +typedef struct necpp_wasm_v1_deck necpp_wasm_v1_deck; + +enum necpp_wasm_v1_status { + NECPP_WASM_V1_OK = 0, + NECPP_WASM_V1_STATE_ERROR = 1, + NECPP_WASM_V1_INPUT_ERROR = 2, + NECPP_WASM_V1_GEOMETRY_ERROR = 3, + NECPP_WASM_V1_PORT_ERROR = 4, + NECPP_WASM_V1_CONDITIONING_ERROR = 5, + NECPP_WASM_V1_SOLVER_ERROR = 6, + NECPP_WASM_V1_RUNTIME_ERROR = 7 +}; + +enum necpp_wasm_v1_model_state { + NECPP_WASM_V1_STATE_INVALID = -1, + NECPP_WASM_V1_STATE_EMPTY = 0, + NECPP_WASM_V1_STATE_GEOMETRY_BUILDING = 1, + NECPP_WASM_V1_STATE_GEOMETRY_COMPLETE = 2, + NECPP_WASM_V1_STATE_PREPARED = 3, + NECPP_WASM_V1_STATE_SOLVED = 4 +}; + +enum necpp_wasm_v1_ground_connection { + NECPP_WASM_V1_GROUND_CONNECTION_NONE = 0, + NECPP_WASM_V1_GROUND_CONNECTION_INTERPOLATE = 1, + NECPP_WASM_V1_GROUND_CONNECTION_ZERO_CURRENT = 2 +}; + +enum necpp_wasm_v1_load_kind { + NECPP_WASM_V1_LOAD_SERIES_RLC = 0, + NECPP_WASM_V1_LOAD_PARALLEL_RLC = 1, + NECPP_WASM_V1_LOAD_DISTRIBUTED_SERIES_RLC = 2, + NECPP_WASM_V1_LOAD_DISTRIBUTED_PARALLEL_RLC = 3, + NECPP_WASM_V1_LOAD_IMPEDANCE = 4, + NECPP_WASM_V1_LOAD_CONDUCTIVITY = 5 +}; + +enum necpp_wasm_v1_ground_kind { + NECPP_WASM_V1_GROUND_FREE_SPACE = 0, + NECPP_WASM_V1_GROUND_PERFECT = 1, + NECPP_WASM_V1_GROUND_FINITE_REFLECTION_COEFFICIENT = 2, + NECPP_WASM_V1_GROUND_FINITE_SOMMERFELD_NORTON = 3 +}; + +enum necpp_wasm_v1_drive { + NECPP_WASM_V1_DRIVE_VOLTAGE = 0, + NECPP_WASM_V1_DRIVE_CURRENT = 1 +}; + +enum necpp_wasm_v1_embedded_normalization { + NECPP_WASM_V1_UNIT_VOLTAGE = 0, + NECPP_WASM_V1_UNIT_CURRENT = 1 +}; + +/* + * Borrowed double buffers returned by necpp_wasm_v1_result_buffer(). A + * successful operation replacing a result of the same category may invalidate + * its pointers. Callers crossing the WASM boundary must copy immediately. + */ +enum necpp_wasm_v1_result_buffer_kind { + NECPP_WASM_V1_IMPEDANCE_REAL = 0, + NECPP_WASM_V1_IMPEDANCE_IMAG = 1, + NECPP_WASM_V1_ADMITTANCE_REAL = 2, + NECPP_WASM_V1_ADMITTANCE_IMAG = 3, + NECPP_WASM_V1_SOLUTION_REQUESTED_REAL = 4, + NECPP_WASM_V1_SOLUTION_REQUESTED_IMAG = 5, + NECPP_WASM_V1_SOLUTION_VOLTAGES_REAL = 6, + NECPP_WASM_V1_SOLUTION_VOLTAGES_IMAG = 7, + NECPP_WASM_V1_SOLUTION_CURRENTS_REAL = 8, + NECPP_WASM_V1_SOLUTION_CURRENTS_IMAG = 9, + NECPP_WASM_V1_SOLUTION_ACTIVE_IMPEDANCES_REAL = 10, + NECPP_WASM_V1_SOLUTION_ACTIVE_IMPEDANCES_IMAG = 11, + NECPP_WASM_V1_SOLUTION_POWERS_W = 12, + NECPP_WASM_V1_FAR_FIELD_THETA_DEG = 13, + NECPP_WASM_V1_FAR_FIELD_PHI_DEG = 14, + NECPP_WASM_V1_FAR_FIELD_E_THETA_REAL = 15, + NECPP_WASM_V1_FAR_FIELD_E_THETA_IMAG = 16, + NECPP_WASM_V1_FAR_FIELD_E_PHI_REAL = 17, + NECPP_WASM_V1_FAR_FIELD_E_PHI_IMAG = 18, + NECPP_WASM_V1_EMBEDDED_THETA_DEG = 19, + NECPP_WASM_V1_EMBEDDED_PHI_DEG = 20, + NECPP_WASM_V1_EMBEDDED_E_THETA_REAL = 21, + NECPP_WASM_V1_EMBEDDED_E_THETA_IMAG = 22, + NECPP_WASM_V1_EMBEDDED_E_PHI_REAL = 23, + NECPP_WASM_V1_EMBEDDED_E_PHI_IMAG = 24 +}; + +uint32_t necpp_wasm_v1_abi_version(void); +const char* necpp_wasm_v1_engine_version(void); + +necpp_wasm_v1_model* necpp_wasm_v1_model_create(void); +void necpp_wasm_v1_model_delete(necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_model_state(const necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_last_status(const necpp_wasm_v1_model* model); +const char* necpp_wasm_v1_last_error(const necpp_wasm_v1_model* model); + +int32_t necpp_wasm_v1_add_wire( + necpp_wasm_v1_model* model, + int32_t tag, int32_t segments, + double x1, double y1, double z1, + double x2, double y2, double z2, + double radius_m); +int32_t necpp_wasm_v1_complete_geometry( + necpp_wasm_v1_model* model, int32_t ground_connection); +int32_t necpp_wasm_v1_define_ports( + necpp_wasm_v1_model* model, + const int32_t* tags, const int32_t* segments, size_t count); +int32_t necpp_wasm_v1_add_load( + necpp_wasm_v1_model* model, + int32_t kind, int32_t tag, int32_t first_segment, int32_t last_segment, + double value1, double value2, double value3); +int32_t necpp_wasm_v1_clear_loads(necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_set_ground( + necpp_wasm_v1_model* model, + int32_t kind, double relative_permittivity, double conductivity_s_per_m); +int32_t necpp_wasm_v1_prepare( + necpp_wasm_v1_model* model, double frequency_mhz); +int32_t necpp_wasm_v1_compute_impedance(necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_solve_voltages( + necpp_wasm_v1_model* model, + const double* real, const double* imag, size_t count); +int32_t necpp_wasm_v1_solve_currents( + necpp_wasm_v1_model* model, + const double* real, const double* imag, size_t count); +int32_t necpp_wasm_v1_compute_far_field( + necpp_wasm_v1_model* model, + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg); +int32_t necpp_wasm_v1_compute_embedded_far_fields( + necpp_wasm_v1_model* model, + double radius_m, + double theta_start_deg, int32_t theta_count, double theta_step_deg, + double phi_start_deg, int32_t phi_count, double phi_step_deg, + int32_t normalization); + +size_t necpp_wasm_v1_port_count(const necpp_wasm_v1_model* model); +const int32_t* necpp_wasm_v1_port_tags(const necpp_wasm_v1_model* model); +const int32_t* necpp_wasm_v1_port_segments(const necpp_wasm_v1_model* model); + +size_t necpp_wasm_v1_impedance_order(const necpp_wasm_v1_model* model); +double necpp_wasm_v1_impedance_frequency_mhz( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_impedance_condition_estimate( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_impedance_factorization_generation( + const necpp_wasm_v1_model* model); + +size_t necpp_wasm_v1_solution_count(const necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_solution_drive(const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_frequency_mhz( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_factorization_generation( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_generation( + const necpp_wasm_v1_model* model); + +double necpp_wasm_v1_far_field_radius_m(const necpp_wasm_v1_model* model); +double necpp_wasm_v1_far_field_frequency_mhz( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_far_field_theta_count( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_far_field_phi_count( + const necpp_wasm_v1_model* model); + +double necpp_wasm_v1_embedded_radius_m(const necpp_wasm_v1_model* model); +double necpp_wasm_v1_embedded_frequency_mhz( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_embedded_theta_count( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_embedded_phi_count( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_embedded_port_count( + const necpp_wasm_v1_model* model); +size_t necpp_wasm_v1_embedded_samples_per_port( + const necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_embedded_normalization( + const necpp_wasm_v1_model* model); + +const double* necpp_wasm_v1_result_buffer( + const necpp_wasm_v1_model* model, int32_t kind); +size_t necpp_wasm_v1_result_buffer_length( + const necpp_wasm_v1_model* model, int32_t kind); + +/* Complete-deck compatibility path, independent of a stateful model. */ +necpp_wasm_v1_deck* necpp_wasm_v1_deck_create(void); +void necpp_wasm_v1_deck_delete(necpp_wasm_v1_deck* deck); +int32_t necpp_wasm_v1_deck_process( + necpp_wasm_v1_deck* deck, const char* utf8, size_t length); +int32_t necpp_wasm_v1_deck_last_status(const necpp_wasm_v1_deck* deck); +const char* necpp_wasm_v1_deck_last_error(const necpp_wasm_v1_deck* deck); +const char* necpp_wasm_v1_deck_output(const necpp_wasm_v1_deck* deck); +size_t necpp_wasm_v1_deck_output_length(const necpp_wasm_v1_deck* deck); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* NECPP_WASM_V1_H */ diff --git a/src/necpp_wasm_v1_c_tb.c b/src/necpp_wasm_v1_c_tb.c new file mode 100644 index 00000000..505c0665 --- /dev/null +++ b/src/necpp_wasm_v1_c_tb.c @@ -0,0 +1,290 @@ +#include "necpp_wasm_v1.h" + +#include +#include +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) return __LINE__; } while (0) + +static int buffer_is_finite( + const necpp_wasm_v1_model* model, int32_t kind, size_t expected_length) +{ + const double* values = necpp_wasm_v1_result_buffer(model, kind); + size_t index; + CHECK(necpp_wasm_v1_result_buffer_length(model, kind) == expected_length); + CHECK(expected_length == 0 || values != NULL); + for (index = 0; index < expected_length; ++index) + CHECK(isfinite(values[index])); + return 0; +} + +int necpp_wasm_v1_run_c_contract_test(void) +{ + static const int32_t tags[2] = {1, 2}; + static const int32_t segments[2] = {6, 6}; + static const double voltage_real[2] = {1.0, 0.0}; + static const double voltage_imag[2] = {0.0, 0.0}; + static const double current_real[2] = {0.01, 0.0}; + static const double current_imag[2] = {0.0, 0.0}; + static const char valid_deck[] = + "CM WP4 C ABI DECK TEST\n" + "CE\n" + "GW 1 11 0 0 -0.25 0 0 0.25 0.001\n" + "GE 0\n" + "FR 0 1 0 0 300\n" + "EX 0 1 6 0 1 0\n" + "XQ\n" + "EN\n"; + static const char invalid_rp_deck[] = + "CE\n" + "GW 1 11 0 0 -0.25 0 0 0.25 0.001\n" + "GE 0\n" + "FR 0 1 0 0 300\n" + "EX 0 1 6 0 1 0\n" + "RP 0 -1 1 0 0 0 0 0 0 1\n" + "EN\n"; + static const char blank_count_rp_deck[] = + "CE\n" + "GW 1 11 0 0 -0.25 0 0 0.25 0.001\n" + "GE 0\n" + "FR 0 1 0 0 300\n" + "EX 0 1 6 0 1 0\n" + "RP 0 0 0 0 90 0 0 0 1 0\n" + "EN\n"; + static const char invalid_xq_deck[] = + "CE\n" + "GW 1 11 0 0 -0.25 0 0 0.25 0.001\n" + "GE 0\n" + "FR 0 1 0 0 300\n" + "EX 0 1 6 0 1 0\n" + "XQ 4\n" + "EN\n"; + necpp_wasm_v1_model* model; + necpp_wasm_v1_deck* deck; + const int32_t* returned_tags; + const int32_t* returned_segments; + const double* copied_source; + double copied_value; + int index; + + CHECK(necpp_wasm_v1_abi_version() == 1); + CHECK(necpp_wasm_v1_engine_version() != NULL); + CHECK(strlen(necpp_wasm_v1_engine_version()) != 0); + CHECK(necpp_wasm_v1_model_state(NULL) == NECPP_WASM_V1_STATE_INVALID); + CHECK(necpp_wasm_v1_last_status(NULL) == NECPP_WASM_V1_RUNTIME_ERROR); + CHECK(necpp_wasm_v1_last_error(NULL) != NULL); + CHECK(necpp_wasm_v1_port_count(NULL) == 0); + CHECK(necpp_wasm_v1_port_tags(NULL) == NULL); + CHECK(necpp_wasm_v1_port_segments(NULL) == NULL); + CHECK(necpp_wasm_v1_result_buffer(NULL, 0) == NULL); + CHECK(necpp_wasm_v1_result_buffer_length(NULL, 0) == 0); + necpp_wasm_v1_model_delete(NULL); + + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_model_state(model) == NECPP_WASM_V1_STATE_EMPTY); + CHECK(necpp_wasm_v1_last_status(model) == NECPP_WASM_V1_OK); + CHECK(strcmp(necpp_wasm_v1_last_error(model), "") == 0); + + CHECK(necpp_wasm_v1_prepare(model, 300.0) == NECPP_WASM_V1_STATE_ERROR); + CHECK(necpp_wasm_v1_last_status(model) == NECPP_WASM_V1_STATE_ERROR); + CHECK(strlen(necpp_wasm_v1_last_error(model)) != 0); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0, 0, 0, 0, 0, 0, 0.001) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0, 0, -0.25, 0, 0, 0.25, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_add_wire( + model, 2, 11, 0.2, 0, -0.25, 0.2, 0, 0.25, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_model_state(model) == + NECPP_WASM_V1_STATE_GEOMETRY_BUILDING); + CHECK(necpp_wasm_v1_complete_geometry(model, 99) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_complete_geometry( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_model_state(model) == + NECPP_WASM_V1_STATE_GEOMETRY_COMPLETE); + CHECK(necpp_wasm_v1_add_wire( + model, 3, 11, 0.4, 0, -0.25, 0.4, 0, 0.25, 0.001) == + NECPP_WASM_V1_STATE_ERROR); + + CHECK(necpp_wasm_v1_define_ports(model, NULL, NULL, 2) == + NECPP_WASM_V1_INPUT_ERROR); + { + const int32_t invalid_segment[2] = {6, 999}; + CHECK(necpp_wasm_v1_define_ports(model, tags, invalid_segment, 2) == + NECPP_WASM_V1_PORT_ERROR); + } + CHECK(necpp_wasm_v1_define_ports(model, tags, segments, 2) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_port_count(model) == 2); + returned_tags = necpp_wasm_v1_port_tags(model); + returned_segments = necpp_wasm_v1_port_segments(model); + CHECK(returned_tags != NULL && returned_segments != NULL); + CHECK(returned_tags[0] == 1 && returned_tags[1] == 2); + CHECK(returned_segments[0] == 6 && returned_segments[1] == 6); + + CHECK(necpp_wasm_v1_add_load( + model, 99, 1, 6, 6, 1.0, 0.0, 0.0) == NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_add_load( + model, NECPP_WASM_V1_LOAD_IMPEDANCE, 1, 6, 6, 1.0, 0.0, 0.0) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_clear_loads(model) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_set_ground( + model, NECPP_WASM_V1_GROUND_FINITE_REFLECTION_COEFFICIENT, + -1.0, 0.01) == NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_set_ground( + model, NECPP_WASM_V1_GROUND_FREE_SPACE, 0.0, 0.0) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_prepare(model, 0.0) == NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_prepare(model, 300.0) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_model_state(model) == NECPP_WASM_V1_STATE_PREPARED); + + CHECK(necpp_wasm_v1_compute_impedance(model) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_impedance_order(model) == 2); + CHECK(necpp_wasm_v1_impedance_frequency_mhz(model) == 300.0); + CHECK(isfinite(necpp_wasm_v1_impedance_condition_estimate(model))); + CHECK(necpp_wasm_v1_impedance_factorization_generation(model) == 1.0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_IMPEDANCE_REAL, 4) == 0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_IMPEDANCE_IMAG, 4) == 0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_ADMITTANCE_REAL, 4) == 0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_ADMITTANCE_IMAG, 4) == 0); + CHECK(necpp_wasm_v1_result_buffer(model, 999) == NULL); + CHECK(necpp_wasm_v1_result_buffer_length(model, 999) == 0); + + CHECK(necpp_wasm_v1_solve_voltages(model, NULL, NULL, 2) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_solve_voltages( + model, voltage_real, voltage_imag, 2) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_model_state(model) == NECPP_WASM_V1_STATE_SOLVED); + CHECK(necpp_wasm_v1_solution_count(model) == 2); + CHECK(necpp_wasm_v1_solution_drive(model) == NECPP_WASM_V1_DRIVE_VOLTAGE); + CHECK(necpp_wasm_v1_solution_frequency_mhz(model) == 300.0); + CHECK(necpp_wasm_v1_solution_factorization_generation(model) == 1.0); + CHECK(necpp_wasm_v1_solution_generation(model) == 1.0); + for (index = NECPP_WASM_V1_SOLUTION_REQUESTED_REAL; + index <= NECPP_WASM_V1_SOLUTION_POWERS_W; ++index) + CHECK(buffer_is_finite(model, index, 2) == 0); + + CHECK(necpp_wasm_v1_compute_far_field( + model, 0.0, 0.0, 1, 0.0, 0.0, 1, 0.0) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_compute_far_field( + model, 1.0, DBL_MAX, 2, DBL_MAX, 0.0, 1, 0.0) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_compute_far_field( + model, 1.0, 0.0, 3, 45.0, 0.0, 2, 90.0) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_far_field_radius_m(model) == 1.0); + CHECK(necpp_wasm_v1_far_field_frequency_mhz(model) == 300.0); + CHECK(necpp_wasm_v1_far_field_theta_count(model) == 3); + CHECK(necpp_wasm_v1_far_field_phi_count(model) == 2); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_FAR_FIELD_THETA_DEG, 3) == 0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_FAR_FIELD_PHI_DEG, 2) == 0); + for (index = NECPP_WASM_V1_FAR_FIELD_E_THETA_REAL; + index <= NECPP_WASM_V1_FAR_FIELD_E_PHI_IMAG; ++index) + CHECK(buffer_is_finite(model, index, 6) == 0); + + copied_source = necpp_wasm_v1_result_buffer( + model, NECPP_WASM_V1_FAR_FIELD_E_THETA_REAL); + CHECK(copied_source != NULL); + copied_value = copied_source[0]; + CHECK(necpp_wasm_v1_compute_embedded_far_fields( + model, 1.0, 0.0, 2, 90.0, 0.0, 2, 90.0, 99) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_compute_embedded_far_fields( + model, 1.0, 0.0, 2, 90.0, 0.0, 2, 90.0, + NECPP_WASM_V1_UNIT_VOLTAGE) == NECPP_WASM_V1_OK); + CHECK(copied_value == copied_source[0]); + CHECK(necpp_wasm_v1_embedded_radius_m(model) == 1.0); + CHECK(necpp_wasm_v1_embedded_frequency_mhz(model) == 300.0); + CHECK(necpp_wasm_v1_embedded_theta_count(model) == 2); + CHECK(necpp_wasm_v1_embedded_phi_count(model) == 2); + CHECK(necpp_wasm_v1_embedded_port_count(model) == 2); + CHECK(necpp_wasm_v1_embedded_samples_per_port(model) == 4); + CHECK(necpp_wasm_v1_embedded_normalization(model) == + NECPP_WASM_V1_UNIT_VOLTAGE); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_EMBEDDED_THETA_DEG, 2) == 0); + CHECK(buffer_is_finite(model, NECPP_WASM_V1_EMBEDDED_PHI_DEG, 2) == 0); + for (index = NECPP_WASM_V1_EMBEDDED_E_THETA_REAL; + index <= NECPP_WASM_V1_EMBEDDED_E_PHI_IMAG; ++index) + CHECK(buffer_is_finite(model, index, 8) == 0); + CHECK(necpp_wasm_v1_compute_embedded_far_fields( + model, 1.0, 90.0, 1, 0.0, 0.0, 1, 0.0, + NECPP_WASM_V1_UNIT_CURRENT) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_embedded_normalization(model) == + NECPP_WASM_V1_UNIT_CURRENT); + + CHECK(necpp_wasm_v1_solve_currents( + model, current_real, current_imag, 2) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_solution_drive(model) == NECPP_WASM_V1_DRIVE_CURRENT); + CHECK(necpp_wasm_v1_solution_generation(model) == 2.0); + CHECK(necpp_wasm_v1_set_ground( + model, NECPP_WASM_V1_GROUND_FREE_SPACE, 0.0, 0.0) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_model_state(model) == + NECPP_WASM_V1_STATE_GEOMETRY_COMPLETE); + CHECK(necpp_wasm_v1_solution_count(model) == 0); + CHECK(necpp_wasm_v1_impedance_order(model) == 0); + CHECK(necpp_wasm_v1_far_field_radius_m(model) == 0.0); + CHECK(necpp_wasm_v1_embedded_radius_m(model) == 0.0); + CHECK(necpp_wasm_v1_prepare(model, 300.0) == NECPP_WASM_V1_OK); + necpp_wasm_v1_model_delete(model); + + for (index = 0; index < 100; ++index) { + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + necpp_wasm_v1_model_delete(model); + } + for (index = 0; index < 5; ++index) { + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0, 0, -0.25, 0, 0, 0.25, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_define_ports(model, tags, segments, 1) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_prepare(model, 300.0) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_solve_voltages( + model, voltage_real, voltage_imag, 1) == NECPP_WASM_V1_OK); + necpp_wasm_v1_model_delete(model); + } + + CHECK(necpp_wasm_v1_deck_last_status(NULL) == + NECPP_WASM_V1_RUNTIME_ERROR); + CHECK(necpp_wasm_v1_deck_last_error(NULL) != NULL); + CHECK(strcmp(necpp_wasm_v1_deck_output(NULL), "") == 0); + CHECK(necpp_wasm_v1_deck_output_length(NULL) == 0); + necpp_wasm_v1_deck_delete(NULL); + deck = necpp_wasm_v1_deck_create(); + CHECK(deck != NULL); + CHECK(necpp_wasm_v1_deck_process(deck, NULL, 0) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_deck_process( + deck, valid_deck, sizeof(valid_deck) - 1) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_deck_last_status(deck) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_deck_output_length(deck) > 0); + CHECK(strstr(necpp_wasm_v1_deck_output(deck), "WP4 C ABI DECK TEST") != NULL); + CHECK(necpp_wasm_v1_deck_process( + deck, "CE\nBOGUS\nEN\n", sizeof("CE\nBOGUS\nEN\n") - 1) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(strlen(necpp_wasm_v1_deck_last_error(deck)) != 0); + CHECK(necpp_wasm_v1_deck_output_length(deck) == 0); + CHECK(necpp_wasm_v1_deck_process( + deck, invalid_rp_deck, sizeof(invalid_rp_deck) - 1) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_deck_process( + deck, invalid_xq_deck, sizeof(invalid_xq_deck) - 1) == + NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_deck_process( + deck, blank_count_rp_deck, sizeof(blank_count_rp_deck) - 1) == + NECPP_WASM_V1_OK); + necpp_wasm_v1_deck_delete(deck); + + return 0; +} diff --git a/src/necpp_wasm_v1_tb.cpp b/src/necpp_wasm_v1_tb.cpp new file mode 100644 index 00000000..004b477e --- /dev/null +++ b/src/necpp_wasm_v1_tb.cpp @@ -0,0 +1,133 @@ +#include +#include + +#include "nec_stateful_model.h" +#include "necpp_wasm_v1.h" + +#include +#include + +extern "C" int necpp_wasm_v1_run_c_contract_test(void); + +TEST_CASE("WP4 versioned ABI is consumable from C", "[wp4][wasm_abi]") +{ + const int failed_line = necpp_wasm_v1_run_c_contract_test(); + INFO("C ABI contract check failed at necpp_wasm_v1_c_tb.c line " + << failed_line); + REQUIRE(failed_line == 0); +} + +namespace { + +void build_one_port_model(nec_stateful_model& model) +{ + model.add_wire({ + 1, 11, + 0.0, 0.0, -0.25, + 0.0, 0.0, 0.25, + 0.001, + }); + model.complete_geometry(); + model.define_ports({{1, 6}}); + model.prepare(300.0); +} + +void require_complex_buffer_matches( + const necpp_wasm_v1_model* model, + int32_t real_kind, + int32_t imag_kind, + const std::vector& expected) +{ + const double* real = necpp_wasm_v1_result_buffer(model, real_kind); + const double* imag = necpp_wasm_v1_result_buffer(model, imag_kind); + REQUIRE(real != nullptr); + REQUIRE(imag != nullptr); + REQUIRE(necpp_wasm_v1_result_buffer_length(model, real_kind) == + expected.size()); + REQUIRE(necpp_wasm_v1_result_buffer_length(model, imag_kind) == + expected.size()); + for (size_t index = 0; index < expected.size(); ++index) { + INFO("buffer index " << index); + REQUIRE(real[index] == Catch::Approx(expected[index].real()) + .epsilon(1.0e-12).margin(1.0e-12)); + REQUIRE(imag[index] == Catch::Approx(expected[index].imag()) + .epsilon(1.0e-12).margin(1.0e-12)); + } +} + +} // namespace + +TEST_CASE("WP4 bulk ABI buffers reproduce native results", + "[wp4][wasm_abi][numerical_contract]") +{ + nec_stateful_model native; + build_one_port_model(native); + const nec_impedance_result native_matrices = + native.compute_impedance_matrix(); + const nec_port_solution native_solution = + native.solve_port_voltages_detailed({nec_complex(1.0, 0.0)}); + nec_far_field_grid grid; + grid.radius_m = 1.0; + grid.theta_start_deg = 0.0; + grid.theta_count = 3; + grid.theta_step_deg = 45.0; + grid.phi_start_deg = 0.0; + grid.phi_count = 2; + grid.phi_step_deg = 90.0; + const nec_far_field_result native_field = native.compute_far_field(grid); + + std::unique_ptr + abi(necpp_wasm_v1_model_create(), &necpp_wasm_v1_model_delete); + REQUIRE(abi != nullptr); + REQUIRE(necpp_wasm_v1_add_wire( + abi.get(), 1, 11, + 0.0, 0.0, -0.25, + 0.0, 0.0, 0.25, + 0.001) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_complete_geometry( + abi.get(), NECPP_WASM_V1_GROUND_CONNECTION_NONE) == NECPP_WASM_V1_OK); + const int32_t tags[] = {1}; + const int32_t segments[] = {6}; + REQUIRE(necpp_wasm_v1_define_ports( + abi.get(), tags, segments, 1) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_prepare(abi.get(), 300.0) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_compute_impedance(abi.get()) == NECPP_WASM_V1_OK); + + require_complex_buffer_matches( + abi.get(), + NECPP_WASM_V1_IMPEDANCE_REAL, + NECPP_WASM_V1_IMPEDANCE_IMAG, + native_matrices.impedance.values); + require_complex_buffer_matches( + abi.get(), + NECPP_WASM_V1_ADMITTANCE_REAL, + NECPP_WASM_V1_ADMITTANCE_IMAG, + native_matrices.admittance.values); + + const double voltage_real[] = {1.0}; + const double voltage_imag[] = {0.0}; + REQUIRE(necpp_wasm_v1_solve_voltages( + abi.get(), voltage_real, voltage_imag, 1) == NECPP_WASM_V1_OK); + require_complex_buffer_matches( + abi.get(), + NECPP_WASM_V1_SOLUTION_CURRENTS_REAL, + NECPP_WASM_V1_SOLUTION_CURRENTS_IMAG, + native_solution.currents); + + REQUIRE(necpp_wasm_v1_compute_far_field( + abi.get(), + grid.radius_m, + grid.theta_start_deg, grid.theta_count, grid.theta_step_deg, + grid.phi_start_deg, grid.phi_count, grid.phi_step_deg) == + NECPP_WASM_V1_OK); + require_complex_buffer_matches( + abi.get(), + NECPP_WASM_V1_FAR_FIELD_E_THETA_REAL, + NECPP_WASM_V1_FAR_FIELD_E_THETA_IMAG, + native_field.e_theta); + require_complex_buffer_matches( + abi.get(), + NECPP_WASM_V1_FAR_FIELD_E_PHI_REAL, + NECPP_WASM_V1_FAR_FIELD_E_PHI_IMAG, + native_field.e_phi); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 97b71bf5..376b35b0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,8 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp2_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp3_tb.cpp + ${CMAKE_SOURCE_DIR}/src/necpp_wasm_v1_c_tb.c + ${CMAKE_SOURCE_DIR}/src/necpp_wasm_v1_tb.cpp ) # Test runner = Catch2 main + test sources + nec2cpp.cpp (main renamed) + the @@ -73,6 +75,7 @@ add_executable(nec2++_tests ${CMAKE_SOURCE_DIR}/src/nec_results.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model.cpp ${CMAKE_SOURCE_DIR}/src/nec_structure_currents.cpp + ${CMAKE_SOURCE_DIR}/src/necpp_wasm_v1.cpp ) target_include_directories(nec2++_tests PRIVATE @@ -97,13 +100,15 @@ endif() # stress suite on independent timeout budgets. Otherwise a slow numerical # fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]") + COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]~[wp4]") add_test(NAME necpp_wp1 COMMAND nec2++_tests "[wp1]") add_test(NAME necpp_wp2 COMMAND nec2++_tests "[wp2]") add_test(NAME necpp_wp3 COMMAND nec2++_tests "[wp3]") +add_test(NAME necpp_wp4 + COMMAND nec2++_tests "[wp4]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of # blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a # 1,000-excitation retained-factorization stress case; it completes in a few @@ -112,6 +117,7 @@ set_tests_properties(necpp_unit PROPERTIES TIMEOUT 480) set_tests_properties(necpp_wp1 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp2 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp3 PROPERTIES TIMEOUT 180) +set_tests_properties(necpp_wp4 PROPERTIES TIMEOUT 180) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From efe6d6c5598f2bb97e942cb98efc1634ae4ad04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 12:58:11 +0200 Subject: [PATCH 21/46] WP5 --- .gitignore | 3 + docs/local-build-environment.md | 11 +- docs/ts_engine_plan.md | 28 + docs/wasm-api.md | 10 +- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 8 +- packages/necpp-wasm/src/deck.ts | 169 +++ packages/necpp-wasm/src/errors.ts | 2 +- packages/necpp-wasm/src/index.ts | 35 +- packages/necpp-wasm/src/loader.ts | 115 ++ packages/necpp-wasm/src/model.ts | 1018 +++++++++++++++++ packages/necpp-wasm/src/nec2pp.generated.d.ts | 5 + packages/necpp-wasm/src/state-machine.ts | 4 +- packages/necpp-wasm/src/wasm-internal.ts | 138 +++ packages/necpp-wasm/test-d/public-api.test.ts | 2 +- packages/necpp-wasm/test/build.mjs | 51 + .../necpp-wasm/test/facade-mapping.test.mjs | 240 ++++ .../necpp-wasm/test/facade-runtime.test.mjs | 196 ++++ packages/necpp-wasm/test/require-wasm.mjs | 11 + .../necpp-wasm/test/state-machine.test.mjs | 4 +- packages/necpp-wasm/tsconfig.build.json | 11 + scripts/build_wasm_inner.sh | 10 + 22 files changed, 2049 insertions(+), 26 deletions(-) create mode 100644 packages/necpp-wasm/src/deck.ts create mode 100644 packages/necpp-wasm/src/loader.ts create mode 100644 packages/necpp-wasm/src/model.ts create mode 100644 packages/necpp-wasm/src/nec2pp.generated.d.ts create mode 100644 packages/necpp-wasm/src/wasm-internal.ts create mode 100644 packages/necpp-wasm/test/build.mjs create mode 100644 packages/necpp-wasm/test/facade-mapping.test.mjs create mode 100644 packages/necpp-wasm/test/facade-runtime.test.mjs create mode 100644 packages/necpp-wasm/test/require-wasm.mjs create mode 100644 packages/necpp-wasm/tsconfig.build.json diff --git a/.gitignore b/.gitignore index 5a80f890..73b697e9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ /wasm/nec2pp.wasm /wasm/nec2pp.d.ts /wasm/SHA256SUMS +/packages/necpp-wasm/src/nec2pp.generated.js +/packages/necpp-wasm/src/nec2pp.wasm +/packages/necpp-wasm/.test-build/ # Legacy test binary name (pre-CMake era); kept ignored in case of stale trees. src/necpp_test src/test_manager diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 13802074..1eceadc4 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -131,12 +131,14 @@ wasm/nec2pp.wasm wasm/nec2pp.d.ts ``` -The wrapper also runs `scripts/wasm_smoke_test.mjs`. Generated WASM artifacts -and all `build-*` directories are ignored by Git. +The wrapper runs `scripts/wasm_smoke_test.mjs`, stages the generated loader and +binary beside the handwritten TypeScript facade, and runs the strict facade +and Node ESM integration tests. Generated WASM artifacts, the facade's +`.test-build` directory, and all `build-*` directories are ignored by Git. ## Known-good verification -The WP4 implementation was verified with: +The WP5 implementation was verified with: - Windows/MSVC: all six CTest entries passed, including the CLI smoke test; - focused WP4: the C caller contract and native bulk-buffer comparison passed; @@ -145,3 +147,6 @@ The WP4 implementation was verified with: successfully in the Docker Release build; - Emscripten 4.0.7: the versioned ABI matrix, solve, combined/embedded field, memory-growth, controlled-error, and complete-deck smoke paths passed. +- TypeScript 5.8.3 and Node ESM: the public facade passed strict compilation, + real matrix/solve/field operations, copied-result lifetime and disposal + checks, default/URL/binary WASM loading, and complete-deck execution. diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index d54ac480..80b426e2 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -498,6 +498,8 @@ The next open package on the critical path is WP5. ## WP5 — TypeScript facade +**Status: complete (2026-08-28).** + Create a handwritten TypeScript layer that is the actual package API. Responsibilities: @@ -547,6 +549,32 @@ DoD: - Quick-start code contains no filesystem paths, `locateFile`, raw memory or glue-module calls. - Node and browser expose the same public types and numerical behavior. +### WP5 progress + +- Implemented the handwritten facade under + [`packages/necpp-wasm/src`](../packages/necpp-wasm/src). `createNecModel()` + asynchronously creates an isolated modular Emscripten instance, resolves the + adjacent `nec2pp.wasm` by default, and accepts mutually exclusive `wasmUrl` + and copied `wasmBinary` overrides. +- Added runtime validation for geometry, ports, loads, ground, frequency, + complex-vector dimensions, finite values, and far-field grids. The facade + enforces the public state machine before crossing the ABI and maps every v1 + status category to its documented `NecError` subclass. +- Added private allocation helpers for split `Int32Array` and `Float64Array` + inputs. Every matrix, solution, coordinate axis, combined field, and + embedded field is copied immediately into JavaScript-owned arrays; generated + Emscripten types and heap views remain private. +- Added idempotent deterministic `dispose()`, frozen port snapshots, and the + asynchronous `runDeck()` compatibility path with UTF-8 ownership and + pre-start abort handling. +- Added strict emit/type tests and Node ESM integration tests covering a real + dipole matrix, repeated solves, copied-result lifetime, combined and embedded + fields, disposal, default/URL/binary loading, controlled errors, and complete + deck execution. The pinned Docker WASM build stages its generated artifacts + privately and runs the full facade suite. + +The next open package on the critical path is WP7; WP6 can proceed in parallel. + --- ## WP6 — Web Worker entry point diff --git a/docs/wasm-api.md b/docs/wasm-api.md index a9975efe..56a42f63 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,8 +1,8 @@ # `@necpp/wasm` API and numerical contract -Status: normative specification, updated through WP4 on 2026-08-28. The -stateful native layer and versioned C/WASM ABI are implemented; the handwritten -TypeScript facade follows in WP5. The committed TypeScript surface is in +Status: normative specification, updated through WP5 on 2026-08-28. The +stateful native layer, versioned C/WASM ABI, and handwritten TypeScript facade +are implemented. The committed TypeScript surface is in [`packages/necpp-wasm/src`](../packages/necpp-wasm/src). ## Package and runtime boundary @@ -17,8 +17,8 @@ name will not change if the package is initially distributed as a tarball. returns a stateful `NecModel`. After creation, model methods are synchronous; large browser calculations should use the worker facade planned in WP6. `runDeck()` is an asynchronous compatibility escape hatch for a complete NEC -text deck. It is not part of a `NecModel` lifecycle and returns the formatted -report as a string. +text deck. It is not part of a `NecModel` lifecycle and returns a `DeckResult` +containing the formatted report and engine version. The JavaScript facade owns the native handle and is solely responsible for destroying it. A consumer never receives a pointer, heap view, generated diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 2cbc37da..fbe17a98 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp0", + "version": "0.0.0-wp5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp/wasm", - "version": "0.0.0-wp0", + "version": "0.0.0-wp5", "license": "GPL-2.0-or-later", "devDependencies": { "typescript": "5.8.3" diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index 2ebb930c..9c672889 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,12 +1,14 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp0", + "version": "0.0.0-wp5", "private": true, "type": "module", - "description": "Public TypeScript contract for the NEC2++ WebAssembly engine", + "description": "TypeScript facade for the NEC2++ WebAssembly engine", "license": "GPL-2.0-or-later", "scripts": { - "test": "node --test test/*.test.mjs && npm run typecheck", + "build:test": "node test/build.mjs", + "test": "npm run build:test && node --test test/*.test.mjs && npm run typecheck", + "test:wasm": "node test/require-wasm.mjs && npm test", "typecheck": "tsc --project tsconfig.json" }, "devDependencies": { diff --git a/packages/necpp-wasm/src/deck.ts b/packages/necpp-wasm/src/deck.ts new file mode 100644 index 00000000..9ce2fa67 --- /dev/null +++ b/packages/necpp-wasm/src/deck.ts @@ -0,0 +1,169 @@ +import { + NecConditioningError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, +} from "./errors.js"; +import type { DeckResult, RunDeckOptions } from "./types.js"; +import type { NecWasmModule } from "./wasm-internal.js"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function decodeBytes( + module: NecWasmModule, + pointer: number, + length: number, +): string { + if ( + !Number.isSafeInteger(pointer) + || pointer < 0 + || !Number.isSafeInteger(length) + || length < 0 + || pointer + length > module.HEAPU8.length + ) { + throw new NecRuntimeError("The native deck runner returned an invalid buffer"); + } + return textDecoder.decode(module.HEAPU8.slice(pointer, pointer + length)); +} + +function decodeCString(module: NecWasmModule, pointer: number): string { + if ( + !Number.isSafeInteger(pointer) + || pointer <= 0 + || pointer >= module.HEAPU8.length + ) { + throw new NecRuntimeError("The native module returned an invalid string"); + } + const end = module.HEAPU8.indexOf(0, pointer); + if (end < 0) { + throw new NecRuntimeError("The native module returned an unterminated string"); + } + return decodeBytes(module, pointer, end - pointer); +} + +function deckError(module: NecWasmModule, deck: number, status: number): never { + let message = `Deck execution failed with native status ${status}`; + try { + const nativeMessage = decodeCString( + module, + module._necpp_wasm_v1_deck_last_error(deck), + ); + if (nativeMessage.length > 0) { + message = nativeMessage; + } + } catch { + // Keep the stable fallback message. + } + const details = { operation: "runDeck", nativeStatus: status }; + switch (status) { + case 2: + throw new NecInputError(message, { details }); + case 3: + throw new NecGeometryError(message, { details }); + case 4: + throw new NecPortError(message, { details }); + case 5: + throw new NecConditioningError(message, { details }); + case 6: + throw new NecSolverError(message, { details }); + default: + throw new NecRuntimeError(message, { details }); + } +} + +function assertNotAborted(options: RunDeckOptions | undefined): void { + if (options?.signal?.aborted === true) { + throw new NecInputError("Deck execution was aborted before it started", { + details: { operation: "runDeck", aborted: true }, + }); + } +} + +export function validateDeckText(deckText: unknown): asserts deckText is string { + if (typeof deckText !== "string" || deckText.length === 0) { + throw new NecInputError("deck must be a nonempty string"); + } + if (deckText.includes("\0")) { + throw new NecInputError("deck cannot contain embedded NUL characters"); + } +} + +export function runDeckWithModule( + module: NecWasmModule, + deckText: string, + options?: RunDeckOptions, +): DeckResult { + assertNotAborted(options); + validateDeckText(deckText); + const bytes = textEncoder.encode(deckText); + if (bytes.length === 0) { + throw new NecInputError("deck must contain UTF-8 input"); + } + + let deck = 0; + let inputPointer = 0; + try { + deck = module._necpp_wasm_v1_deck_create(); + if (!Number.isSafeInteger(deck) || deck <= 0) { + throw new NecRuntimeError("Failed to create the native deck runner"); + } + inputPointer = module._malloc(bytes.length); + if (!Number.isSafeInteger(inputPointer) || inputPointer <= 0) { + throw new NecRuntimeError("WASM memory allocation failed"); + } + module.HEAPU8.set(bytes, inputPointer); + assertNotAborted(options); + const status = module._necpp_wasm_v1_deck_process( + deck, + inputPointer, + bytes.length, + ); + if (status !== 0) { + deckError(module, deck, status); + } + const reportLength = module._necpp_wasm_v1_deck_output_length(deck); + const report = decodeBytes( + module, + module._necpp_wasm_v1_deck_output(deck), + reportLength, + ); + const engineVersion = decodeCString( + module, + module._necpp_wasm_v1_engine_version(), + ); + return { report, engineVersion }; + } catch (error) { + if ( + error instanceof NecInputError + || error instanceof NecGeometryError + || error instanceof NecPortError + || error instanceof NecConditioningError + || error instanceof NecSolverError + || error instanceof NecRuntimeError + ) { + throw error; + } + throw new NecRuntimeError("runDeck failed at the WASM boundary", { + cause: error, + details: { operation: "runDeck" }, + }); + } finally { + if (inputPointer !== 0) { + try { + module._free(inputPointer); + } catch { + // Preserve the operation's result. + } + } + if (deck !== 0) { + try { + module._necpp_wasm_v1_deck_delete(deck); + } catch { + // Cleanup is contained at the ABI. + } + } + } +} diff --git a/packages/necpp-wasm/src/errors.ts b/packages/necpp-wasm/src/errors.ts index da907d54..21a1f845 100644 --- a/packages/necpp-wasm/src/errors.ts +++ b/packages/necpp-wasm/src/errors.ts @@ -1,4 +1,4 @@ -import type { NecModelState } from "./types.ts"; +import type { NecModelState } from "./types.js"; export type NecErrorCode = | "NEC_STATE" diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 68035077..0a75c141 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -7,9 +7,9 @@ export { NecRuntimeError, NecSolverError, NecStateError, -} from "./errors.ts"; +} from "./errors.js"; -export type { NecErrorCode, NecErrorOptions } from "./errors.ts"; +export type { NecErrorCode, NecErrorOptions } from "./errors.js"; export type { AngleSweep, @@ -44,17 +44,38 @@ export type { SegmentSelection, SeriesRlcLoad, WireDefinition, -} from "./types.ts"; +} from "./types.js"; +import { runDeckWithModule, validateDeckText } from "./deck.js"; +import { NecInputError } from "./errors.js"; +import { instantiateNecModule } from "./loader.js"; +import { createModelFromModule } from "./model.js"; import type { CreateNecModelOptions, DeckResult, NecModel, RunDeckOptions, -} from "./types.ts"; +} from "./types.js"; -/** WP0 contract declaration. The runtime factory is implemented in WP5. */ -export declare function createNecModel(options?: CreateNecModelOptions): Promise; +/** Create an isolated stateful NEC model backed by a new WASM module instance. */ +export async function createNecModel( + options?: CreateNecModelOptions, +): Promise { + const module = await instantiateNecModule(options); + return createModelFromModule(module); +} /** Compatibility escape hatch for complete NEC text decks. */ -export declare function runDeck(deck: string, options?: RunDeckOptions): Promise; +export async function runDeck( + deck: string, + options?: RunDeckOptions, +): Promise { + validateDeckText(deck); + if (options?.signal?.aborted === true) { + throw new NecInputError("Deck execution was aborted before it started", { + details: { operation: "runDeck", aborted: true }, + }); + } + const module = await instantiateNecModule(options); + return runDeckWithModule(module, deck, options); +} diff --git a/packages/necpp-wasm/src/loader.ts b/packages/necpp-wasm/src/loader.ts new file mode 100644 index 00000000..4e67e1a0 --- /dev/null +++ b/packages/necpp-wasm/src/loader.ts @@ -0,0 +1,115 @@ +import { NecInputError, NecRuntimeError } from "./errors.js"; +import type { CreateNecModelOptions } from "./types.js"; +import type { + EmscriptenModuleOptions, + NecWasmModule, + NecWasmModuleFactory, +} from "./wasm-internal.js"; + +const EXPECTED_ABI_VERSION = 1; + +function copyWasmBinary(binary: ArrayBuffer | Uint8Array): Uint8Array { + try { + if (binary instanceof Uint8Array) { + return new Uint8Array(binary); + } + if (binary instanceof ArrayBuffer) { + return new Uint8Array(binary.slice(0)); + } + } catch (cause) { + throw new NecInputError("wasmBinary must reference readable WASM bytes", { + cause, + }); + } + throw new NecInputError("wasmBinary must be an ArrayBuffer or Uint8Array"); +} + +function resolveWasmUrl(value: string | URL | undefined): string { + if (value === undefined) { + return new URL("./nec2pp.wasm", import.meta.url).href; + } + if (value instanceof URL) { + return value.href; + } + if (typeof value !== "string" || value.trim().length === 0) { + throw new NecInputError("wasmUrl must be a nonempty string or URL"); + } + try { + return new URL(value, import.meta.url).href; + } catch (cause) { + throw new NecInputError("wasmUrl is not a valid URL", { cause }); + } +} + +function moduleOptions( + options: CreateNecModelOptions | undefined, +): EmscriptenModuleOptions { + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw new NecInputError("WASM loading options must be an object"); + } + if (options?.wasmUrl !== undefined && options.wasmBinary !== undefined) { + throw new NecInputError("wasmUrl and wasmBinary cannot both be supplied"); + } + + if (options?.wasmBinary !== undefined) { + return { wasmBinary: copyWasmBinary(options.wasmBinary) }; + } + + const wasmUrl = resolveWasmUrl(options?.wasmUrl); + return { + locateFile(path, prefix) { + return path.endsWith(".wasm") ? wasmUrl : `${prefix}${path}`; + }, + }; +} + +function validateModule(module: NecWasmModule): void { + if ( + typeof module !== "object" + || module === null + || typeof module._necpp_wasm_v1_abi_version !== "function" + || typeof module._necpp_wasm_v1_model_create !== "function" + || typeof module._necpp_wasm_v1_deck_create !== "function" + || typeof module._malloc !== "function" + || typeof module._free !== "function" + || !(module.HEAPU8 instanceof Uint8Array) + || !(module.HEAP32 instanceof Int32Array) + || !(module.HEAPF64 instanceof Float64Array) + ) { + throw new NecRuntimeError("The loaded Emscripten module has an invalid surface"); + } + + const abiVersion = module._necpp_wasm_v1_abi_version(); + if (abiVersion !== EXPECTED_ABI_VERSION) { + throw new NecRuntimeError( + `Unsupported NEC WASM ABI version ${abiVersion}; expected ${EXPECTED_ABI_VERSION}`, + { details: { actualAbiVersion: abiVersion, expectedAbiVersion: EXPECTED_ABI_VERSION } }, + ); + } +} + +export async function instantiateNecModule( + options: CreateNecModelOptions | undefined, + factory?: NecWasmModuleFactory, +): Promise { + try { + const selectedOptions = moduleOptions(options); + const selectedFactory = factory + ?? (await import("./nec2pp.generated.js")).default; + if (typeof selectedFactory !== "function") { + throw new NecRuntimeError( + "The generated Emscripten module does not export a default factory", + ); + } + const module = await selectedFactory(selectedOptions); + validateModule(module); + return module; + } catch (error) { + if (error instanceof NecInputError || error instanceof NecRuntimeError) { + throw error; + } + throw new NecRuntimeError("Failed to load or instantiate NEC WebAssembly", { + cause: error, + }); + } +} diff --git a/packages/necpp-wasm/src/model.ts b/packages/necpp-wasm/src/model.ts new file mode 100644 index 00000000..5fda5fdd --- /dev/null +++ b/packages/necpp-wasm/src/model.ts @@ -0,0 +1,1018 @@ +import { + NecConditioningError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, + NecStateError, +} from "./errors.js"; +import { transitionModelState, type ModelOperation } from "./state-machine.js"; +import type { + ComplexMatrix, + ComplexVector, + CompleteGeometryOptions, + EmbeddedFarFieldResult, + EmbeddedFieldNormalization, + FarFieldRequest, + FarFieldResult, + GroundModel, + ImpedanceResult, + LoadDefinition, + NecModel, + NecModelState, + PortDefinition, + PortSolution, + PrepareOptions, + SegmentSelection, + WireDefinition, +} from "./types.js"; +import type { NecWasmModule } from "./wasm-internal.js"; + +const STATUS_OK = 0; +const STATUS_STATE = 1; +const STATUS_INPUT = 2; +const STATUS_GEOMETRY = 3; +const STATUS_PORT = 4; +const STATUS_CONDITIONING = 5; +const STATUS_SOLVER = 6; +const STATUS_RUNTIME = 7; + +const INT32_MAX = 2_147_483_647; +const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT; +const INT32_BYTES = Int32Array.BYTES_PER_ELEMENT; + +const BUFFER = { + impedanceReal: 0, + impedanceImag: 1, + admittanceReal: 2, + admittanceImag: 3, + solutionRequestedReal: 4, + solutionRequestedImag: 5, + solutionVoltagesReal: 6, + solutionVoltagesImag: 7, + solutionCurrentsReal: 8, + solutionCurrentsImag: 9, + solutionActiveImpedancesReal: 10, + solutionActiveImpedancesImag: 11, + solutionPowersW: 12, + farFieldThetaDeg: 13, + farFieldPhiDeg: 14, + farFieldEThetaReal: 15, + farFieldEThetaImag: 16, + farFieldEPhiReal: 17, + farFieldEPhiImag: 18, + embeddedThetaDeg: 19, + embeddedPhiDeg: 20, + embeddedEThetaReal: 21, + embeddedEThetaImag: 22, + embeddedEPhiReal: 23, + embeddedEPhiImag: 24, +} as const; + +const textDecoder = new TextDecoder(); + +function inputError(message: string, details?: Readonly>): never { + throw new NecInputError(message, details === undefined ? {} : { details }); +} + +function finiteNumber(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + inputError(`${name} must be a finite number`, { name, value }); + } + return value; +} + +function positiveNumber(value: unknown, name: string): number { + const number = finiteNumber(value, name); + if (!(number > 0)) { + inputError(`${name} must be greater than zero`, { name, value }); + } + return number; +} + +function integerInRange( + value: unknown, + name: string, + minimum: number, +): number { + if ( + typeof value !== "number" + || !Number.isInteger(value) + || value < minimum + || value > INT32_MAX + ) { + inputError( + `${name} must be an integer from ${minimum} through ${INT32_MAX}`, + { name, value }, + ); + } + return value; +} + +function requireRecord( + value: unknown, + name: string, +): Readonly> { + if (typeof value !== "object" || value === null) { + inputError(`${name} must be an object`, { name }); + } + return value as Readonly>; +} + +function validatePoint(value: unknown, name: string): readonly [number, number, number] { + if (!Array.isArray(value) || value.length !== 3) { + inputError(`${name} must contain exactly three coordinates`, { name }); + } + return [ + finiteNumber(value[0], `${name}[0]`), + finiteNumber(value[1], `${name}[1]`), + finiteNumber(value[2], `${name}[2]`), + ]; +} + +function isFloat64Array(value: unknown): value is Float64Array { + return Object.prototype.toString.call(value) === "[object Float64Array]"; +} + +function validateComplexVector( + vector: unknown, + expectedLength: number, + name: string, +): ComplexVector { + const record = requireRecord(vector, name); + const real = record.real; + const imag = record.imag; + if (!isFloat64Array(real) || !isFloat64Array(imag)) { + inputError(`${name}.real and ${name}.imag must be Float64Array instances`); + } + if (real.length !== imag.length || real.length !== expectedLength) { + inputError(`${name} must contain exactly one value per port`, { + expectedLength, + realLength: real.length, + imagLength: imag.length, + }); + } + for (let index = 0; index < expectedLength; index += 1) { + if (!Number.isFinite(real[index]) || !Number.isFinite(imag[index])) { + inputError(`${name} values must be finite`, { index }); + } + } + return { real, imag }; +} + +interface ValidatedGrid { + readonly radiusM: number; + readonly thetaStartDeg: number; + readonly thetaCount: number; + readonly thetaStepDeg: number; + readonly phiStartDeg: number; + readonly phiCount: number; + readonly phiStepDeg: number; + readonly sampleCount: number; +} + +function validateGrid(request: unknown, portCountForEmbedded = 1): ValidatedGrid { + const record = requireRecord(request, "request"); + const theta = requireRecord(record.theta, "request.theta"); + const phi = requireRecord(record.phi, "request.phi"); + const radiusM = record.radiusM === undefined + ? 1 + : positiveNumber(record.radiusM, "request.radiusM"); + const thetaStartDeg = finiteNumber(theta.startDeg, "request.theta.startDeg"); + const thetaCount = integerInRange(theta.count, "request.theta.count", 1); + const thetaStepDeg = finiteNumber(theta.stepDeg, "request.theta.stepDeg"); + const phiStartDeg = finiteNumber(phi.startDeg, "request.phi.startDeg"); + const phiCount = integerInRange(phi.count, "request.phi.count", 1); + const phiStepDeg = finiteNumber(phi.stepDeg, "request.phi.stepDeg"); + const thetaEnd = thetaStartDeg + (thetaCount - 1) * thetaStepDeg; + const phiEnd = phiStartDeg + (phiCount - 1) * phiStepDeg; + if (!Number.isFinite(thetaEnd) || !Number.isFinite(phiEnd)) { + inputError("The requested angle sweep overflows"); + } + const sampleCount = thetaCount * phiCount; + if ( + !Number.isSafeInteger(sampleCount) + || sampleCount > INT32_MAX + || sampleCount * portCountForEmbedded > INT32_MAX + ) { + inputError("The requested far-field array is too large"); + } + return { + radiusM, + thetaStartDeg, + thetaCount, + thetaStepDeg, + phiStartDeg, + phiCount, + phiStepDeg, + sampleCount, + }; +} + +function validateTarget(target: unknown): { + readonly tag: number; + readonly firstSegment: number; + readonly lastSegment: number; +} { + const record = requireRecord(target, "load.target"); + const tag = integerInRange(record.tag, "load.target.tag", 0); + if (record.firstSegment === undefined) { + if (record.lastSegment !== undefined) { + inputError("load.target.lastSegment requires firstSegment"); + } + return { tag, firstSegment: 0, lastSegment: 0 }; + } + const firstSegment = integerInRange( + record.firstSegment, + "load.target.firstSegment", + 1, + ); + const lastSegment = record.lastSegment === undefined + ? firstSegment + : integerInRange(record.lastSegment, "load.target.lastSegment", 1); + if (lastSegment < firstSegment) { + inputError("load.target.lastSegment cannot precede firstSegment"); + } + return { tag, firstSegment, lastSegment }; +} + +function snapshotPorts(ports: readonly PortDefinition[]): readonly PortDefinition[] { + return Object.freeze(ports.map((port) => Object.freeze( + port.name === undefined + ? { tag: port.tag, segment: port.segment } + : { tag: port.tag, segment: port.segment, name: port.name }, + ))); +} + +function nativeState(value: number): Exclude { + switch (value) { + case 0: + return "empty"; + case 1: + return "geometry-building"; + case 2: + return "geometry-complete"; + case 3: + return "prepared"; + case 4: + return "solved"; + default: + throw new NecRuntimeError(`The native model returned invalid state ${value}`); + } +} + +export class WasmNecModel implements NecModel { + #moduleStorage: NecWasmModule | undefined; + #handle: number; + #state: NecModelState = "empty"; + #ports: readonly PortDefinition[] = Object.freeze([]); + + constructor(module: NecWasmModule, handle: number) { + this.#moduleStorage = module; + this.#handle = handle; + try { + this.#state = nativeState(module._necpp_wasm_v1_model_state(handle)); + } catch (cause) { + if (cause instanceof NecRuntimeError) { + throw cause; + } + throw new NecRuntimeError("Failed to read the new native model state", { + cause, + }); + } + if (this.#state !== "empty") { + throw new NecRuntimeError( + `A newly created native model started in unexpected state ${this.#state}`, + ); + } + } + + get state(): NecModelState { + return this.#state; + } + + get #module(): NecWasmModule { + if (this.#moduleStorage === undefined) { + throw new NecRuntimeError("The disposed model no longer has a WASM module"); + } + return this.#moduleStorage; + } + + #assertOperation(operation: ModelOperation): void { + transitionModelState(this.#state, operation); + } + + #syncState(): void { + if (this.#handle === 0) { + this.#state = "disposed"; + return; + } + this.#state = nativeState( + this.#module._necpp_wasm_v1_model_state(this.#handle), + ); + } + + #decodeBytes(pointer: number, length: number): string { + if ( + !Number.isSafeInteger(pointer) + || pointer < 0 + || !Number.isSafeInteger(length) + || length < 0 + || pointer + length > this.#module.HEAPU8.length + ) { + throw new NecRuntimeError("The native module returned an invalid string buffer"); + } + return textDecoder.decode(this.#module.HEAPU8.slice(pointer, pointer + length)); + } + + #decodeCString(pointer: number): string { + if ( + !Number.isSafeInteger(pointer) + || pointer <= 0 + || pointer >= this.#module.HEAPU8.length + ) { + return ""; + } + const end = this.#module.HEAPU8.indexOf(0, pointer); + if (end < 0) { + throw new NecRuntimeError("The native module returned an unterminated string"); + } + return this.#decodeBytes(pointer, end - pointer); + } + + #lastError(): string { + try { + return this.#decodeCString( + this.#module._necpp_wasm_v1_last_error(this.#handle), + ); + } catch { + return ""; + } + } + + #statusError(status: number, operation: ModelOperation): never { + const message = this.#lastError() || `${operation} failed with native status ${status}`; + const details = { operation, nativeStatus: status }; + switch (status) { + case STATUS_STATE: + throw new NecStateError(operation, this.#state, message); + case STATUS_INPUT: + throw new NecInputError(message, { details }); + case STATUS_GEOMETRY: + throw new NecGeometryError(message, { details }); + case STATUS_PORT: + throw new NecPortError(message, { details }); + case STATUS_CONDITIONING: + throw new NecConditioningError(message, { details }); + case STATUS_SOLVER: + throw new NecSolverError(message, { details }); + case STATUS_RUNTIME: + throw new NecRuntimeError(message, { details }); + default: + throw new NecRuntimeError( + `${operation} returned unknown native status ${status}`, + { details }, + ); + } + } + + #invokeStatus(operation: ModelOperation, call: () => number): void { + let status: number; + try { + status = call(); + this.#syncState(); + } catch (cause) { + try { + this.#syncState(); + } catch { + // Preserve the original boundary failure. + } + if (cause instanceof NecRuntimeError) { + throw cause; + } + throw new NecRuntimeError(`${operation} failed at the WASM boundary`, { + cause, + details: { operation }, + }); + } + if (status !== STATUS_OK) { + this.#statusError(status, operation); + } + } + + #readResult(operation: ModelOperation, read: () => T): T { + try { + return read(); + } catch (cause) { + if (cause instanceof NecRuntimeError) { + throw cause; + } + throw new NecRuntimeError( + `${operation} returned an invalid native result`, + { cause, details: { operation } }, + ); + } + } + + #allocate(bytes: number): number { + let pointer: number; + try { + pointer = this.#module._malloc(bytes); + } catch (cause) { + throw new NecRuntimeError("WASM memory allocation failed", { cause }); + } + if (!Number.isSafeInteger(pointer) || pointer <= 0) { + throw new NecRuntimeError("WASM memory allocation failed"); + } + return pointer; + } + + #free(pointer: number): void { + if (pointer === 0) { + return; + } + try { + this.#module._free(pointer); + } catch { + // Emscripten free is not expected to throw; cleanup remains best-effort. + } + } + + #withInt32Pair( + first: Int32Array, + second: Int32Array, + call: (firstPointer: number, secondPointer: number) => number, + ): number { + let firstPointer = 0; + let secondPointer = 0; + try { + firstPointer = this.#allocate(first.byteLength); + secondPointer = this.#allocate(second.byteLength); + this.#module.HEAP32.set(first, firstPointer / INT32_BYTES); + this.#module.HEAP32.set(second, secondPointer / INT32_BYTES); + return call(firstPointer, secondPointer); + } finally { + this.#free(secondPointer); + this.#free(firstPointer); + } + } + + #withFloat64Pair( + first: Float64Array, + second: Float64Array, + call: (firstPointer: number, secondPointer: number) => number, + ): number { + let firstPointer = 0; + let secondPointer = 0; + try { + firstPointer = this.#allocate(first.byteLength); + secondPointer = this.#allocate(second.byteLength); + this.#module.HEAPF64.set(first, firstPointer / FLOAT64_BYTES); + this.#module.HEAPF64.set(second, secondPointer / FLOAT64_BYTES); + return call(firstPointer, secondPointer); + } finally { + this.#free(secondPointer); + this.#free(firstPointer); + } + } + + #copyBuffer(kind: number, expectedLength: number): Float64Array { + try { + const length = this.#module._necpp_wasm_v1_result_buffer_length( + this.#handle, + kind, + ); + const pointer = this.#module._necpp_wasm_v1_result_buffer(this.#handle, kind); + if ( + length !== expectedLength + || !Number.isSafeInteger(pointer) + || pointer < 0 + || pointer % FLOAT64_BYTES !== 0 + || (length > 0 && pointer === 0) + ) { + throw new NecRuntimeError( + `Native result buffer ${kind} has invalid dimensions`, + { details: { kind, expectedLength, actualLength: length, pointer } }, + ); + } + const start = pointer / FLOAT64_BYTES; + const end = start + length; + if (start < 0 || end > this.#module.HEAPF64.length) { + throw new NecRuntimeError(`Native result buffer ${kind} is out of bounds`); + } + return this.#module.HEAPF64.slice(start, end); + } catch (cause) { + if (cause instanceof NecRuntimeError) { + throw cause; + } + throw new NecRuntimeError(`Failed to copy native result buffer ${kind}`, { + cause, + details: { kind }, + }); + } + } + + #matrix(realKind: number, imagKind: number, order: number): ComplexMatrix { + const length = order * order; + return { + rows: order, + columns: order, + order: "row-major", + real: this.#copyBuffer(realKind, length), + imag: this.#copyBuffer(imagKind, length), + }; + } + + addWire(wire: WireDefinition): void { + this.#assertOperation("addWire"); + const record = requireRecord(wire, "wire"); + const tag = integerInRange(record.tag, "wire.tag", 1); + const segments = integerInRange(record.segments, "wire.segments", 1); + const start = validatePoint(record.start, "wire.start"); + const end = validatePoint(record.end, "wire.end"); + if (start[0] === end[0] && start[1] === end[1] && start[2] === end[2]) { + inputError("wire.start and wire.end must be distinct"); + } + const radiusM = positiveNumber(record.radiusM, "wire.radiusM"); + this.#invokeStatus("addWire", () => this.#module._necpp_wasm_v1_add_wire( + this.#handle, + tag, + segments, + start[0], + start[1], + start[2], + end[0], + end[1], + end[2], + radiusM, + )); + } + + completeGeometry(options: CompleteGeometryOptions = {}): void { + this.#assertOperation("completeGeometry"); + const record = requireRecord(options, "options"); + const connection = record.groundConnection ?? "none"; + const nativeConnection = connection === "none" + ? 0 + : connection === "interpolate" + ? 1 + : connection === "zero-current" + ? 2 + : inputError("Unknown ground connection", { connection }); + this.#invokeStatus( + "completeGeometry", + () => this.#module._necpp_wasm_v1_complete_geometry( + this.#handle, + nativeConnection, + ), + ); + } + + definePorts(ports: readonly PortDefinition[]): void { + this.#assertOperation("definePorts"); + if (!Array.isArray(ports) || ports.length === 0) { + throw new NecPortError("At least one port is required"); + } + if (ports.length > INT32_MAX) { + inputError("Too many ports"); + } + const tags = new Int32Array(ports.length); + const segments = new Int32Array(ports.length); + const copies: PortDefinition[] = []; + for (let index = 0; index < ports.length; index += 1) { + const record = requireRecord(ports[index], `ports[${index}]`); + const tag = integerInRange(record.tag, `ports[${index}].tag`, 1); + const segment = integerInRange( + record.segment, + `ports[${index}].segment`, + 1, + ); + if (record.name !== undefined && typeof record.name !== "string") { + inputError(`ports[${index}].name must be a string`); + } + tags[index] = tag; + segments[index] = segment; + copies.push(record.name === undefined + ? { tag, segment } + : { tag, segment, name: record.name as string }); + } + this.#invokeStatus("definePorts", () => this.#withInt32Pair( + tags, + segments, + (tagsPointer, segmentsPointer) => + this.#module._necpp_wasm_v1_define_ports( + this.#handle, + tagsPointer, + segmentsPointer, + ports.length, + ), + )); + this.#ports = snapshotPorts(copies); + } + + addLoad(load: LoadDefinition): void { + this.#assertOperation("addLoad"); + const record = requireRecord(load, "load"); + const target = validateTarget(record.target as SegmentSelection); + let kind: number; + let value1: number; + let value2 = 0; + let value3 = 0; + switch (record.kind) { + case "series-rlc": + case "parallel-rlc": { + if ( + record.perMeter !== undefined + && record.perMeter !== true + && record.perMeter !== false + ) { + inputError("load.perMeter must be boolean when supplied"); + } + const distributed = record.perMeter === true; + kind = record.kind === "series-rlc" + ? (distributed ? 2 : 0) + : (distributed ? 3 : 1); + value1 = finiteNumber(record.resistanceOhm, "load.resistanceOhm"); + value2 = finiteNumber(record.inductanceH, "load.inductanceH"); + value3 = finiteNumber(record.capacitanceF, "load.capacitanceF"); + break; + } + case "impedance": + kind = 4; + value1 = finiteNumber(record.resistanceOhm, "load.resistanceOhm"); + value2 = finiteNumber(record.reactanceOhm, "load.reactanceOhm"); + break; + case "conductivity": + kind = 5; + value1 = positiveNumber( + record.conductivitySPerM, + "load.conductivitySPerM", + ); + break; + default: + return inputError("Unknown load kind", { kind: record.kind }); + } + this.#invokeStatus("addLoad", () => this.#module._necpp_wasm_v1_add_load( + this.#handle, + kind, + target.tag, + target.firstSegment, + target.lastSegment, + value1, + value2, + value3, + )); + } + + clearLoads(): void { + this.#assertOperation("clearLoads"); + this.#invokeStatus( + "clearLoads", + () => this.#module._necpp_wasm_v1_clear_loads(this.#handle), + ); + } + + setGround(ground: GroundModel): void { + this.#assertOperation("setGround"); + const record = requireRecord(ground, "ground"); + let kind: number; + let relativePermittivity = 0; + let conductivitySPerM = 0; + switch (record.kind) { + case "free-space": + kind = 0; + break; + case "perfect": + kind = 1; + break; + case "finite": + kind = record.method === "reflection-coefficient" + ? 2 + : record.method === "sommerfeld-norton" + ? 3 + : inputError("Unknown finite-ground method", { method: record.method }); + relativePermittivity = positiveNumber( + record.relativePermittivity, + "ground.relativePermittivity", + ); + conductivitySPerM = positiveNumber( + record.conductivitySPerM, + "ground.conductivitySPerM", + ); + break; + default: + return inputError("Unknown ground kind", { kind: record.kind }); + } + this.#invokeStatus("setGround", () => this.#module._necpp_wasm_v1_set_ground( + this.#handle, + kind, + relativePermittivity, + conductivitySPerM, + )); + } + + prepare(options: PrepareOptions): void { + this.#assertOperation("prepare"); + const record = requireRecord(options, "options"); + const frequencyMHz = positiveNumber( + record.frequencyMHz, + "options.frequencyMHz", + ); + this.#invokeStatus( + "prepare", + () => this.#module._necpp_wasm_v1_prepare(this.#handle, frequencyMHz), + ); + } + + computeImpedanceMatrix(): ImpedanceResult { + this.#assertOperation("computeImpedanceMatrix"); + this.#invokeStatus( + "computeImpedanceMatrix", + () => this.#module._necpp_wasm_v1_compute_impedance(this.#handle), + ); + return this.#readResult("computeImpedanceMatrix", () => { + const order = this.#module._necpp_wasm_v1_impedance_order(this.#handle); + if (order !== this.#ports.length || !Number.isSafeInteger(order)) { + throw new NecRuntimeError("The native impedance matrix has invalid order"); + } + const conditionEstimate = + this.#module._necpp_wasm_v1_impedance_condition_estimate(this.#handle); + if (!Number.isFinite(conditionEstimate) || conditionEstimate < 0) { + throw new NecRuntimeError("The native condition estimate is invalid"); + } + return { + impedance: this.#matrix( + BUFFER.impedanceReal, + BUFFER.impedanceImag, + order, + ), + admittance: this.#matrix( + BUFFER.admittanceReal, + BUFFER.admittanceImag, + order, + ), + conditionEstimate, + frequencyMHz: + this.#module._necpp_wasm_v1_impedance_frequency_mhz(this.#handle), + factorizationGeneration: + this.#module._necpp_wasm_v1_impedance_factorization_generation( + this.#handle, + ), + }; + }); + } + + #solution(drive: "voltage" | "current"): PortSolution { + const count = this.#module._necpp_wasm_v1_solution_count(this.#handle); + const nativeDrive = this.#module._necpp_wasm_v1_solution_drive(this.#handle); + if ( + count !== this.#ports.length + || !Number.isSafeInteger(count) + || nativeDrive !== (drive === "voltage" ? 0 : 1) + ) { + throw new NecRuntimeError("The native port solution has invalid metadata"); + } + const complex = (realKind: number, imagKind: number): ComplexVector => ({ + real: this.#copyBuffer(realKind, count), + imag: this.#copyBuffer(imagKind, count), + }); + return { + drive, + frequencyMHz: + this.#module._necpp_wasm_v1_solution_frequency_mhz(this.#handle), + ports: snapshotPorts(this.#ports), + requested: complex( + BUFFER.solutionRequestedReal, + BUFFER.solutionRequestedImag, + ), + voltages: complex( + BUFFER.solutionVoltagesReal, + BUFFER.solutionVoltagesImag, + ), + currents: complex( + BUFFER.solutionCurrentsReal, + BUFFER.solutionCurrentsImag, + ), + activeImpedances: complex( + BUFFER.solutionActiveImpedancesReal, + BUFFER.solutionActiveImpedancesImag, + ), + powersW: this.#copyBuffer(BUFFER.solutionPowersW, count), + factorizationGeneration: + this.#module._necpp_wasm_v1_solution_factorization_generation( + this.#handle, + ), + solveGeneration: + this.#module._necpp_wasm_v1_solution_generation(this.#handle), + }; + } + + solveVoltages(voltages: ComplexVector): PortSolution { + this.#assertOperation("solveVoltages"); + const vector = validateComplexVector( + voltages, + this.#ports.length, + "voltages", + ); + this.#invokeStatus("solveVoltages", () => this.#withFloat64Pair( + vector.real, + vector.imag, + (realPointer, imagPointer) => + this.#module._necpp_wasm_v1_solve_voltages( + this.#handle, + realPointer, + imagPointer, + vector.real.length, + ), + )); + return this.#readResult("solveVoltages", () => this.#solution("voltage")); + } + + solveCurrents(currents: ComplexVector): PortSolution { + this.#assertOperation("solveCurrents"); + const vector = validateComplexVector( + currents, + this.#ports.length, + "currents", + ); + this.#invokeStatus("solveCurrents", () => this.#withFloat64Pair( + vector.real, + vector.imag, + (realPointer, imagPointer) => + this.#module._necpp_wasm_v1_solve_currents( + this.#handle, + realPointer, + imagPointer, + vector.real.length, + ), + )); + return this.#readResult("solveCurrents", () => this.#solution("current")); + } + + #farFieldResult(grid: ValidatedGrid): FarFieldResult { + const thetaCount = + this.#module._necpp_wasm_v1_far_field_theta_count(this.#handle); + const phiCount = + this.#module._necpp_wasm_v1_far_field_phi_count(this.#handle); + if ( + thetaCount !== grid.thetaCount + || phiCount !== grid.phiCount + || thetaCount * phiCount !== grid.sampleCount + ) { + throw new NecRuntimeError("The native far-field result has invalid dimensions"); + } + return { + radiusM: this.#module._necpp_wasm_v1_far_field_radius_m(this.#handle), + frequencyMHz: + this.#module._necpp_wasm_v1_far_field_frequency_mhz(this.#handle), + thetaDeg: this.#copyBuffer(BUFFER.farFieldThetaDeg, thetaCount), + phiDeg: this.#copyBuffer(BUFFER.farFieldPhiDeg, phiCount), + eThetaReal: this.#copyBuffer( + BUFFER.farFieldEThetaReal, + grid.sampleCount, + ), + eThetaImag: this.#copyBuffer( + BUFFER.farFieldEThetaImag, + grid.sampleCount, + ), + ePhiReal: this.#copyBuffer(BUFFER.farFieldEPhiReal, grid.sampleCount), + ePhiImag: this.#copyBuffer(BUFFER.farFieldEPhiImag, grid.sampleCount), + }; + } + + computeFarField(request: FarFieldRequest): FarFieldResult { + this.#assertOperation("computeFarField"); + const grid = validateGrid(request); + this.#invokeStatus( + "computeFarField", + () => this.#module._necpp_wasm_v1_compute_far_field( + this.#handle, + grid.radiusM, + grid.thetaStartDeg, + grid.thetaCount, + grid.thetaStepDeg, + grid.phiStartDeg, + grid.phiCount, + grid.phiStepDeg, + ), + ); + return this.#readResult( + "computeFarField", + () => this.#farFieldResult(grid), + ); + } + + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization: EmbeddedFieldNormalization = { + kind: "unit-voltage", + valueV: 1, + }, + ): EmbeddedFarFieldResult { + this.#assertOperation("computeEmbeddedFarFields"); + const grid = validateGrid(request, this.#ports.length); + const record = requireRecord(normalization, "normalization"); + const nativeNormalization = record.kind === "unit-voltage" + && record.valueV === 1 + ? 0 + : record.kind === "unit-current" && record.valueA === 1 + ? 1 + : inputError("normalization must request exactly one volt or one ampere"); + this.#invokeStatus( + "computeEmbeddedFarFields", + () => this.#module._necpp_wasm_v1_compute_embedded_far_fields( + this.#handle, + grid.radiusM, + grid.thetaStartDeg, + grid.thetaCount, + grid.thetaStepDeg, + grid.phiStartDeg, + grid.phiCount, + grid.phiStepDeg, + nativeNormalization, + ), + ); + return this.#readResult("computeEmbeddedFarFields", () => { + const thetaCount = + this.#module._necpp_wasm_v1_embedded_theta_count(this.#handle); + const phiCount = + this.#module._necpp_wasm_v1_embedded_phi_count(this.#handle); + const portCount = + this.#module._necpp_wasm_v1_embedded_port_count(this.#handle); + const samplesPerPort = + this.#module._necpp_wasm_v1_embedded_samples_per_port(this.#handle); + const returnedNormalization = + this.#module._necpp_wasm_v1_embedded_normalization(this.#handle); + if ( + thetaCount !== grid.thetaCount + || phiCount !== grid.phiCount + || portCount !== this.#ports.length + || samplesPerPort !== grid.sampleCount + || returnedNormalization !== nativeNormalization + ) { + throw new NecRuntimeError( + "The native embedded far-field result has invalid metadata", + ); + } + const totalSamples = samplesPerPort * portCount; + const resultNormalization: EmbeddedFieldNormalization = + nativeNormalization === 0 + ? Object.freeze({ kind: "unit-voltage", valueV: 1 }) + : Object.freeze({ kind: "unit-current", valueA: 1 }); + return { + radiusM: this.#module._necpp_wasm_v1_embedded_radius_m(this.#handle), + frequencyMHz: + this.#module._necpp_wasm_v1_embedded_frequency_mhz(this.#handle), + thetaDeg: this.#copyBuffer(BUFFER.embeddedThetaDeg, thetaCount), + phiDeg: this.#copyBuffer(BUFFER.embeddedPhiDeg, phiCount), + eThetaReal: this.#copyBuffer(BUFFER.embeddedEThetaReal, totalSamples), + eThetaImag: this.#copyBuffer(BUFFER.embeddedEThetaImag, totalSamples), + ePhiReal: this.#copyBuffer(BUFFER.embeddedEPhiReal, totalSamples), + ePhiImag: this.#copyBuffer(BUFFER.embeddedEPhiImag, totalSamples), + ports: snapshotPorts(this.#ports), + normalization: resultNormalization, + samplesPerPort, + }; + }); + } + + dispose(): void { + if (this.#state === "disposed") { + return; + } + const handle = this.#handle; + const module = this.#moduleStorage; + this.#handle = 0; + this.#moduleStorage = undefined; + this.#state = "disposed"; + this.#ports = Object.freeze([]); + try { + module?._necpp_wasm_v1_model_delete(handle); + } catch { + // The ABI promises contained, deterministic cleanup. + } + } +} + +export function createModelFromModule(module: NecWasmModule): NecModel { + let handle: number; + try { + handle = module._necpp_wasm_v1_model_create(); + } catch (cause) { + throw new NecRuntimeError("Failed to create the native NEC model", { cause }); + } + if (!Number.isSafeInteger(handle) || handle <= 0) { + throw new NecRuntimeError("Failed to create the native NEC model"); + } + try { + return new WasmNecModel(module, handle); + } catch (error) { + try { + module._necpp_wasm_v1_model_delete(handle); + } catch { + // Preserve the initialization error. + } + throw error; + } +} diff --git a/packages/necpp-wasm/src/nec2pp.generated.d.ts b/packages/necpp-wasm/src/nec2pp.generated.d.ts new file mode 100644 index 00000000..18433f4a --- /dev/null +++ b/packages/necpp-wasm/src/nec2pp.generated.d.ts @@ -0,0 +1,5 @@ +import type { NecWasmModuleFactory } from "./wasm-internal.js"; + +declare const createNecModule: NecWasmModuleFactory; + +export default createNecModule; diff --git a/packages/necpp-wasm/src/state-machine.ts b/packages/necpp-wasm/src/state-machine.ts index 0ff97f6e..1c3a5827 100644 --- a/packages/necpp-wasm/src/state-machine.ts +++ b/packages/necpp-wasm/src/state-machine.ts @@ -1,5 +1,5 @@ -import { NecStateError } from "./errors.ts"; -import type { NecModelState } from "./types.ts"; +import { NecStateError } from "./errors.js"; +import type { NecModelState } from "./types.js"; export type ModelOperation = | "addWire" diff --git a/packages/necpp-wasm/src/wasm-internal.ts b/packages/necpp-wasm/src/wasm-internal.ts new file mode 100644 index 00000000..84167185 --- /dev/null +++ b/packages/necpp-wasm/src/wasm-internal.ts @@ -0,0 +1,138 @@ +/** Handwritten private view of the stable v1 ABI. Never exported publicly. */ +export interface NecWasmModule { + HEAPU8: Uint8Array; + HEAP32: Int32Array; + HEAPF64: Float64Array; + + _malloc(bytes: number): number; + _free(pointer: number): void; + + _necpp_wasm_v1_abi_version(): number; + _necpp_wasm_v1_engine_version(): number; + + _necpp_wasm_v1_model_create(): number; + _necpp_wasm_v1_model_delete(model: number): void; + _necpp_wasm_v1_model_state(model: number): number; + _necpp_wasm_v1_last_status(model: number): number; + _necpp_wasm_v1_last_error(model: number): number; + + _necpp_wasm_v1_add_wire( + model: number, + tag: number, + segments: number, + x1: number, + y1: number, + z1: number, + x2: number, + y2: number, + z2: number, + radiusM: number, + ): number; + _necpp_wasm_v1_complete_geometry(model: number, connection: number): number; + _necpp_wasm_v1_define_ports( + model: number, + tags: number, + segments: number, + count: number, + ): number; + _necpp_wasm_v1_add_load( + model: number, + kind: number, + tag: number, + firstSegment: number, + lastSegment: number, + value1: number, + value2: number, + value3: number, + ): number; + _necpp_wasm_v1_clear_loads(model: number): number; + _necpp_wasm_v1_set_ground( + model: number, + kind: number, + relativePermittivity: number, + conductivitySPerM: number, + ): number; + _necpp_wasm_v1_prepare(model: number, frequencyMHz: number): number; + _necpp_wasm_v1_compute_impedance(model: number): number; + _necpp_wasm_v1_solve_voltages( + model: number, + real: number, + imag: number, + count: number, + ): number; + _necpp_wasm_v1_solve_currents( + model: number, + real: number, + imag: number, + count: number, + ): number; + _necpp_wasm_v1_compute_far_field( + model: number, + radiusM: number, + thetaStartDeg: number, + thetaCount: number, + thetaStepDeg: number, + phiStartDeg: number, + phiCount: number, + phiStepDeg: number, + ): number; + _necpp_wasm_v1_compute_embedded_far_fields( + model: number, + radiusM: number, + thetaStartDeg: number, + thetaCount: number, + thetaStepDeg: number, + phiStartDeg: number, + phiCount: number, + phiStepDeg: number, + normalization: number, + ): number; + + _necpp_wasm_v1_impedance_order(model: number): number; + _necpp_wasm_v1_impedance_frequency_mhz(model: number): number; + _necpp_wasm_v1_impedance_condition_estimate(model: number): number; + _necpp_wasm_v1_impedance_factorization_generation(model: number): number; + + _necpp_wasm_v1_solution_count(model: number): number; + _necpp_wasm_v1_solution_drive(model: number): number; + _necpp_wasm_v1_solution_frequency_mhz(model: number): number; + _necpp_wasm_v1_solution_factorization_generation(model: number): number; + _necpp_wasm_v1_solution_generation(model: number): number; + + _necpp_wasm_v1_far_field_radius_m(model: number): number; + _necpp_wasm_v1_far_field_frequency_mhz(model: number): number; + _necpp_wasm_v1_far_field_theta_count(model: number): number; + _necpp_wasm_v1_far_field_phi_count(model: number): number; + + _necpp_wasm_v1_embedded_radius_m(model: number): number; + _necpp_wasm_v1_embedded_frequency_mhz(model: number): number; + _necpp_wasm_v1_embedded_theta_count(model: number): number; + _necpp_wasm_v1_embedded_phi_count(model: number): number; + _necpp_wasm_v1_embedded_port_count(model: number): number; + _necpp_wasm_v1_embedded_samples_per_port(model: number): number; + _necpp_wasm_v1_embedded_normalization(model: number): number; + + _necpp_wasm_v1_result_buffer(model: number, kind: number): number; + _necpp_wasm_v1_result_buffer_length(model: number, kind: number): number; + + _necpp_wasm_v1_deck_create(): number; + _necpp_wasm_v1_deck_delete(deck: number): void; + _necpp_wasm_v1_deck_process( + deck: number, + utf8: number, + length: number, + ): number; + _necpp_wasm_v1_deck_last_status(deck: number): number; + _necpp_wasm_v1_deck_last_error(deck: number): number; + _necpp_wasm_v1_deck_output(deck: number): number; + _necpp_wasm_v1_deck_output_length(deck: number): number; +} + +export interface EmscriptenModuleOptions { + readonly locateFile?: (path: string, prefix: string) => string; + readonly wasmBinary?: Uint8Array; +} + +export type NecWasmModuleFactory = ( + options?: EmscriptenModuleOptions, +) => Promise; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index 8ed598d5..a4ecad07 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -5,7 +5,7 @@ import { type ComplexMatrix, type FarFieldResult, type PortSolution, -} from "../src/index.ts"; +} from "../src/index.js"; async function validConsumer(): Promise { const model = await createNecModel(); diff --git a/packages/necpp-wasm/test/build.mjs b/packages/necpp-wasm/test/build.mjs new file mode 100644 index 00000000..0444b872 --- /dev/null +++ b/packages/necpp-wasm/test/build.mjs @@ -0,0 +1,51 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + rmSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const outputDirectory = fileURLToPath( + new URL("../.test-build/", import.meta.url), +); +const localCompiler = fileURLToPath( + new URL("../node_modules/typescript/bin/tsc", import.meta.url), +); + +rmSync(outputDirectory, { force: true, recursive: true }); + +const compiler = existsSync(localCompiler) ? process.execPath : "tsc"; +const compilerArguments = existsSync(localCompiler) + ? [localCompiler, "--project", "tsconfig.build.json"] + : ["--project", "tsconfig.build.json"]; +const result = spawnSync( + compiler, + compilerArguments, + { + cwd: packageDirectory, + stdio: "inherit", + }, +); +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +const sourceDirectory = fileURLToPath(new URL("../src/", import.meta.url)); +const builtSourceDirectory = fileURLToPath( + new URL("../.test-build/src/", import.meta.url), +); +mkdirSync(builtSourceDirectory, { recursive: true }); + +for (const name of ["nec2pp.generated.js", "nec2pp.wasm"]) { + const source = join(sourceDirectory, name); + if (existsSync(source)) { + copyFileSync(source, join(builtSourceDirectory, name)); + } +} diff --git a/packages/necpp-wasm/test/facade-mapping.test.mjs b/packages/necpp-wasm/test/facade-mapping.test.mjs new file mode 100644 index 00000000..0bd4ed5f --- /dev/null +++ b/packages/necpp-wasm/test/facade-mapping.test.mjs @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + NecConditioningError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, + NecStateError, +} from "../.test-build/src/errors.js"; +import { WasmNecModel } from "../.test-build/src/model.js"; + +function createRecordingModule() { + const memory = new ArrayBuffer(65_536); + const HEAPU8 = new Uint8Array(memory); + const calls = []; + let state = 0; + let nextStatus = 0; + let allocation = 256; + let deleted = false; + HEAPU8.set(new TextEncoder().encode("controlled native failure\0"), 8); + + const complete = (name, args, nextState) => { + calls.push([name, ...args]); + const status = nextStatus; + nextStatus = 0; + if (status === 0 && nextState !== undefined) { + state = nextState; + } + return status; + }; + + return { + module: { + HEAPU8, + HEAP32: new Int32Array(memory), + HEAPF64: new Float64Array(memory), + _malloc(bytes) { + const pointer = allocation; + allocation = (allocation + bytes + 7) & ~7; + return pointer; + }, + _free() {}, + _necpp_wasm_v1_model_state() { + return state; + }, + _necpp_wasm_v1_last_error() { + return 8; + }, + _necpp_wasm_v1_add_wire(...args) { + return complete("addWire", args, 1); + }, + _necpp_wasm_v1_complete_geometry(...args) { + return complete("completeGeometry", args, 2); + }, + _necpp_wasm_v1_define_ports(...args) { + return complete("definePorts", args); + }, + _necpp_wasm_v1_add_load(...args) { + return complete("addLoad", args); + }, + _necpp_wasm_v1_clear_loads(...args) { + return complete("clearLoads", args); + }, + _necpp_wasm_v1_set_ground(...args) { + return complete("setGround", args); + }, + _necpp_wasm_v1_model_delete() { + deleted = true; + }, + }, + calls, + setNextStatus(status) { + nextStatus = status; + }, + wasDeleted() { + return deleted; + }, + }; +} + +function createConfigurableModel(recording) { + const model = new WasmNecModel(recording.module, 1); + model.addWire({ + tag: 1, + segments: 3, + start: [0, 0, 0], + end: [0, 0, 1], + radiusM: 0.001, + }); + model.completeGeometry({ groundConnection: "zero-current" }); + model.definePorts([{ tag: 1, segment: 2 }]); + return model; +} + +test("the facade maps every load, ground, and connection enum to ABI values", () => { + const recording = createRecordingModule(); + const model = createConfigurableModel(recording); + assert.equal( + recording.calls.find(([name]) => name === "completeGeometry")[2], + 2, + ); + + const loads = [ + [ + { + kind: "series-rlc", + target: { tag: 1 }, + resistanceOhm: 1, + inductanceH: 2, + capacitanceF: 3, + }, + [0, 1, 0, 0, 1, 2, 3], + ], + [ + { + kind: "parallel-rlc", + target: { tag: 1, firstSegment: 2 }, + resistanceOhm: 4, + inductanceH: 5, + capacitanceF: 6, + }, + [1, 1, 2, 2, 4, 5, 6], + ], + [ + { + kind: "series-rlc", + perMeter: true, + target: { tag: 0, firstSegment: 2, lastSegment: 3 }, + resistanceOhm: 7, + inductanceH: 8, + capacitanceF: 9, + }, + [2, 0, 2, 3, 7, 8, 9], + ], + [ + { + kind: "parallel-rlc", + perMeter: true, + target: { tag: 1 }, + resistanceOhm: 10, + inductanceH: 11, + capacitanceF: 12, + }, + [3, 1, 0, 0, 10, 11, 12], + ], + [ + { + kind: "impedance", + target: { tag: 1, firstSegment: 1, lastSegment: 2 }, + resistanceOhm: 13, + reactanceOhm: 14, + }, + [4, 1, 1, 2, 13, 14, 0], + ], + [ + { + kind: "conductivity", + target: { tag: 1 }, + conductivitySPerM: 15, + }, + [5, 1, 0, 0, 15, 0, 0], + ], + ]; + + for (const [load, expected] of loads) { + model.addLoad(load); + const call = recording.calls.at(-1); + assert.equal(call[0], "addLoad"); + assert.deepEqual(call.slice(2), expected); + } + + const grounds = [ + [{ kind: "free-space" }, [0, 0, 0]], + [{ kind: "perfect" }, [1, 0, 0]], + [ + { + kind: "finite", + method: "reflection-coefficient", + relativePermittivity: 13, + conductivitySPerM: 0.005, + }, + [2, 13, 0.005], + ], + [ + { + kind: "finite", + method: "sommerfeld-norton", + relativePermittivity: 20, + conductivitySPerM: 0.01, + }, + [3, 20, 0.01], + ], + ]; + for (const [ground, expected] of grounds) { + model.setGround(ground); + const call = recording.calls.at(-1); + assert.equal(call[0], "setGround"); + assert.deepEqual(call.slice(2), expected); + } + + model.clearLoads(); + assert.equal(recording.calls.at(-1)[0], "clearLoads"); + model.dispose(); + assert.equal(recording.wasDeleted(), true); +}); + +test("every stable native status becomes its public typed error", () => { + const recording = createRecordingModule(); + const model = createConfigurableModel(recording); + const load = { + kind: "impedance", + target: { tag: 1 }, + resistanceOhm: 1, + reactanceOhm: 0, + }; + const mappings = [ + [1, NecStateError, "NEC_STATE"], + [2, NecInputError, "NEC_INPUT"], + [3, NecGeometryError, "NEC_GEOMETRY"], + [4, NecPortError, "NEC_PORT"], + [5, NecConditioningError, "NEC_CONDITIONING"], + [6, NecSolverError, "NEC_SOLVER"], + [7, NecRuntimeError, "NEC_RUNTIME"], + ]; + + for (const [status, ErrorClass, code] of mappings) { + recording.setNextStatus(status); + assert.throws( + () => model.addLoad(load), + (error) => + error instanceof ErrorClass + && error.code === code + && error.message === "controlled native failure", + ); + } + model.dispose(); +}); diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs new file mode 100644 index 00000000..963c3a2e --- /dev/null +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; + +import { + NecInputError, + NecStateError, + createNecModel, + runDeck, +} from "../.test-build/src/index.js"; + +const generatedLoader = new URL( + "../.test-build/src/nec2pp.generated.js", + import.meta.url, +); +const wasmUrl = new URL("../.test-build/src/nec2pp.wasm", import.meta.url); +const hasWasm = existsSync(generatedLoader) && existsSync(wasmUrl); + +const validDeck = `CM TYPESCRIPT FACADE TEST +CE +GW 1 11 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 +GE 0 +FR 0 1 0 0 300.0 +EX 0 1 6 0 1.0 0.0 +XQ +EN +`; + +function addDipole(model) { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + const port = { tag: 1, segment: 6, name: "feed" }; + model.definePorts([port]); + port.name = "mutated"; + model.addLoad({ + kind: "impedance", + target: { tag: 1, firstSegment: 1 }, + resistanceOhm: 1, + reactanceOhm: 0, + }); + model.clearLoads(); + model.setGround({ kind: "free-space" }); + model.prepare({ frequencyMHz: 300 }); +} + +test("loading options reject ambiguous input before module loading", async () => { + await assert.rejects( + createNecModel({ + wasmUrl, + wasmBinary: new Uint8Array([0]), + }), + (error) => error instanceof NecInputError && error.code === "NEC_INPUT", + ); +}); + +test("runDeck honors a pre-start abort without loading WASM", async () => { + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + runDeck(validDeck, { signal: controller.signal }), + (error) => error instanceof NecInputError && error.code === "NEC_INPUT", + ); +}); + +test("runDeck validates text before loading WASM", async () => { + await assert.rejects( + runDeck(""), + (error) => error instanceof NecInputError && error.code === "NEC_INPUT", + ); + await assert.rejects( + runDeck("CE\0EN\n"), + (error) => error instanceof NecInputError && error.code === "NEC_INPUT", + ); +}); + +test("the facade performs a complete stateful solve with owned results", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const model = await createNecModel(); + assert.equal(model.state, "empty"); + addDipole(model); + assert.equal(model.state, "prepared"); + + const matrices = model.computeImpedanceMatrix(); + assert.equal(matrices.impedance.order, "row-major"); + assert.equal(matrices.impedance.real.length, 1); + assert.ok(Number.isFinite(matrices.impedance.real[0])); + assert.ok(Number.isFinite(matrices.impedance.imag[0])); + assert.ok(matrices.impedance.real[0] > 0); + + const first = model.solveVoltages({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + assert.equal(model.state, "solved"); + assert.equal(first.drive, "voltage"); + assert.equal(first.ports[0].name, "feed"); + const retainedCurrent = [ + first.currents.real[0], + first.currents.imag[0], + ]; + + const second = model.solveVoltages({ + real: new Float64Array([0.5]), + imag: new Float64Array([0.25]), + }); + assert.equal(second.solveGeneration, first.solveGeneration + 1); + assert.deepEqual( + [first.currents.real[0], first.currents.imag[0]], + retainedCurrent, + "an earlier solution must not alias native result memory", + ); + + const currentDriven = model.solveCurrents({ + real: new Float64Array([0.01]), + imag: new Float64Array([0]), + }); + assert.equal(currentDriven.drive, "current"); + assert.equal(currentDriven.requested.real[0], 0.01); + assert.ok(Number.isFinite(currentDriven.voltages.real[0])); + + const field = model.computeFarField({ + theta: { startDeg: 0, count: 3, stepDeg: 45 }, + phi: { startDeg: 0, count: 2, stepDeg: 90 }, + }); + assert.equal(field.radiusM, 1); + assert.equal(field.thetaDeg.length, 3); + assert.equal(field.phiDeg.length, 2); + assert.equal(field.eThetaReal.length, 6); + assert.ok(field.eThetaReal.every(Number.isFinite)); + const retainedField = field.eThetaReal.slice(); + + const embedded = model.computeEmbeddedFarFields({ + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + assert.equal(embedded.normalization.kind, "unit-voltage"); + assert.equal(embedded.samplesPerPort, 1); + assert.equal(embedded.eThetaReal.length, 1); + + const currentEmbedded = model.computeEmbeddedFarFields({ + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }, { kind: "unit-current", valueA: 1 }); + assert.equal(currentEmbedded.normalization.kind, "unit-current"); + assert.equal(currentEmbedded.eThetaReal.length, 1); + + assert.throws( + () => model.solveVoltages({ + real: new Float64Array(0), + imag: new Float64Array(0), + }), + NecInputError, + ); + + model.dispose(); + model.dispose(); + assert.equal(model.state, "disposed"); + assert.deepEqual(field.eThetaReal, retainedField); + assert.throws(() => model.prepare({ frequencyMHz: 300 }), NecStateError); +}); + +test("default, explicit URL, and binary WASM loading behave alike", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const urlModel = await createNecModel({ wasmUrl }); + assert.equal(urlModel.state, "empty"); + urlModel.dispose(); + + const bytes = readFileSync(wasmUrl); + const binaryModel = await createNecModel({ wasmBinary: bytes }); + assert.equal(binaryModel.state, "empty"); + binaryModel.dispose(); +}); + +test("runDeck returns an owned report and engine version", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const result = await runDeck(validDeck); + assert.match(result.report, /TYPESCRIPT FACADE TEST/); + assert.match(result.report, /ANTENNA INPUT PARAMETERS/); + assert.ok(result.engineVersion.length > 0); + + await assert.rejects( + runDeck("CE INVALID INPUT\nBOGUS\nEN\n"), + (error) => error instanceof NecInputError && error.code === "NEC_INPUT", + ); +}); diff --git a/packages/necpp-wasm/test/require-wasm.mjs b/packages/necpp-wasm/test/require-wasm.mjs new file mode 100644 index 00000000..b1f3dff2 --- /dev/null +++ b/packages/necpp-wasm/test/require-wasm.mjs @@ -0,0 +1,11 @@ +import { existsSync } from "node:fs"; + +const missing = ["nec2pp.generated.js", "nec2pp.wasm"].filter( + (name) => !existsSync(new URL(`../src/${name}`, import.meta.url)), +); + +if (missing.length > 0) { + throw new Error( + `WASM facade acceptance requires built artifacts: ${missing.join(", ")}`, + ); +} diff --git a/packages/necpp-wasm/test/state-machine.test.mjs b/packages/necpp-wasm/test/state-machine.test.mjs index 559021af..eac334ae 100644 --- a/packages/necpp-wasm/test/state-machine.test.mjs +++ b/packages/necpp-wasm/test/state-machine.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { NecStateError } from "../src/errors.ts"; +import { NecStateError } from "../.test-build/src/errors.js"; import { MODEL_TRANSITIONS, transitionModelState, -} from "../src/state-machine.ts"; +} from "../.test-build/src/state-machine.js"; const states = [ "empty", diff --git a/packages/necpp-wasm/tsconfig.build.json b/packages/necpp-wasm/tsconfig.build.json new file mode 100644 index 00000000..4b66a593 --- /dev/null +++ b/packages/necpp-wasm/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "noEmit": false, + "outDir": ".test-build", + "rootDir": "." + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/build_wasm_inner.sh b/scripts/build_wasm_inner.sh index d3850a8e..e528e4b4 100644 --- a/scripts/build_wasm_inner.sh +++ b/scripts/build_wasm_inner.sh @@ -23,6 +23,9 @@ export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" tsc --version +rm -f \ + packages/necpp-wasm/src/nec2pp.generated.js \ + packages/necpp-wasm/src/nec2pp.wasm rm -rf "$CONTAINER_BUILD_DIR" CXX_FLAGS="-O3 -DNDEBUG -flto -fexceptions" @@ -56,6 +59,13 @@ node --experimental-default-type=module \ scripts/wasm_smoke_test.mjs \ "$CONTAINER_BUILD_DIR/src/nec2pp.js" +cp "$CONTAINER_BUILD_DIR/src/nec2pp.js" \ + packages/necpp-wasm/src/nec2pp.generated.js +cp "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" \ + packages/necpp-wasm/src/nec2pp.wasm + +npm --prefix packages/necpp-wasm run test:wasm + mkdir -p "$WASM_OUT_DIR" cp \ From 542923f8ed7e952287e7b63a6a34b01eb9945b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 13:19:08 +0200 Subject: [PATCH 22/46] WP6 (Grok) --- docs/local-build-environment.md | 5 + docs/ts_engine_plan.md | 33 +- docs/wasm-api.md | 45 +- docs/wp6-web-worker.md | 40 ++ packages/necpp-wasm/package.json | 2 +- packages/necpp-wasm/src/index.ts | 5 + .../necpp-wasm/src/node-worker-threads.d.ts | 20 + packages/necpp-wasm/src/types.ts | 62 +++ packages/necpp-wasm/src/worker-client.ts | 418 ++++++++++++++++++ packages/necpp-wasm/src/worker-entry.ts | 120 +++++ packages/necpp-wasm/src/worker-protocol.ts | 384 ++++++++++++++++ packages/necpp-wasm/src/worker-runtime.ts | 194 ++++++++ packages/necpp-wasm/src/worker.ts | 51 +++ packages/necpp-wasm/test-d/worker-api.test.ts | 75 ++++ .../necpp-wasm/test/worker-client.test.mjs | 407 +++++++++++++++++ .../test/worker-integration.test.mjs | 122 +++++ .../necpp-wasm/test/worker-protocol.test.mjs | 86 ++++ 17 files changed, 2058 insertions(+), 11 deletions(-) create mode 100644 docs/wp6-web-worker.md create mode 100644 packages/necpp-wasm/src/node-worker-threads.d.ts create mode 100644 packages/necpp-wasm/src/worker-client.ts create mode 100644 packages/necpp-wasm/src/worker-entry.ts create mode 100644 packages/necpp-wasm/src/worker-protocol.ts create mode 100644 packages/necpp-wasm/src/worker-runtime.ts create mode 100644 packages/necpp-wasm/src/worker.ts create mode 100644 packages/necpp-wasm/test-d/worker-api.test.ts create mode 100644 packages/necpp-wasm/test/worker-client.test.mjs create mode 100644 packages/necpp-wasm/test/worker-integration.test.mjs create mode 100644 packages/necpp-wasm/test/worker-protocol.test.mjs diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 1eceadc4..4e9ec110 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -150,3 +150,8 @@ The WP5 implementation was verified with: - TypeScript 5.8.3 and Node ESM: the public facade passed strict compilation, real matrix/solve/field operations, copied-result lifetime and disposal checks, default/URL/binary WASM loading, and complete-deck execution. + +The WP6 worker facade was verified on the same host with TypeScript 5.8.3 and +Node ESM: all 24 package tests passed, including transferable result buffers, +client-thread heartbeats during outstanding work, independent worker models, +termination, and real WASM Z-matrix/far-field agreement with direct mode. diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 80b426e2..4bce2dba 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -573,12 +573,15 @@ DoD: deck execution. The pinned Docker WASM build stages its generated artifacts privately and runs the full facade suite. -The next open package on the critical path is WP7; WP6 can proceed in parallel. +The next open package on the critical path is WP7; WP6 worker support is +complete and can land independently of package assembly. --- ## WP6 — Web Worker entry point +**Status: complete (2026-08-28).** + Browser solves are synchronous and potentially expensive, so include an optional worker facade: ```ts @@ -610,6 +613,34 @@ DoD: - Worker setup requires only importing the documented subpath. - No consumer-authored worker bootstrap file is needed. +### WP6 progress + +- Added `createNecWorkerModel()` on the `@necpp/wasm/worker` subpath. The + package ships `worker-entry.ts`; the client constructs + `new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" })` + in browsers and uses `node:worker_threads` in Node. No consumer bootstrap + file is required. +- Each worker model keeps one isolated Emscripten instance and native handle + across requests. Client calls are serialized per model. Two worker models + do not share state. +- Result `ArrayBuffer`s are posted with a transfer list. Input typed arrays + are copied first so caller buffers are never detached. Port snapshots are + re-frozen on the client. +- Coarse `start`/`complete` progress events are emitted at operation + boundaries, including worker-only `create`. `terminate()` kills the worker + and rejects outstanding work; `dispose()` destroys the native model first. +- Added protocol, loopback-host, and Node ESM tests for transfer (detached + buffers), heartbeats during outstanding work, queue serialization, + independent models, typed-error revival, and termination. Real WASM + integration compares Z matrices and far fields with direct mode at `1e-12` + when artifacts are present. +- Documented the worker contract in [`docs/wasm-api.md`](wasm-api.md) and + [`docs/wp6-web-worker.md`](wp6-web-worker.md). Direct `createNecModel()` is + unchanged. + +The next open package on the critical path is WP7; browser CI for the worker +subpath is WP8. + --- ## WP7 — npm package assembly diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 56a42f63..b011b924 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,9 +1,9 @@ # `@necpp/wasm` API and numerical contract -Status: normative specification, updated through WP5 on 2026-08-28. The -stateful native layer, versioned C/WASM ABI, and handwritten TypeScript facade -are implemented. The committed TypeScript surface is in -[`packages/necpp-wasm/src`](../packages/necpp-wasm/src). +Status: normative specification, updated through WP6 on 2026-08-28. The +stateful native layer, versioned C/WASM ABI, handwritten TypeScript facade, +and optional Web Worker entry point are implemented. The committed TypeScript +surface is in [`packages/necpp-wasm/src`](../packages/necpp-wasm/src). ## Package and runtime boundary @@ -14,11 +14,13 @@ packages. Publication requires control of the `necpp` npm scope, but the API name will not change if the package is initially distributed as a tarball. `createNecModel()` asynchronously initializes the Emscripten module and -returns a stateful `NecModel`. After creation, model methods are synchronous; -large browser calculations should use the worker facade planned in WP6. -`runDeck()` is an asynchronous compatibility escape hatch for a complete NEC -text deck. It is not part of a `NecModel` lifecycle and returns a `DeckResult` -containing the formatted report and engine version. +returns a stateful `NecModel`. After creation, those model methods are +synchronous. Large browser calculations should use `createNecWorkerModel()` +from `@necpp/wasm/worker`; its methods are asynchronous, serialized per model, +and otherwise observe this contract. `runDeck()` is an asynchronous +compatibility escape hatch for a complete NEC text deck. It is not part of a +`NecModel` lifecycle and returns a `DeckResult` containing the formatted +report and engine version. The JavaScript facade owns the native handle and is solely responsible for destroying it. A consumer never receives a pointer, heap view, generated @@ -170,6 +172,7 @@ enumerates every operation/state pair plus both `prepare()` branches. | Method | Inputs and units | Output | Failures beyond illegal state | |---|---|---|---| | `createNecModel(options?)` | Optional `wasmUrl` or caller-owned WASM bytes | Promise of an `empty` model | `NecRuntimeError` for load/instantiate/version failure; `NecInputError` if both overrides are supplied | +| `createNecWorkerModel(options?)` | Same loading overrides plus optional `onProgress` | Promise of an `empty` worker model | Same loading failures; `NecRuntimeError` if the worker cannot start or is terminated | | `addWire(wire)` | Positive integer tag/count; distinct finite endpoints and positive finite radius, all in m | `void`; copies the definition | `NecInputError` for shape/range errors; `NecGeometryError` for engine geometry limits | | `completeGeometry(options?)` | Ground connection: `none` (default), `interpolate`, or `zero-current` | `void` | `NecGeometryError` for intersections, invalid junctions, or a ground-incompatible structure | | `definePorts(ports)` | Nonempty ordered tag and one-based segment pairs | `void`; copies and freezes order | `NecPortError` for missing/duplicate ports or non-source-capable segments; `NecInputError` for malformed integers | @@ -183,6 +186,7 @@ enumerates every operation/state pair plus both `prepare()` branches. | `computeFarField(request)` | Positive radius in m (default 1), finite angle starts/steps, positive integer counts | Complex V/m for latest solution | `NecInputError` for grid/range/size overflow; `NecSolverError` for field calculation failure | | `computeEmbeddedFarFields(request, normalization?)` | Same grid plus unit-voltage (default) or unit-current normalization | Basis-major complex V/m arrays | Matrix/conditioning and far-field failures above | | `dispose()` | None | `void`; idempotent | No failure is exposed; cleanup errors are contained | +| `terminate()` | Worker models only | `void`; kills the worker immediately | Outstanding promises reject with `NecRuntimeError` | | `runDeck(deck, options?)` | Complete UTF-8 deck string; optional pre-start abort signal | Promise of formatted report and engine version | `NecInputError` for empty/invalid deck or pre-abort; `NecSolverError` for execution; `NecRuntimeError` for module failure | All native exceptions are contained at the C ABI. The TypeScript layer maps a @@ -208,6 +212,29 @@ keeps the last successfully prepared factorization and consumer solution when the native layer can prove they are intact; otherwise it rolls back to `geometry-complete` and discards prepared data. +## Worker facade + +`createNecWorkerModel()` is imported from `@necpp/wasm/worker`. The package +supplies the worker script; a consumer does not write a bootstrap file. Each +call creates an isolated worker and Emscripten instance. Methods match +`NecModel` but return promises and are serialized per model. Progress +callbacks fire at coarse `start`/`complete` boundaries, including worker-only +`create`. Large result `ArrayBuffer`s are transferred, not structured-cloned. +Input arrays remain caller-owned. + +`terminate()` is the cancellation mechanism: it kills the worker, rejects +outstanding operations with `NecRuntimeError`, and leaves the model +`disposed`. `dispose()` destroys the native handle first, then terminates. +The direct `createNecModel()` entry point is unchanged for Node, tests, and +small models. Browser integration of this subpath after bundling is a WP7/WP8 +packaging concern; the worker client constructs + +```ts +new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" }) +``` + +so bundlers can rewrite the worker URL without extra consumer configuration. + ## Canonical test models All fixtures use free space, 300 MHz, round PEC wire of radius 0.001 m, 11 diff --git a/docs/wp6-web-worker.md b/docs/wp6-web-worker.md new file mode 100644 index 00000000..debc9233 --- /dev/null +++ b/docs/wp6-web-worker.md @@ -0,0 +1,40 @@ +# WP6 Web Worker entry point + +WP6 adds an optional worker facade so browser applications can keep realistic +NEC solves off the UI thread. The public factory is +`createNecWorkerModel()` from the `@necpp/wasm/worker` subpath. Direct +`createNecModel()` remains the Node, test, and small-model entry point. + +The package ships the worker script. Consumers import the documented subpath +and do not author a bootstrap file, `locateFile` helper, or Emscripten glue. + +## Lifecycle and messaging + +Each worker model owns one dedicated worker and one isolated Emscripten +module. The native `NecModel` stays inside the worker for the lifetime of the +client object. Requests are serialized per model: a second method call waits +until the previous reply arrives. Two `createNecWorkerModel()` calls create +two workers and do not share handles, heaps, or factorization state. + +Worker methods are asynchronous and otherwise match `NecModel`. Returned typed +arrays are reconstructed on the client from transferred `ArrayBuffer`s, so +they remain valid after later solves, WASM growth, or disposal. Input arrays +are copied before posting; the caller's buffers are never detached. + +Coarse `start`/`complete` progress events are posted at operation boundaries, +including worker-only `create`. Listeners run on the client thread. + +## Cancellation + +An in-progress native solve is not interruptible. `terminate()` kills the +worker immediately, rejects every outstanding promise with `NecRuntimeError`, +and leaves `state === "disposed"`. `dispose()` asks the worker to destroy the +native handle, then terminates the thread. Both are idempotent. + +## Verification + +Node tests cover transferable result buffers, serialized per-model queues, +client-thread heartbeats during outstanding work, independent models, error +revival, and termination. When WASM artifacts are present, a real +`worker_threads` integration compares Z matrices and far fields with direct +mode at the native-to-WASM bulk tolerance of `1e-12`. diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index 9c672889..b0559ee3 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp5", + "version": "0.0.0-wp6", "private": true, "type": "module", "description": "TypeScript facade for the NEC2++ WebAssembly engine", diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 0a75c141..99697c54 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -19,6 +19,7 @@ export type { ComplexVector, ConductivityLoad, CreateNecModelOptions, + CreateNecWorkerModelOptions, DeckResult, DistributedParallelRlcLoad, DistributedSeriesRlcLoad, @@ -35,6 +36,10 @@ export type { LoadDefinition, NecModel, NecModelState, + NecWorkerModel, + NecWorkerOperation, + NecWorkerProgressEvent, + NecWorkerProgressListener, ParallelRlcLoad, PerfectGround, PortDefinition, diff --git a/packages/necpp-wasm/src/node-worker-threads.d.ts b/packages/necpp-wasm/src/node-worker-threads.d.ts new file mode 100644 index 00000000..e1ee03d9 --- /dev/null +++ b/packages/necpp-wasm/src/node-worker-threads.d.ts @@ -0,0 +1,20 @@ +declare module "node:worker_threads" { + export class Worker { + constructor(filename: string | URL, options?: { type?: "module" }); + postMessage(value: unknown, transferList?: readonly ArrayBuffer[]): void; + terminate(): Promise; + on(event: "message", listener: (value: unknown) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "exit", listener: (code: number) => void): this; + off(event: "message", listener: (value: unknown) => void): this; + off(event: "error", listener: (error: Error) => void): this; + off(event: "exit", listener: (code: number) => void): this; + } + + export interface MessagePort { + postMessage(value: unknown, transferList?: readonly ArrayBuffer[]): void; + on(event: "message", listener: (value: unknown) => void): this; + } + + export const parentPort: MessagePort | null; +} diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts index 8058095d..d4e57f96 100644 --- a/packages/necpp-wasm/src/types.ts +++ b/packages/necpp-wasm/src/types.ts @@ -249,3 +249,65 @@ export interface NecModel { /** Idempotent. After disposal, every operation except `state` and `dispose` fails. */ dispose(): void; } + +/** Coarse worker-boundary operation names, including worker-only `create`. */ +export type NecWorkerOperation = + | "create" + | "addWire" + | "completeGeometry" + | "definePorts" + | "addLoad" + | "clearLoads" + | "setGround" + | "prepare" + | "computeImpedanceMatrix" + | "solveVoltages" + | "solveCurrents" + | "computeFarField" + | "computeEmbeddedFarFields" + | "dispose"; + +export interface NecWorkerProgressEvent { + readonly operation: NecWorkerOperation; + readonly phase: "start" | "complete"; +} + +export type NecWorkerProgressListener = (event: NecWorkerProgressEvent) => void; + +export interface CreateNecWorkerModelOptions extends CreateNecModelOptions { + /** Invoked on the client thread at worker operation start and completion. */ + readonly onProgress?: NecWorkerProgressListener; +} + +/** + * Worker-backed model. Methods are asynchronous, serialized per instance, and + * otherwise match `NecModel`. `terminate()` is the cancellation mechanism. + */ +export interface NecWorkerModel { + readonly state: NecModelState; + + addWire(wire: WireDefinition): Promise; + completeGeometry(options?: CompleteGeometryOptions): Promise; + definePorts(ports: readonly PortDefinition[]): Promise; + addLoad(load: LoadDefinition): Promise; + clearLoads(): Promise; + setGround(ground: GroundModel): Promise; + prepare(options: PrepareOptions): Promise; + computeImpedanceMatrix(): Promise; + solveVoltages(voltages: ComplexVector): Promise; + solveCurrents(currents: ComplexVector): Promise; + computeFarField(request: FarFieldRequest): Promise; + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise; + /** Idempotent. Disposes the native model, then releases the worker thread. */ + dispose(): Promise; + /** + * Immediately kills the worker and rejects outstanding operations. + * Idempotent. The `state` getter afterwards is `"disposed"`. + */ + terminate(): void; + /** Register a progress listener. Returns an unsubscribe function. */ + subscribeProgress(listener: NecWorkerProgressListener): () => void; +} diff --git a/packages/necpp-wasm/src/worker-client.ts b/packages/necpp-wasm/src/worker-client.ts new file mode 100644 index 00000000..d97ca756 --- /dev/null +++ b/packages/necpp-wasm/src/worker-client.ts @@ -0,0 +1,418 @@ +import { NecInputError, NecRuntimeError, NecStateError } from "./errors.js"; +import type { + CompleteGeometryOptions, + ComplexVector, + CreateNecWorkerModelOptions, + EmbeddedFarFieldResult, + EmbeddedFieldNormalization, + FarFieldRequest, + FarFieldResult, + GroundModel, + ImpedanceResult, + LoadDefinition, + NecModelState, + NecWorkerModel, + NecWorkerProgressListener, + PortDefinition, + PortSolution, + PrepareOptions, + WireDefinition, +} from "./types.js"; +import { + cloneFloat64, + isNodeRuntime, + isWorkerResponse, + reviveEmbeddedFarFieldResult, + reviveError, + reviveFarFieldResult, + reviveImpedanceResult, + revivePortSolution, + serializeCreateOptions, + type SerializedCreateOptions, + type WorkerMethod, +} from "./worker-protocol.js"; + +export interface WorkerHost { + postMessage(data: unknown, transfer?: readonly ArrayBuffer[]): void; + subscribe(listener: (data: unknown) => void): () => void; + subscribeError(listener: (error: unknown) => void): () => void; + terminate(): void; +} + +interface PendingRequest { + readonly resolve: (value: unknown) => void; + readonly reject: (error: unknown) => void; +} + +function createWebWorker(): Worker { + return new Worker(new URL("./worker-entry.js", import.meta.url), { + type: "module", + }); +} + +async function openWorkerHost(): Promise { + if (isNodeRuntime()) { + const { Worker: NodeWorker } = await import("node:worker_threads"); + const worker = new NodeWorker( + new URL("./worker-entry.js", import.meta.url), + { type: "module" }, + ); + return { + postMessage(data, transfer = []) { + worker.postMessage(data, transfer); + }, + subscribe(listener) { + const handler = (value: unknown): void => { + listener(value); + }; + worker.on("message", handler); + return () => { + worker.off("message", handler); + }; + }, + subscribeError(listener) { + const handler = (error: Error): void => { + listener(error); + }; + worker.on("error", handler); + return () => { + worker.off("error", handler); + }; + }, + terminate() { + void worker.terminate(); + }, + }; + } + + const worker = createWebWorker(); + return { + postMessage(data, transfer = []) { + worker.postMessage(data, transfer as Transferable[]); + }, + subscribe(listener) { + const handler = (event: MessageEvent): void => { + listener(event.data); + }; + worker.addEventListener("message", handler); + return () => { + worker.removeEventListener("message", handler); + }; + }, + subscribeError(listener) { + const handler = (event: ErrorEvent): void => { + listener(event.error ?? event.message); + }; + worker.addEventListener("error", handler); + return () => { + worker.removeEventListener("error", handler); + }; + }, + terminate() { + worker.terminate(); + }, + }; +} + +function cloneComplexVector(vector: ComplexVector): { + readonly vector: ComplexVector; + readonly transfer: ArrayBuffer[]; +} { + const real = cloneFloat64(vector.real); + const imag = cloneFloat64(vector.imag); + return { + vector: { real, imag }, + transfer: [real.buffer, imag.buffer], + }; +} + +class WorkerNecModel implements NecWorkerModel { + readonly #host: WorkerHost; + #state: NecModelState = "empty"; + #terminated = false; + #nextId = 1; + #tail: Promise = Promise.resolve(); + readonly #pending = new Map(); + readonly #listeners = new Set(); + readonly #unsubscribeMessage: () => void; + readonly #unsubscribeError: () => void; + + constructor(host: WorkerHost, onProgress?: NecWorkerProgressListener) { + this.#host = host; + if (onProgress !== undefined) { + this.#listeners.add(onProgress); + } + this.#unsubscribeMessage = host.subscribe((data) => { + this.#onMessage(data); + }); + this.#unsubscribeError = host.subscribeError((error) => { + this.#failAll( + new NecRuntimeError("The NEC worker failed", { cause: error }), + ); + }); + } + + get state(): NecModelState { + return this.#state; + } + + subscribeProgress(listener: NecWorkerProgressListener): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + async initialize(options?: CreateNecWorkerModelOptions): Promise { + const serialized = serializeCreateOptions(options); + await this.#request( + serialized.payload === undefined + ? { kind: "create" } + : { kind: "create", options: serialized.payload }, + serialized.transfer, + ); + } + + addWire(wire: WireDefinition): Promise { + return this.#invokeVoid("addWire", [wire]); + } + + completeGeometry(options?: CompleteGeometryOptions): Promise { + return this.#invokeVoid( + "completeGeometry", + options === undefined ? [] : [options], + ); + } + + definePorts(ports: readonly PortDefinition[]): Promise { + return this.#invokeVoid("definePorts", [ports]); + } + + addLoad(load: LoadDefinition): Promise { + return this.#invokeVoid("addLoad", [load]); + } + + clearLoads(): Promise { + return this.#invokeVoid("clearLoads", []); + } + + setGround(ground: GroundModel): Promise { + return this.#invokeVoid("setGround", [ground]); + } + + prepare(options: PrepareOptions): Promise { + return this.#invokeVoid("prepare", [options]); + } + + async computeImpedanceMatrix(): Promise { + return reviveImpedanceResult(await this.#invoke("computeImpedanceMatrix", [])); + } + + async solveVoltages(voltages: ComplexVector): Promise { + const cloned = cloneComplexVector(voltages); + return revivePortSolution( + await this.#invoke("solveVoltages", [cloned.vector], cloned.transfer), + ); + } + + async solveCurrents(currents: ComplexVector): Promise { + const cloned = cloneComplexVector(currents); + return revivePortSolution( + await this.#invoke("solveCurrents", [cloned.vector], cloned.transfer), + ); + } + + async computeFarField(request: FarFieldRequest): Promise { + return reviveFarFieldResult(await this.#invoke("computeFarField", [request])); + } + + async computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise { + const args = normalization === undefined ? [request] : [request, normalization]; + return reviveEmbeddedFarFieldResult( + await this.#invoke("computeEmbeddedFarFields", args), + ); + } + + async dispose(): Promise { + if (this.#terminated || this.#state === "disposed") { + this.#state = "disposed"; + this.terminate(); + return; + } + try { + await this.#invoke("dispose", []); + } finally { + this.terminate(); + } + } + + terminate(): void { + if (this.#terminated) { + return; + } + this.#terminated = true; + this.#state = "disposed"; + this.#unsubscribeMessage(); + this.#unsubscribeError(); + const error = new NecRuntimeError("The NEC worker was terminated"); + for (const pending of this.#pending.values()) { + pending.reject(error); + } + this.#pending.clear(); + try { + this.#host.terminate(); + } catch { + // Termination is best-effort once outstanding work has been rejected. + } + } + + #notify(listenerEvent: { operation: WorkerMethod | "create"; phase: "start" | "complete" }): void { + for (const listener of [...this.#listeners]) { + try { + listener(listenerEvent); + } catch { + // Progress listeners must not break the worker client. + } + } + } + + #failAll(error: NecRuntimeError): void { + this.#terminated = true; + this.#state = "disposed"; + for (const pending of this.#pending.values()) { + pending.reject(error); + } + this.#pending.clear(); + try { + this.#host.terminate(); + } catch { + // The host is already failing. + } + } + + #onMessage(data: unknown): void { + if (!isWorkerResponse(data)) { + this.#failAll( + new NecRuntimeError("The NEC worker returned a malformed message"), + ); + return; + } + if (data.kind === "progress") { + this.#notify(data); + return; + } + if (data.kind === "crash") { + this.#failAll(new NecRuntimeError( + data.error.message, + data.error.details === undefined ? {} : { details: data.error.details }, + )); + return; + } + const pending = this.#pending.get(data.id); + if (pending === undefined) { + return; + } + this.#pending.delete(data.id); + if (data.state !== undefined) { + this.#state = data.state; + } + if (data.kind === "ok") { + pending.resolve(data.result); + return; + } + pending.reject(reviveError(data.error)); + } + + #assertCallable(method: WorkerMethod | "create"): void { + if (this.#terminated || this.#state === "disposed") { + if (method === "dispose") { + return; + } + throw new NecStateError(method, "disposed"); + } + } + + #request( + body: + | { readonly kind: "create"; readonly options?: SerializedCreateOptions } + | { + readonly kind: "invoke"; + readonly method: WorkerMethod; + readonly args: readonly unknown[]; + }, + transfer: readonly ArrayBuffer[] = [], + ): Promise { + this.#assertCallable( + body.kind === "create" ? "create" : body.method, + ); + const run = this.#tail.then(() => { + if (this.#terminated) { + throw new NecRuntimeError("The NEC worker was terminated"); + } + return new Promise((resolve, reject) => { + const id = this.#nextId; + this.#nextId += 1; + this.#pending.set(id, { resolve, reject }); + try { + this.#host.postMessage({ ...body, id }, transfer); + } catch (cause) { + this.#pending.delete(id); + reject(new NecRuntimeError("Failed to post a NEC worker request", { + cause, + })); + } + }); + }); + this.#tail = run.then(() => undefined, () => undefined); + return run; + } + + #invokeVoid( + method: WorkerMethod, + args: readonly unknown[], + transfer: readonly ArrayBuffer[] = [], + ): Promise { + return this.#invoke(method, args, transfer).then(() => undefined); + } + + async #invoke( + method: WorkerMethod, + args: readonly unknown[], + transfer: readonly ArrayBuffer[] = [], + ): Promise { + if (method !== "dispose") { + this.#assertCallable(method); + } else if (this.#terminated || this.#state === "disposed") { + return undefined; + } + return this.#request({ kind: "invoke", method, args }, transfer); + } +} + +export async function createNecWorkerModelFromHost( + host: WorkerHost, + options?: CreateNecWorkerModelOptions, +): Promise { + const model = new WorkerNecModel(host, options?.onProgress); + try { + await model.initialize(options); + return model; + } catch (error) { + model.terminate(); + throw error; + } +} + +/** Create a stateful NEC model that runs inside a dedicated worker. */ +export async function createNecWorkerModel( + options?: CreateNecWorkerModelOptions, +): Promise { + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw new NecInputError("WASM loading options must be an object"); + } + const host = await openWorkerHost(); + return createNecWorkerModelFromHost(host, options); +} diff --git a/packages/necpp-wasm/src/worker-entry.ts b/packages/necpp-wasm/src/worker-entry.ts new file mode 100644 index 00000000..36f56168 --- /dev/null +++ b/packages/necpp-wasm/src/worker-entry.ts @@ -0,0 +1,120 @@ +import { createNecModel } from "./index.js"; +import { NecRuntimeError } from "./errors.js"; +import { + isNodeRuntime, + isWorkerResponse, + serializeError, + type WorkerRequest, + type WorkerResponse, +} from "./worker-protocol.js"; +import { handleWorkerRequest, type WorkerSession } from "./worker-runtime.js"; + +interface WorkerParent { + postMessage(value: unknown, transfer?: readonly ArrayBuffer[]): void; + onMessage(listener: (value: unknown) => void): void; +} + +async function connectParent(): Promise { + if (typeof document !== "undefined") { + throw new NecRuntimeError("NEC worker entry must run inside a worker"); + } + if (isNodeRuntime()) { + const { parentPort } = await import("node:worker_threads"); + if (parentPort === null) { + throw new NecRuntimeError("NEC worker entry must run inside a worker"); + } + return { + postMessage(value, transfer = []) { + parentPort.postMessage(value, transfer); + }, + onMessage(listener) { + parentPort.on("message", listener); + }, + }; + } + + const scope = globalThis as typeof globalThis & { + postMessage: (message: unknown, transfer?: Transferable[]) => void; + addEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; + }; + if (typeof scope.postMessage !== "function") { + throw new NecRuntimeError("NEC worker entry must run inside a worker"); + } + return { + postMessage(value, transfer = []) { + scope.postMessage(value, transfer as Transferable[]); + }, + onMessage(listener) { + scope.addEventListener("message", (event) => { + listener(event.data); + }); + }, + }; +} + +function isWorkerRequest(value: unknown): value is WorkerRequest { + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as { id?: unknown; kind?: unknown }; + return typeof record.id === "number" + && (record.kind === "create" || record.kind === "invoke"); +} + +const parent = await connectParent(); +const session: WorkerSession = { model: undefined }; +let queue: Promise = Promise.resolve(); + +function post(response: WorkerResponse, transfer: readonly ArrayBuffer[] = []): void { + parent.postMessage(response, transfer); +} + +parent.onMessage((value) => { + queue = queue.then(async () => { + if (!isWorkerRequest(value)) { + post({ + kind: "crash", + error: serializeError( + new NecRuntimeError("The NEC worker received a malformed request"), + ), + }); + return; + } + try { + const { response, transfer } = await handleWorkerRequest( + session, + value, + { + createModel: createNecModel, + emitProgress(event) { + post({ + kind: "progress", + operation: event.operation, + phase: event.phase, + }); + }, + }, + ); + if (!isWorkerResponse(response)) { + post({ + id: value.id, + kind: "error", + error: serializeError( + new NecRuntimeError("The NEC worker produced an invalid response"), + ), + }); + return; + } + post(response, transfer); + } catch (error) { + post({ + id: value.id, + kind: "error", + error: serializeError(error), + }); + } + }, () => undefined); +}); diff --git a/packages/necpp-wasm/src/worker-protocol.ts b/packages/necpp-wasm/src/worker-protocol.ts new file mode 100644 index 00000000..9ad0acf9 --- /dev/null +++ b/packages/necpp-wasm/src/worker-protocol.ts @@ -0,0 +1,384 @@ +import { + NecConditioningError, + NecError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, + NecStateError, + type NecErrorCode, + type NecErrorOptions, +} from "./errors.js"; +import type { + CreateNecModelOptions, + CreateNecWorkerModelOptions, + EmbeddedFarFieldResult, + FarFieldResult, + ImpedanceResult, + NecModelState, + NecWorkerOperation, + PortDefinition, + PortSolution, +} from "./types.js"; + +export function isNodeRuntime(): boolean { + const runtime = globalThis as typeof globalThis & { + process?: { versions?: { node?: string } }; + window?: unknown; + }; + return typeof runtime.process === "object" + && runtime.process !== null + && typeof runtime.process.versions === "object" + && runtime.process.versions !== null + && typeof runtime.process.versions.node === "string" + && typeof runtime.window === "undefined"; +} + +export type WorkerMethod = Exclude; + +export interface SerializedCreateOptions { + readonly wasmUrl?: string; + readonly wasmBinary?: ArrayBuffer; +} + +export type WorkerRequest = + | { + readonly id: number; + readonly kind: "create"; + readonly options?: SerializedCreateOptions; + } + | { + readonly id: number; + readonly kind: "invoke"; + readonly method: WorkerMethod; + readonly args: readonly unknown[]; + }; + +export interface SerializedNecError { + readonly name: string; + readonly code: NecErrorCode; + readonly message: string; + readonly details?: Readonly>; + readonly operation?: string; + readonly state?: NecModelState; +} + +export type WorkerProgressMessage = { + readonly kind: "progress"; + readonly operation: NecWorkerOperation; + readonly phase: "start" | "complete"; +}; + +export type WorkerResponse = + | { + readonly id: number; + readonly kind: "ok"; + readonly state: NecModelState; + readonly result?: unknown; + readonly transferredBufferCount: number; + } + | { + readonly id: number; + readonly kind: "error"; + readonly state?: NecModelState; + readonly error: SerializedNecError; + } + | { + readonly kind: "crash"; + readonly error: SerializedNecError; + } + | WorkerProgressMessage; + +export function isWorkerResponse(value: unknown): value is WorkerResponse { + if (typeof value !== "object" || value === null) { + return false; + } + const kind = (value as { kind?: unknown }).kind; + return kind === "ok" + || kind === "error" + || kind === "crash" + || kind === "progress"; +} + +function isArrayBuffer(value: unknown): value is ArrayBuffer { + return Object.prototype.toString.call(value) === "[object ArrayBuffer]"; +} + +function visitTransferables(value: unknown, buffers: Set): void { + if (value === null || value === undefined) { + return; + } + if (isArrayBuffer(value)) { + if (value.byteLength > 0) { + buffers.add(value); + } + return; + } + if (ArrayBuffer.isView(value)) { + const buffer = value.buffer; + if (isArrayBuffer(buffer) && buffer.byteLength > 0) { + buffers.add(buffer); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) { + visitTransferables(item, buffers); + } + return; + } + if (typeof value === "object") { + for (const item of Object.values(value)) { + visitTransferables(item, buffers); + } + } +} + +/** Collect unique, non-empty ArrayBuffers owned by a structured-cloneable result. */ +export function collectTransferables(value: unknown): ArrayBuffer[] { + const buffers = new Set(); + visitTransferables(value, buffers); + return [...buffers]; +} + +function errorOptions( + details: Readonly> | undefined, +): NecErrorOptions { + return details === undefined ? {} : { details }; +} + +export function serializeError(error: unknown): SerializedNecError { + if (error instanceof NecStateError) { + const serialized: SerializedNecError = { + name: error.name, + code: error.code, + message: error.message, + operation: error.operation, + state: error.state, + }; + return error.details === undefined + ? serialized + : { ...serialized, details: error.details }; + } + if (error instanceof NecError) { + const serialized: SerializedNecError = { + name: error.name, + code: error.code, + message: error.message, + }; + return error.details === undefined + ? serialized + : { ...serialized, details: error.details }; + } + if (error instanceof Error) { + return { + name: "NecRuntimeError", + code: "NEC_RUNTIME", + message: error.message, + }; + } + return { + name: "NecRuntimeError", + code: "NEC_RUNTIME", + message: String(error), + }; +} + +export function reviveError(serialized: SerializedNecError): NecError { + switch (serialized.code) { + case "NEC_STATE": + return new NecStateError( + serialized.operation ?? "unknown", + serialized.state ?? "disposed", + serialized.message, + ); + case "NEC_INPUT": + return new NecInputError(serialized.message, errorOptions(serialized.details)); + case "NEC_GEOMETRY": + return new NecGeometryError( + serialized.message, + errorOptions(serialized.details), + ); + case "NEC_PORT": + return new NecPortError(serialized.message, errorOptions(serialized.details)); + case "NEC_CONDITIONING": + return new NecConditioningError( + serialized.message, + errorOptions(serialized.details), + ); + case "NEC_SOLVER": + return new NecSolverError(serialized.message, errorOptions(serialized.details)); + default: + return new NecRuntimeError( + serialized.message, + errorOptions(serialized.details), + ); + } +} + +export function snapshotPorts( + ports: readonly PortDefinition[], +): readonly PortDefinition[] { + return Object.freeze(ports.map((port) => Object.freeze( + port.name === undefined + ? { tag: port.tag, segment: port.segment } + : { tag: port.tag, segment: port.segment, name: port.name }, + ))); +} + +function copyFloat64(value: unknown, name: string): Float64Array { + if (Object.prototype.toString.call(value) !== "[object Float64Array]") { + throw new NecRuntimeError(`Worker result ${name} is not a Float64Array`); + } + return value as Float64Array; +} + +export function revivePortSolution(value: unknown): PortSolution { + const record = value as PortSolution; + return { + drive: record.drive, + frequencyMHz: record.frequencyMHz, + ports: snapshotPorts(record.ports), + requested: { + real: copyFloat64(record.requested.real, "requested.real"), + imag: copyFloat64(record.requested.imag, "requested.imag"), + }, + voltages: { + real: copyFloat64(record.voltages.real, "voltages.real"), + imag: copyFloat64(record.voltages.imag, "voltages.imag"), + }, + currents: { + real: copyFloat64(record.currents.real, "currents.real"), + imag: copyFloat64(record.currents.imag, "currents.imag"), + }, + activeImpedances: { + real: copyFloat64(record.activeImpedances.real, "activeImpedances.real"), + imag: copyFloat64(record.activeImpedances.imag, "activeImpedances.imag"), + }, + powersW: copyFloat64(record.powersW, "powersW"), + factorizationGeneration: record.factorizationGeneration, + solveGeneration: record.solveGeneration, + }; +} + +export function reviveImpedanceResult(value: unknown): ImpedanceResult { + const record = value as ImpedanceResult; + const result: ImpedanceResult = { + impedance: { + rows: record.impedance.rows, + columns: record.impedance.columns, + order: "row-major", + real: copyFloat64(record.impedance.real, "impedance.real"), + imag: copyFloat64(record.impedance.imag, "impedance.imag"), + }, + admittance: { + rows: record.admittance.rows, + columns: record.admittance.columns, + order: "row-major", + real: copyFloat64(record.admittance.real, "admittance.real"), + imag: copyFloat64(record.admittance.imag, "admittance.imag"), + }, + frequencyMHz: record.frequencyMHz, + factorizationGeneration: record.factorizationGeneration, + }; + if (record.conditionEstimate !== undefined) { + return { ...result, conditionEstimate: record.conditionEstimate }; + } + return result; +} + +export function reviveFarFieldResult(value: unknown): FarFieldResult { + const record = value as FarFieldResult; + return { + radiusM: record.radiusM, + frequencyMHz: record.frequencyMHz, + thetaDeg: copyFloat64(record.thetaDeg, "thetaDeg"), + phiDeg: copyFloat64(record.phiDeg, "phiDeg"), + eThetaReal: copyFloat64(record.eThetaReal, "eThetaReal"), + eThetaImag: copyFloat64(record.eThetaImag, "eThetaImag"), + ePhiReal: copyFloat64(record.ePhiReal, "ePhiReal"), + ePhiImag: copyFloat64(record.ePhiImag, "ePhiImag"), + }; +} + +export function reviveEmbeddedFarFieldResult( + value: unknown, +): EmbeddedFarFieldResult { + const record = value as EmbeddedFarFieldResult; + const field = reviveFarFieldResult(record); + const normalization = record.normalization.kind === "unit-current" + ? Object.freeze({ kind: "unit-current" as const, valueA: 1 as const }) + : Object.freeze({ kind: "unit-voltage" as const, valueV: 1 as const }); + return { + ...field, + ports: snapshotPorts(record.ports), + normalization, + samplesPerPort: record.samplesPerPort, + }; +} + +export function cloneFloat64(source: Float64Array): Float64Array { + return new Float64Array(source); +} + +export function serializeCreateOptions( + options: CreateNecWorkerModelOptions | undefined, +): { + readonly payload?: SerializedCreateOptions; + readonly transfer: ArrayBuffer[]; +} { + if (options === undefined) { + return { transfer: [] }; + } + const payload: { + wasmUrl?: string; + wasmBinary?: ArrayBuffer; + } = {}; + const transfer: ArrayBuffer[] = []; + + if (options.wasmUrl !== undefined && options.wasmBinary !== undefined) { + throw new NecInputError("wasmUrl and wasmBinary cannot both be supplied"); + } + + if (options.wasmUrl !== undefined) { + payload.wasmUrl = options.wasmUrl instanceof URL + ? options.wasmUrl.href + : options.wasmUrl; + } + + if (options.wasmBinary !== undefined) { + let bytes: Uint8Array; + try { + bytes = options.wasmBinary instanceof Uint8Array + ? options.wasmBinary.slice() + : new Uint8Array(options.wasmBinary.slice(0)); + } catch (cause) { + throw new NecInputError("wasmBinary must reference readable WASM bytes", { + cause, + }); + } + payload.wasmBinary = bytes.buffer; + transfer.push(bytes.buffer); + } + + if (payload.wasmUrl === undefined && payload.wasmBinary === undefined) { + return { transfer }; + } + return { payload, transfer }; +} + +export function toCreateNecModelOptions( + options: SerializedCreateOptions | undefined, +): CreateNecModelOptions | undefined { + if (options === undefined) { + return undefined; + } + if (options.wasmBinary !== undefined) { + return { wasmBinary: options.wasmBinary }; + } + if (options.wasmUrl !== undefined) { + return { wasmUrl: options.wasmUrl }; + } + return undefined; +} diff --git a/packages/necpp-wasm/src/worker-runtime.ts b/packages/necpp-wasm/src/worker-runtime.ts new file mode 100644 index 00000000..01e80faf --- /dev/null +++ b/packages/necpp-wasm/src/worker-runtime.ts @@ -0,0 +1,194 @@ +import { NecRuntimeError, NecStateError } from "./errors.js"; +import type { + CompleteGeometryOptions, + ComplexVector, + CreateNecModelOptions, + EmbeddedFieldNormalization, + FarFieldRequest, + GroundModel, + LoadDefinition, + NecModel, + NecModelState, + NecWorkerProgressEvent, + PortDefinition, + PrepareOptions, + WireDefinition, +} from "./types.js"; +import { + collectTransferables, + serializeError, + toCreateNecModelOptions, + type WorkerMethod, + type WorkerRequest, + type WorkerResponse, +} from "./worker-protocol.js"; + +export interface WorkerSession { + model: NecModel | undefined; +} + +export type ModelFactory = ( + options?: CreateNecModelOptions, +) => Promise; + +export interface WorkerRuntimeDependencies { + readonly createModel: ModelFactory; + readonly emitProgress: (event: NecWorkerProgressEvent) => void; +} + +function invokeModel( + model: NecModel, + method: WorkerMethod, + args: readonly unknown[], +): unknown { + switch (method) { + case "addWire": + model.addWire(args[0] as WireDefinition); + return undefined; + case "completeGeometry": + model.completeGeometry(args[0] as CompleteGeometryOptions | undefined); + return undefined; + case "definePorts": + model.definePorts(args[0] as readonly PortDefinition[]); + return undefined; + case "addLoad": + model.addLoad(args[0] as LoadDefinition); + return undefined; + case "clearLoads": + model.clearLoads(); + return undefined; + case "setGround": + model.setGround(args[0] as GroundModel); + return undefined; + case "prepare": + model.prepare(args[0] as PrepareOptions); + return undefined; + case "computeImpedanceMatrix": + return model.computeImpedanceMatrix(); + case "solveVoltages": + return model.solveVoltages(args[0] as ComplexVector); + case "solveCurrents": + return model.solveCurrents(args[0] as ComplexVector); + case "computeFarField": + return model.computeFarField(args[0] as FarFieldRequest); + case "computeEmbeddedFarFields": + return model.computeEmbeddedFarFields( + args[0] as FarFieldRequest, + args[1] as EmbeddedFieldNormalization | undefined, + ); + case "dispose": + model.dispose(); + return undefined; + default: { + const unexpected: never = method; + throw new NecRuntimeError(`Unsupported worker method ${String(unexpected)}`); + } + } +} + +async function withProgress( + emitProgress: (event: NecWorkerProgressEvent) => void, + operation: NecWorkerProgressEvent["operation"], + body: () => T | Promise, +): Promise { + emitProgress({ operation, phase: "start" }); + try { + return await body(); + } finally { + emitProgress({ operation, phase: "complete" }); + } +} + +export async function handleWorkerRequest( + session: WorkerSession, + request: WorkerRequest, + deps: WorkerRuntimeDependencies, +): Promise<{ response: WorkerResponse; transfer: ArrayBuffer[] }> { + if (request.kind === "create") { + if (session.model !== undefined) { + return { + response: { + id: request.id, + kind: "error", + state: session.model.state, + error: serializeError( + new NecRuntimeError("The worker model has already been created"), + ), + }, + transfer: [], + }; + } + try { + const model = await withProgress( + deps.emitProgress, + "create", + () => deps.createModel(toCreateNecModelOptions(request.options)), + ); + session.model = model; + return { + response: { + id: request.id, + kind: "ok", + state: model.state, + transferredBufferCount: 0, + }, + transfer: [], + }; + } catch (error) { + return { + response: { + id: request.id, + kind: "error", + error: serializeError(error), + }, + transfer: [], + }; + } + } + + const model = session.model; + if (model === undefined) { + return { + response: { + id: request.id, + kind: "error", + error: serializeError( + new NecStateError(request.method, "disposed", "The worker model is not created"), + ), + }, + transfer: [], + }; + } + + try { + const result = await withProgress( + deps.emitProgress, + request.method, + () => invokeModel(model, request.method, request.args), + ); + if (request.method === "dispose") { + session.model = undefined; + } + const transfer = collectTransferables(result); + return { + response: { + id: request.id, + kind: "ok", + state: request.method === "dispose" ? "disposed" : model.state, + result, + transferredBufferCount: transfer.length, + }, + transfer, + }; + } catch (error) { + return { + response: { + id: request.id, + kind: "error", + state: model.state, + error: serializeError(error), + }, + transfer: [], + }; + } +} diff --git a/packages/necpp-wasm/src/worker.ts b/packages/necpp-wasm/src/worker.ts new file mode 100644 index 00000000..90cc44e5 --- /dev/null +++ b/packages/necpp-wasm/src/worker.ts @@ -0,0 +1,51 @@ +export { + NecConditioningError, + NecError, + NecGeometryError, + NecInputError, + NecPortError, + NecRuntimeError, + NecSolverError, + NecStateError, +} from "./errors.js"; + +export type { NecErrorCode, NecErrorOptions } from "./errors.js"; + +export { createNecWorkerModel } from "./worker-client.js"; + +export type { + AngleSweep, + CartesianPointM, + CompleteGeometryOptions, + ComplexMatrix, + ComplexVector, + ConductivityLoad, + CreateNecModelOptions, + CreateNecWorkerModelOptions, + DistributedParallelRlcLoad, + DistributedSeriesRlcLoad, + EmbeddedFarFieldResult, + EmbeddedFieldNormalization, + FarFieldRequest, + FarFieldResult, + FiniteGround, + FreeSpaceGround, + GroundConnection, + GroundModel, + ImpedanceLoad, + ImpedanceResult, + LoadDefinition, + NecModelState, + NecWorkerModel, + NecWorkerOperation, + NecWorkerProgressEvent, + NecWorkerProgressListener, + ParallelRlcLoad, + PerfectGround, + PortDefinition, + PortSolution, + PrepareOptions, + SegmentSelection, + SeriesRlcLoad, + WireDefinition, +} from "./types.js"; diff --git a/packages/necpp-wasm/test-d/worker-api.test.ts b/packages/necpp-wasm/test-d/worker-api.test.ts new file mode 100644 index 00000000..b3d8c07a --- /dev/null +++ b/packages/necpp-wasm/test-d/worker-api.test.ts @@ -0,0 +1,75 @@ +import { + NecStateError, + createNecWorkerModel, + type ComplexMatrix, + type FarFieldResult, + type NecWorkerProgressEvent, + type PortSolution, +} from "../src/worker.js"; + +async function validWorkerConsumer(): Promise { + const events: NecWorkerProgressEvent[] = []; + const model = await createNecWorkerModel({ + onProgress(event) { + events.push(event); + }, + }); + + await model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); + await model.prepare({ frequencyMHz: 300 }); + + const matrices: ComplexMatrix = (await model.computeImpedanceMatrix()).impedance; + const solution: PortSolution = await model.solveCurrents({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + const field: FarFieldResult = await model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 3, stepDeg: 45 }, + phi: { startDeg: 0, count: 2, stepDeg: 90 }, + }); + + matrices.real[0]; + solution.currents.imag[0]; + field.eThetaReal[0]; + events[0]?.operation; + + const unsubscribe = model.subscribeProgress(() => undefined); + unsubscribe(); + await model.dispose(); + model.terminate(); +} + +void validWorkerConsumer; + +async function intentionallyInvalidWorkerConsumer(): Promise { + const model = await createNecWorkerModel(); + + // @ts-expect-error worker methods return promises, not void. + const added: void = model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + + // @ts-expect-error worker models do not expose a raw WASM handle. + model.handle; + + // @ts-expect-error progress callbacks are not a createNecModel option mix-in on the model. + model.onProgress; +} + +void intentionallyInvalidWorkerConsumer; + +const stateError = new NecStateError("computeImpedanceMatrix", "disposed"); +stateError.code satisfies "NEC_STATE"; diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs new file mode 100644 index 00000000..6c2734da --- /dev/null +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -0,0 +1,407 @@ +import assert from "node:assert/strict"; +import { MessageChannel } from "node:worker_threads"; +import test from "node:test"; + +import { NecRuntimeError, NecStateError } from "../.test-build/src/errors.js"; +import { createNecWorkerModelFromHost } from "../.test-build/src/worker-client.js"; +import { handleWorkerRequest } from "../.test-build/src/worker-runtime.js"; + +const dipoleWire = { + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, +}; + +function snapshotPorts(ports) { + return Object.freeze(ports.map((port) => Object.freeze({ ...port }))); +} + +function createFakeModel(overrides = {}) { + let state = "empty"; + const ports = []; + const model = { + get state() { + return state; + }, + addWire() { + state = "geometry-building"; + }, + completeGeometry() { + state = "geometry-complete"; + }, + definePorts(nextPorts) { + ports.splice(0, ports.length, ...snapshotPorts(nextPorts)); + }, + addLoad() {}, + clearLoads() {}, + setGround() {}, + prepare() { + state = "prepared"; + }, + computeImpedanceMatrix() { + return { + impedance: { + rows: 1, + columns: 1, + order: "row-major", + real: new Float64Array([73.1]), + imag: new Float64Array([42.5]), + }, + admittance: { + rows: 1, + columns: 1, + order: "row-major", + real: new Float64Array([0.01]), + imag: new Float64Array([-0.005]), + }, + conditionEstimate: 1.5, + frequencyMHz: 300, + factorizationGeneration: 1, + }; + }, + solveVoltages(voltages) { + state = "solved"; + return { + drive: "voltage", + frequencyMHz: 300, + ports: snapshotPorts(ports), + requested: { + real: new Float64Array(voltages.real), + imag: new Float64Array(voltages.imag), + }, + voltages: { + real: new Float64Array(voltages.real), + imag: new Float64Array(voltages.imag), + }, + currents: { + real: new Float64Array([0.01]), + imag: new Float64Array([0]), + }, + activeImpedances: { + real: new Float64Array([73.1]), + imag: new Float64Array([42.5]), + }, + powersW: new Float64Array([0.005]), + factorizationGeneration: 1, + solveGeneration: 1, + }; + }, + solveCurrents() { + state = "solved"; + return model.solveVoltages({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + }, + computeFarField() { + const samples = 2_048; + return { + radiusM: 1, + frequencyMHz: 300, + thetaDeg: new Float64Array([0, 90]), + phiDeg: new Float64Array([0]), + eThetaReal: new Float64Array(samples).fill(1.25), + eThetaImag: new Float64Array(samples).fill(-0.5), + ePhiReal: new Float64Array(samples), + ePhiImag: new Float64Array(samples), + }; + }, + computeEmbeddedFarFields() { + return { + ...model.computeFarField(), + ports: snapshotPorts(ports), + normalization: { kind: "unit-voltage", valueV: 1 }, + samplesPerPort: 2_048, + }; + }, + dispose() { + state = "disposed"; + }, + ...overrides, + }; + return model; +} + +function createLoopbackHost(createModel, options = {}) { + const { port1, port2 } = new MessageChannel(); + const session = { model: undefined }; + let queue = Promise.resolve(); + const calls = []; + + port2.on("message", (request) => { + calls.push(request); + queue = queue.then(async () => { + if (options.hang?.filter?.(request) === true) { + await options.hang.gate; + } + const { response, transfer } = await handleWorkerRequest(session, request, { + createModel, + emitProgress(event) { + port2.postMessage({ + kind: "progress", + operation: event.operation, + phase: event.phase, + }); + }, + }); + port2.postMessage(response, transfer); + }); + }); + + return { + calls, + postMessage(data, transfer = []) { + port1.postMessage(data, transfer); + }, + subscribe(listener) { + const handler = (value) => listener(value); + port1.on("message", handler); + return () => port1.off("message", handler); + }, + subscribeError() { + return () => undefined; + }, + terminate() { + port1.close(); + port2.close(); + }, + }; +} + +async function preparedModel(createModel = () => Promise.resolve(createFakeModel())) { + const host = createLoopbackHost(createModel); + const model = await createNecWorkerModelFromHost(host); + await model.addWire(dipoleWire); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); + await model.prepare({ frequencyMHz: 300 }); + return { host, model }; +} + +test("the worker runtime preserves state across serialized requests", async () => { + const session = { model: undefined }; + const fake = createFakeModel(); + const progress = []; + const deps = { + createModel: async () => fake, + emitProgress(event) { + progress.push(`${event.operation}:${event.phase}`); + }, + }; + + const created = await handleWorkerRequest(session, { id: 1, kind: "create" }, deps); + assert.equal(created.response.kind, "ok"); + assert.equal(session.model, fake); + + await handleWorkerRequest(session, { + id: 2, + kind: "invoke", + method: "addWire", + args: [dipoleWire], + }, deps); + assert.equal(fake.state, "geometry-building"); + + await handleWorkerRequest(session, { + id: 3, + kind: "invoke", + method: "completeGeometry", + args: [], + }, deps); + assert.equal(fake.state, "geometry-complete"); + assert.ok(progress.includes("create:start")); + assert.ok(progress.includes("addWire:complete")); +}); + +test("worker client serializes operations, reports progress, and transfers fields", async () => { + const progress = []; + const { model } = await preparedModel(); + const unsubscribe = model.subscribeProgress((event) => { + progress.push(`${event.operation}:${event.phase}`); + }); + + const matrices = await model.computeImpedanceMatrix(); + assert.equal(matrices.impedance.real[0], 73.1); + assert.equal(matrices.conditionEstimate, 1.5); + + const voltages = { + real: new Float64Array([1]), + imag: new Float64Array([0]), + }; + const solution = await model.solveVoltages(voltages); + assert.equal(voltages.real.buffer.byteLength, 8); + assert.equal(solution.ports[0].name, "feed"); + assert.throws(() => { + solution.ports[0].name = "mutated"; + }, TypeError); + assert.equal(solution.ports[0].name, "feed"); + + const field = await model.computeFarField({ + theta: { startDeg: 0, count: 2, stepDeg: 90 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + assert.equal(field.eThetaReal.length, 2_048); + assert.equal(field.eThetaReal[0], 1.25); + assert.equal(field.eThetaReal.buffer.byteLength, 2_048 * 8); + + unsubscribe(); + await model.dispose(); + assert.equal(model.state, "disposed"); + assert.ok(progress.includes("computeImpedanceMatrix:start")); + assert.ok(progress.includes("computeFarField:complete")); +}); + +test("queued worker operations stay serialized per model", async () => { + const order = []; + let releasePrepare; + const hang = { + filter: (request) => request.method === "prepare", + gate: new Promise((resolve) => { + releasePrepare = resolve; + }), + }; + const fake = createFakeModel(); + const originalPrepare = fake.prepare.bind(fake); + const originalMatrix = fake.computeImpedanceMatrix.bind(fake); + fake.prepare = () => { + order.push("prepare"); + originalPrepare(); + }; + fake.computeImpedanceMatrix = () => { + order.push("matrix"); + return originalMatrix(); + }; + const host = createLoopbackHost(async () => fake, { hang }); + const model = await createNecWorkerModelFromHost(host); + await model.addWire(dipoleWire); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + + const prepare = model.prepare({ frequencyMHz: 300 }); + const matrix = model.computeImpedanceMatrix(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, []); + releasePrepare(); + await prepare; + await matrix; + assert.deepEqual(order, ["prepare", "matrix"]); + await model.dispose(); +}); + +test("the client thread keeps a heartbeat while a worker request is outstanding", async () => { + let release; + const hang = { + filter: (request) => request.method === "computeImpedanceMatrix", + gate: new Promise((resolve) => { + release = resolve; + }), + }; + const host = createLoopbackHost(async () => createFakeModel(), { hang }); + const model = await createNecWorkerModelFromHost(host); + await model.addWire(dipoleWire); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + await model.prepare({ frequencyMHz: 300 }); + + const pending = model.computeImpedanceMatrix(); + let ticks = 0; + const timer = setInterval(() => { + ticks += 1; + }, 5); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.ok(ticks > 0, "the client event loop must continue during a worker calculation"); + release(); + await pending; + clearInterval(timer); + await model.dispose(); +}); + +test("termination releases the worker and rejects outstanding operations", async () => { + let release; + const hang = { + filter: (request) => request.method === "computeFarField", + gate: new Promise((resolve) => { + release = resolve; + }), + }; + const host = createLoopbackHost(async () => createFakeModel(), { hang }); + const model = await createNecWorkerModelFromHost(host); + await model.addWire(dipoleWire); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + await model.prepare({ frequencyMHz: 300 }); + + const first = model.computeFarField({ + theta: { startDeg: 0, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + const second = model.computeFarField({ + theta: { startDeg: 0, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + model.terminate(); + await assert.rejects(first, (error) => ( + error instanceof NecRuntimeError && error.message.includes("terminated") + )); + await assert.rejects(second, NecRuntimeError); + assert.equal(model.state, "disposed"); + await assert.rejects(model.prepare({ frequencyMHz: 300 }), NecStateError); + model.terminate(); + release(); +}); + +test("two worker models run independently", async () => { + const firstFake = createFakeModel({ + computeImpedanceMatrix() { + return { + ...createFakeModel().computeImpedanceMatrix(), + impedance: { + rows: 1, + columns: 1, + order: "row-major", + real: new Float64Array([11]), + imag: new Float64Array([0]), + }, + }; + }, + }); + const secondFake = createFakeModel({ + computeImpedanceMatrix() { + return { + ...createFakeModel().computeImpedanceMatrix(), + impedance: { + rows: 1, + columns: 1, + order: "row-major", + real: new Float64Array([22]), + imag: new Float64Array([0]), + }, + }; + }, + }); + const first = await createNecWorkerModelFromHost( + createLoopbackHost(async () => firstFake), + ); + const second = await createNecWorkerModelFromHost( + createLoopbackHost(async () => secondFake), + ); + await first.addWire(dipoleWire); + await second.addWire(dipoleWire); + await first.completeGeometry(); + await second.completeGeometry(); + await first.definePorts([{ tag: 1, segment: 6 }]); + await second.definePorts([{ tag: 1, segment: 6 }]); + await first.prepare({ frequencyMHz: 150 }); + await second.prepare({ frequencyMHz: 300 }); + + const [firstZ, secondZ] = await Promise.all([ + first.computeImpedanceMatrix(), + second.computeImpedanceMatrix(), + ]); + assert.equal(firstZ.impedance.real[0], 11); + assert.equal(secondZ.impedance.real[0], 22); + + await first.dispose(); + await second.dispose(); +}); diff --git a/packages/necpp-wasm/test/worker-integration.test.mjs b/packages/necpp-wasm/test/worker-integration.test.mjs new file mode 100644 index 00000000..4c3bb407 --- /dev/null +++ b/packages/necpp-wasm/test/worker-integration.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +import { createNecModel } from "../.test-build/src/index.js"; +import { createNecWorkerModel } from "../.test-build/src/worker.js"; + +const generatedLoader = new URL( + "../.test-build/src/nec2pp.generated.js", + import.meta.url, +); +const wasmUrl = new URL("../.test-build/src/nec2pp.wasm", import.meta.url); +const hasWasm = existsSync(generatedLoader) && existsSync(wasmUrl); + +const dipole = { + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, +}; + +const farFieldRequest = { + radiusM: 1, + theta: { startDeg: 0, count: 5, stepDeg: 45 }, + phi: { startDeg: 0, count: 3, stepDeg: 90 }, +}; + +function relativeError(left, right) { + let numerator = 0; + let leftNorm = 0; + let rightNorm = 0; + for (let index = 0; index < left.length; index += 1) { + const delta = left[index] - right[index]; + numerator += delta * delta; + leftNorm += left[index] * left[index]; + rightNorm += right[index] * right[index]; + } + return Math.sqrt(numerator) / Math.max(1, Math.sqrt(leftNorm), Math.sqrt(rightNorm)); +} + +async function buildDipole(model) { + await Promise.resolve(model.addWire(dipole)); + await Promise.resolve(model.completeGeometry()); + await Promise.resolve(model.definePorts([{ tag: 1, segment: 6, name: "feed" }])); + await Promise.resolve(model.prepare({ frequencyMHz: 300 })); +} + +test("worker Z matrices and fields match direct-mode results", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const direct = await createNecModel(); + const worker = await createNecWorkerModel(); + try { + buildDipole(direct); + await buildDipole(worker); + + const directZ = direct.computeImpedanceMatrix(); + const workerZ = await worker.computeImpedanceMatrix(); + assert.equal(workerZ.impedance.order, "row-major"); + assert.ok(relativeError(directZ.impedance.real, workerZ.impedance.real) <= 1e-12); + assert.ok(relativeError(directZ.impedance.imag, workerZ.impedance.imag) <= 1e-12); + assert.ok(relativeError(directZ.admittance.real, workerZ.admittance.real) <= 1e-12); + assert.ok(relativeError(directZ.admittance.imag, workerZ.admittance.imag) <= 1e-12); + + const voltages = { + real: new Float64Array([1]), + imag: new Float64Array([0]), + }; + direct.solveVoltages(voltages); + await worker.solveVoltages(voltages); + + const directField = direct.computeFarField(farFieldRequest); + const workerField = await worker.computeFarField(farFieldRequest); + assert.equal(workerField.radiusM, directField.radiusM); + assert.deepEqual([...workerField.thetaDeg], [...directField.thetaDeg]); + assert.ok(relativeError(directField.eThetaReal, workerField.eThetaReal) <= 1e-12); + assert.ok(relativeError(directField.ePhiImag, workerField.ePhiImag) <= 1e-12); + } finally { + direct.dispose(); + await worker.dispose(); + } +}); + +test("two real worker models remain isolated", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const first = await createNecWorkerModel(); + const second = await createNecWorkerModel(); + try { + await buildDipole(first); + await buildDipole(second); + await first.prepare({ frequencyMHz: 150 }); + await second.prepare({ frequencyMHz: 300 }); + + const [firstZ, secondZ] = await Promise.all([ + first.computeImpedanceMatrix(), + second.computeImpedanceMatrix(), + ]); + assert.equal(firstZ.frequencyMHz, 150); + assert.equal(secondZ.frequencyMHz, 300); + assert.notEqual(firstZ.impedance.real[0], secondZ.impedance.real[0]); + } finally { + await first.dispose(); + await second.dispose(); + } +}); + +test("terminating a real worker rejects outstanding work", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const model = await createNecWorkerModel(); + await buildDipole(model); + const pending = model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 37, stepDeg: 5 }, + phi: { startDeg: 0, count: 73, stepDeg: 5 }, + }); + model.terminate(); + await assert.rejects(pending, (error) => error.message.includes("terminated")); + assert.equal(model.state, "disposed"); +}); diff --git a/packages/necpp-wasm/test/worker-protocol.test.mjs b/packages/necpp-wasm/test/worker-protocol.test.mjs new file mode 100644 index 00000000..98209e52 --- /dev/null +++ b/packages/necpp-wasm/test/worker-protocol.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { MessageChannel } from "node:worker_threads"; +import test from "node:test"; + +import { + NecInputError, + NecRuntimeError, + NecStateError, +} from "../.test-build/src/errors.js"; +import { + collectTransferables, + reviveError, + serializeCreateOptions, + serializeError, +} from "../.test-build/src/worker-protocol.js"; + +test("collectTransferables gathers unique typed-array buffers", () => { + const real = new Float64Array([1, 2, 3]); + const imag = new Float64Array([4, 5, 6]); + const shared = new Float64Array([7]); + const result = { + impedance: { real, imag }, + extra: shared, + again: shared, + }; + const buffers = collectTransferables(result); + assert.equal(buffers.length, 3); + assert.ok(buffers.includes(real.buffer)); + assert.ok(buffers.includes(imag.buffer)); + assert.ok(buffers.includes(shared.buffer)); +}); + +test("transferred result buffers are detached rather than duplicated", async () => { + const field = { + eThetaReal: new Float64Array(1_024).fill(3.5), + eThetaImag: new Float64Array(1_024).fill(-1.25), + }; + const original = field.eThetaReal[0]; + const transfer = collectTransferables(field); + assert.equal(transfer.length, 2); + + const { port1, port2 } = new MessageChannel(); + const received = new Promise((resolve) => { + port2.once("message", resolve); + }); + port1.postMessage(field, transfer); + const copy = await received; + port1.close(); + port2.close(); + + assert.equal(copy.eThetaReal[0], original); + assert.equal(copy.eThetaImag[0], -1.25); + assert.equal(field.eThetaReal.buffer.byteLength, 0); + assert.equal(field.eThetaImag.buffer.byteLength, 0); +}); + +test("typed errors round-trip through the worker protocol", () => { + const state = reviveError(serializeError( + new NecStateError("prepare", "empty"), + )); + assert.ok(state instanceof NecStateError); + assert.equal(state.code, "NEC_STATE"); + assert.equal(state.operation, "prepare"); + assert.equal(state.state, "empty"); + + const runtime = reviveError(serializeError(new Error("boom"))); + assert.ok(runtime instanceof NecRuntimeError); + assert.equal(runtime.message, "boom"); +}); + +test("create options copy wasm bytes and reject mixed overrides", () => { + const bytes = new Uint8Array([1, 2, 3]); + const serialized = serializeCreateOptions({ wasmBinary: bytes }); + assert.ok(serialized.payload.wasmBinary instanceof ArrayBuffer); + assert.equal(serialized.transfer.length, 1); + bytes[0] = 9; + assert.equal(new Uint8Array(serialized.payload.wasmBinary)[0], 1); + + assert.throws( + () => serializeCreateOptions({ + wasmUrl: "https://example.test/nec2pp.wasm", + wasmBinary: bytes, + }), + NecInputError, + ); +}); From a08086f54fa6c85f80e2eb7c65999dd2e1dbe5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 13:54:09 +0200 Subject: [PATCH 23/46] WP7 --- .gitignore | 3 + docs/local-build-environment.md | 12 + docs/ts_engine_plan.md | 29 +- docs/wasm-api.md | 19 +- docs/wp7-npm-package.md | 73 ++++ packages/necpp-wasm/README.md | 134 ++++++++ packages/necpp-wasm/package-lock.json | 7 +- packages/necpp-wasm/package.json | 37 ++- packages/necpp-wasm/scripts/build-dist.mjs | 126 +++++++ packages/necpp-wasm/src/index.ts | 2 + packages/necpp-wasm/src/loader.ts | 84 ++++- packages/necpp-wasm/src/versions.ts | 10 + packages/necpp-wasm/src/worker-entry.ts | 96 +++--- packages/necpp-wasm/src/worker.ts | 2 + packages/necpp-wasm/test-d/public-api.test.ts | 7 + packages/necpp-wasm/test-d/worker-api.test.ts | 7 + .../necpp-wasm/test/facade-runtime.test.mjs | 43 +++ .../necpp-wasm/test/pack/consumer.test.mjs | 313 ++++++++++++++++++ packages/necpp-wasm/test/pack/helpers.mjs | 302 +++++++++++++++++ .../necpp-wasm/test/pack/manifest.test.mjs | 44 +++ packages/necpp-wasm/tsconfig.dist.json | 13 + 21 files changed, 1298 insertions(+), 65 deletions(-) create mode 100644 docs/wp7-npm-package.md create mode 100644 packages/necpp-wasm/README.md create mode 100644 packages/necpp-wasm/scripts/build-dist.mjs create mode 100644 packages/necpp-wasm/src/versions.ts create mode 100644 packages/necpp-wasm/test/pack/consumer.test.mjs create mode 100644 packages/necpp-wasm/test/pack/helpers.mjs create mode 100644 packages/necpp-wasm/test/pack/manifest.test.mjs create mode 100644 packages/necpp-wasm/tsconfig.dist.json diff --git a/.gitignore b/.gitignore index 73b697e9..2dab5e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ /packages/necpp-wasm/src/nec2pp.generated.js /packages/necpp-wasm/src/nec2pp.wasm /packages/necpp-wasm/.test-build/ +/packages/necpp-wasm/dist/ +/packages/necpp-wasm/COPYING +/packages/necpp-wasm/.pack-work/ # Legacy test binary name (pre-CMake era); kept ignored in case of stale trees. src/necpp_test src/test_manager diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 4e9ec110..8cf80b68 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -155,3 +155,15 @@ The WP6 worker facade was verified on the same host with TypeScript 5.8.3 and Node ESM: all 24 package tests passed, including transferable result buffers, client-thread heartbeats during outstanding work, independent worker models, termination, and real WASM Z-matrix/far-field agreement with direct mode. + +The WP7 package assembly is packed with `npm pack` from +`packages/necpp-wasm`. Clean-consumer tests install that tarball in a +temporary fixture, import `@necpp/wasm` and `@necpp/wasm/worker` by name, and +build a Vite app. Run them after a WASM build: + +```powershell +npm --prefix packages/necpp-wasm run test:wasm +``` + +`test:wasm` includes the facade suite and the packed-tarball tests. The +tarball contains `dist/`, `README.md`, and `COPYING` only. diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 4bce2dba..5a3977ba 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -645,6 +645,8 @@ subpath is WP8. ## WP7 — npm package assembly +**Status: complete (2026-08-28).** + Recommended layout: ```text @@ -729,6 +731,29 @@ DoD: - Package version, engine version and ABI version are exposed and documented. - `COPYING` and license metadata are included. Because the engine is GPL-2.0-or-later, downstream distribution implications must be clearly documented and reviewed for the intended product. +### WP7 progress + +- Assembled `@necpp/wasm` as an ESM package with `exports` for `.` and + `./worker`, a `dist/` emit of the handwritten facade, and the generated + `nec2pp.generated.js` plus `nec2pp.wasm` copied beside it. `prepack` builds + that tree, copies `COPYING`, and rejects source maps, oversize artifacts, + and version drift against `package.json` and CMake. +- Exported `packageVersion`, `engineVersion`, and `abiVersion`. Module + instantiation checks the native ABI and engine strings. HTTP(S) `wasmUrl` + values are fetched into a copied `wasmBinary` so CDN loading works in Node. +- Added clean-consumer tests that `npm pack`, install the tarball in a + temporary fixture, import the package by name, solve a dipole in direct and + worker mode, load WASM from an HTTP URL, and build a Vite app that emits + the worker and serves `.wasm` as `application/wasm`. Those tests never + import workspace `src/` or `.test-build`. Direct mode needs no bundler + config; the Vite worker fixture sets `worker: { format: "es" }` and + `build.target: "es2022"` because the package ships an ES2022 module worker. +- Documented GPL-2.0-or-later distribution implications in the package + README and [`docs/wp7-npm-package.md`](wp7-npm-package.md). The package + remains `private` until the `necpp` npm scope is available. + +The next open package on the critical path is WP8. + --- ## WP8 — CI and release pipeline @@ -829,4 +854,6 @@ The package is ready when all of the following are true: - The exact packed tarball passes clean-consumer tests before publication. - Versioning, licensing, release artifacts and documentation are complete. -The critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. Worker support, CI expansion and documentation can proceed once the TypeScript facade stabilizes, but they remain release requirements for a genuinely browser-ready package. +The completed critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. +Remaining release work is WP8 (CI and release pipeline) and WP9 (documentation +and example application). diff --git a/docs/wasm-api.md b/docs/wasm-api.md index b011b924..5951a230 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,9 +1,9 @@ # `@necpp/wasm` API and numerical contract -Status: normative specification, updated through WP6 on 2026-08-28. The +Status: normative specification, updated through WP7 on 2026-08-28. The stateful native layer, versioned C/WASM ABI, handwritten TypeScript facade, -and optional Web Worker entry point are implemented. The committed TypeScript -surface is in [`packages/necpp-wasm/src`](../packages/necpp-wasm/src). +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). ## Package and runtime boundary @@ -13,6 +13,16 @@ while the scoped name identifies this repository and leaves room for future `@ne packages. Publication requires control of the `necpp` npm scope, but the API name will not change if the package is initially distributed as a tarball. +The packed package exports three version identifiers that can be imported +without constructing a model: + +- `packageVersion` — npm version of this TypeScript API +- `engineVersion` — NEC2++ version compiled into the shipped WASM +- `abiVersion` — stable C ABI (`necpp_wasm_v1`), currently `1` + +The facade refuses to instantiate a binary whose ABI or engine version does +not match those constants. + `createNecModel()` asynchronously initializes the Emscripten module and returns a stateful `NecModel`. After creation, those model methods are synchronous. Large browser calculations should use `createNecWorkerModel()` @@ -234,6 +244,9 @@ new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" }) ``` so bundlers can rewrite the worker URL without extra consumer configuration. +WP7 packs this subpath. Direct mode needs no bundler config. Vite apps that +import the worker set `worker: { format: "es" }` because the package ships a +module worker. Browser CI for the worker subpath is WP8. ## Canonical test models diff --git a/docs/wp7-npm-package.md b/docs/wp7-npm-package.md new file mode 100644 index 00000000..727c57bf --- /dev/null +++ b/docs/wp7-npm-package.md @@ -0,0 +1,73 @@ +# WP7 npm package assembly + +WP7 turns the handwritten TypeScript facade and generated WASM into a +packable ESM package named `@necpp/wasm`. Consumers install a tarball (or a +future npm publish) and import the documented entry points. They do not copy +artifacts, author a worker bootstrap, or depend on this repository's source +tree. + +## Layout + +```text +packages/necpp-wasm/ + package.json + README.md + COPYING (copied from the repository root at pack time) + src/ + dist/ + index.js + index.d.ts + worker.js + worker.d.ts + worker-entry.js + nec2pp.generated.js + nec2pp.wasm +``` + +`package.json` is `"type": "module"` with encapsulated `exports` for `.` and +`./worker`. The `files` allowlist is `dist`, `README.md`, and `COPYING`. The +package remains `private` until the `necpp` npm scope is available; +`npm pack` is the distribution path. + +`prepack` runs `scripts/build-dist.mjs`, which compiles `src/` to `dist/`, +copies the generated loader and WASM, copies `COPYING`, and rejects source +maps, debug symbols, a WASM binary at or above 1 MiB, or a loader at or above +200 KiB. `packageVersion` must match `package.json`; `engineVersion` must +match the CMake project version. + +## Loading and versions + +The packed loader resolves WASM with `new URL("./nec2pp.wasm", import.meta.url)` +and still accepts `wasmUrl` or `wasmBinary`. HTTP(S) `wasmUrl` values are +downloaded into a copied `wasmBinary` so CDN URLs work in Node as well as +browsers. + +The public API exports `packageVersion`, `engineVersion`, and `abiVersion`. +Instantiation fails if the native ABI or engine string does not match. + +## Clean-consumer tests + +Package tests never import workspace `src/` or `.test-build`. They: + +1. `npm pack` the assembled package +2. install the `.tgz` into a temporary fixture +3. `import { createNecModel } from "@necpp/wasm"` by name +4. solve the centre-fed dipole +5. import `@necpp/wasm/worker` and repeat +6. load WASM from an HTTP `wasmUrl` +7. build a Vite fixture, confirm the worker is bundled, and fetch the + emitted `.wasm` with `Content-Type: application/wasm` + +Direct `createNecModel()` needs no bundler config. Vite apps that import the +worker subpath set `worker: { format: "es" }` and `build.target: "es2022"`, +which match the module worker and ES2022 syntax the package ships. + +The fixture's resolved module path must contain `node_modules/@necpp/wasm` +and must not contain `packages/necpp-wasm/src`. + +## License + +The engine and package are GPL-2.0-or-later. The package README states that +distributing the loader or WASM is distribution of GPL software and that +downstream products must be reviewed against those obligations. `COPYING` is +included in the tarball. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md new file mode 100644 index 00000000..b4bc9c99 --- /dev/null +++ b/packages/necpp-wasm/README.md @@ -0,0 +1,134 @@ +# `@necpp/wasm` + +Stateful NEC2++ antenna solver for Node and the browser. Consumers import a +high-level TypeScript API; they never copy WASM artifacts, parse NEC reports, +or touch Emscripten handles. + +## License + +This package is **GPL-2.0-or-later**, the same license as NEC2++. Shipping the +JavaScript loader or `nec2pp.wasm` binary to users is distribution of GPL +software. A product that includes this package must comply with GPL-2.0 or +later, including the obligation to provide corresponding source. Review those +obligations for the intended product before shipping. The full license text is +in `COPYING`. + +Publication to the public npm registry requires control of the `necpp` scope. +Until then, install the packed tarball produced by `npm pack`. + +## Install + +From a packed tarball: + +```bash +npm install ./necpp-wasm-0.0.0-wp7.tgz +``` + +The package is ESM-only (`"type": "module"`). Node 18.19 or later is required. + +## Quick start + +```ts +import { + abiVersion, + createNecModel, + engineVersion, + packageVersion, +} from "@necpp/wasm"; + +console.log({ packageVersion, engineVersion, abiVersion }); + +const model = await createNecModel(); + +try { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + model.definePorts([{ tag: 1, segment: 6 }]); + model.prepare({ frequencyMHz: 300 }); + + const impedance = model.computeImpedanceMatrix(); + const solution = model.solveCurrents({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + const field = model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 181, stepDeg: 1 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + }); + + void impedance; + void solution; + void field; +} finally { + model.dispose(); +} +``` + +Geometry is metres, frequency is MHz, port current is positive into the +antenna, and far fields are complex V/m. The numerical contract is +[`docs/wasm-api.md`](../../docs/wasm-api.md). + +## Versions + +| Identifier | Meaning | +|---|---| +| `packageVersion` | npm package version of this TypeScript API | +| `engineVersion` | NEC2++ version compiled into the shipped WASM | +| `abiVersion` | Stable C ABI (`necpp_wasm_v1`); currently `1` | + +The facade refuses to instantiate a binary whose ABI or engine version does +not match these constants. + +## Loading WASM + +By default the adjacent `nec2pp.wasm` is resolved with: + +```ts +new URL("./nec2pp.wasm", import.meta.url) +``` + +Overrides: + +- `wasmUrl` — file URL, HTTP(S) CDN URL, or path resolved against the package +- `wasmBinary` — caller-owned `ArrayBuffer` or `Uint8Array` (copied) + +Bundlers such as Vite rewrite the default `import.meta.url` resolution; no +extra consumer config or artifact copying is required for `createNecModel()`. +Apps that import `@necpp/wasm/worker` and bundle with Vite should set: + +```js +export default { + build: { target: "es2022" }, + worker: { format: "es" }, +}; +``` + +`worker.format` is Vite's setting for `{ type: "module" }` workers. `build.target` +must support the ES2022 syntax used by the package. Direct `createNecModel()` +still needs no application source changes and no artifact copying. + +## Worker entry + +Large browser solves should use the worker subpath: + +```ts +import { createNecWorkerModel } from "@necpp/wasm/worker"; + +const model = await createNecWorkerModel(); +``` + +The package ships the worker script. Methods are asynchronous, serialized per +model, and otherwise match `NecModel`. `terminate()` is cancellation; +`dispose()` destroys the native handle first. + +## Compatibility deck runner + +`runDeck(deck)` executes a complete NEC text deck and returns the formatted +report. It is independent of `NecModel`. diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index fbe17a98..b0eea84e 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,15 +1,18 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp5", + "version": "0.0.0-wp7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp/wasm", - "version": "0.0.0-wp5", + "version": "0.0.0-wp7", "license": "GPL-2.0-or-later", "devDependencies": { "typescript": "5.8.3" + }, + "engines": { + "node": ">=18.19" } }, "node_modules/typescript": { diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index b0559ee3..a2b43a2a 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,14 +1,45 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp6", + "version": "0.0.0-wp7", "private": true, "type": "module", - "description": "TypeScript facade for the NEC2++ WebAssembly engine", + "description": "Stateful NEC2++ electromagnetic solver for Node and the browser", "license": "GPL-2.0-or-later", + "repository": { + "type": "git", + "url": "git+https://github.com/tmolteno/necpp.git", + "directory": "packages/necpp-wasm" + }, + "homepage": "https://github.com/tmolteno/necpp", + "engines": { + "node": ">=18.19" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "import": "./dist/worker.js", + "default": "./dist/worker.js" + } + }, + "files": [ + "dist", + "README.md", + "COPYING" + ], "scripts": { + "build": "node scripts/build-dist.mjs", + "prepack": "npm run build", "build:test": "node test/build.mjs", "test": "npm run build:test && node --test test/*.test.mjs && npm run typecheck", - "test:wasm": "node test/require-wasm.mjs && npm test", + "test:wasm": "node test/require-wasm.mjs && npm test && npm run test:pack", + "test:pack": "node test/require-wasm.mjs && node --test --test-timeout=180000 --test-concurrency=1 test/pack/manifest.test.mjs test/pack/consumer.test.mjs", "typecheck": "tsc --project tsconfig.json" }, "devDependencies": { diff --git a/packages/necpp-wasm/scripts/build-dist.mjs b/packages/necpp-wasm/scripts/build-dist.mjs new file mode 100644 index 00000000..0791acf3 --- /dev/null +++ b/packages/necpp-wasm/scripts/build-dist.mjs @@ -0,0 +1,126 @@ +import { + copyFileSync, + existsSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const sourceDirectory = join(packageDirectory, "src"); +const distDirectory = join(packageDirectory, "dist"); +const localCompiler = join(packageDirectory, "node_modules", "typescript", "bin", "tsc"); + +const packageJson = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), +); +const versionsSource = readFileSync(join(sourceDirectory, "versions.ts"), "utf8"); +const expectedPackageVersion = `packageVersion = "${packageJson.version}"`; +if (!versionsSource.includes(expectedPackageVersion)) { + throw new Error( + `src/versions.ts must export ${expectedPackageVersion} to match package.json`, + ); +} + +const cmakeLists = readFileSync(join(repositoryRoot, "CMakeLists.txt"), "utf8"); +const cmakeVersion = cmakeLists.match(/project\(\s*necpp\s+VERSION\s+([0-9.]+)/); +if (cmakeVersion === null) { + throw new Error("Could not read the CMake project version"); +} +if (!versionsSource.includes(`engineVersion = "${cmakeVersion[1]}"`)) { + throw new Error( + `src/versions.ts engineVersion must match CMake project VERSION ${cmakeVersion[1]}`, + ); +} + +const requiredArtifacts = ["nec2pp.generated.js", "nec2pp.wasm"]; +const missingArtifacts = requiredArtifacts.filter( + (name) => !existsSync(join(sourceDirectory, name)), +); +if (missingArtifacts.length > 0) { + throw new Error( + `Cannot assemble dist without WASM artifacts: ${missingArtifacts.join(", ")}`, + ); +} + +const licenseSource = join(repositoryRoot, "COPYING"); +if (!existsSync(licenseSource)) { + throw new Error(`Missing license file at ${licenseSource}`); +} + +rmSync(distDirectory, { force: true, recursive: true }); + +const compiler = existsSync(localCompiler) ? process.execPath : "tsc"; +const compilerArguments = existsSync(localCompiler) + ? [localCompiler, "--project", "tsconfig.dist.json"] + : ["--project", "tsconfig.dist.json"]; +const result = spawnSync(compiler, compilerArguments, { + cwd: packageDirectory, + stdio: "inherit", +}); +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +for (const name of requiredArtifacts) { + copyFileSync(join(sourceDirectory, name), join(distDirectory, name)); +} +copyFileSync(licenseSource, join(packageDirectory, "COPYING")); + +const requiredDistFiles = [ + "index.js", + "index.d.ts", + "worker.js", + "worker.d.ts", + "worker-entry.js", + "nec2pp.generated.js", + "nec2pp.wasm", +]; +const missingDistFiles = requiredDistFiles.filter( + (name) => !existsSync(join(distDirectory, name)), +); +if (missingDistFiles.length > 0) { + throw new Error(`dist is missing ${missingDistFiles.join(", ")}`); +} + +const disallowed = []; +function walk(directory) { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + if (statSync(path).isDirectory()) { + walk(path); + continue; + } + if (entry.endsWith(".map") || entry.endsWith(".tsbuildinfo")) { + disallowed.push(relative(distDirectory, path)); + } + } +} +walk(distDirectory); +if (disallowed.length > 0) { + throw new Error(`dist contains disallowed debug artifacts: ${disallowed.join(", ")}`); +} + +const wasmBytes = statSync(join(distDirectory, "nec2pp.wasm")).size; +const loaderBytes = statSync(join(distDirectory, "nec2pp.generated.js")).size; +if (wasmBytes >= 1024 * 1024) { + throw new Error(`nec2pp.wasm is ${wasmBytes} bytes; expected under 1 MiB`); +} +if (loaderBytes >= 200 * 1024) { + throw new Error( + `nec2pp.generated.js is ${loaderBytes} bytes; expected under 200 KiB`, + ); +} + +process.stdout.write( + `Assembled ${relative(dirname(packageDirectory), distDirectory)} ` + + `(wasm ${wasmBytes} bytes, loader ${loaderBytes} bytes)\n`, +); diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 99697c54..8da6f350 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -9,6 +9,8 @@ export { NecStateError, } from "./errors.js"; +export { abiVersion, engineVersion, packageVersion } from "./versions.js"; + export type { NecErrorCode, NecErrorOptions } from "./errors.js"; export type { diff --git a/packages/necpp-wasm/src/loader.ts b/packages/necpp-wasm/src/loader.ts index 4e67e1a0..3cdd3ce8 100644 --- a/packages/necpp-wasm/src/loader.ts +++ b/packages/necpp-wasm/src/loader.ts @@ -1,12 +1,14 @@ import { NecInputError, NecRuntimeError } from "./errors.js"; import type { CreateNecModelOptions } from "./types.js"; +import { abiVersion, engineVersion } from "./versions.js"; +import generatedFactory from "./nec2pp.generated.js"; import type { EmscriptenModuleOptions, NecWasmModule, NecWasmModuleFactory, } from "./wasm-internal.js"; -const EXPECTED_ABI_VERSION = 1; +const textDecoder = new TextDecoder(); function copyWasmBinary(binary: ArrayBuffer | Uint8Array): Uint8Array { try { @@ -41,9 +43,33 @@ function resolveWasmUrl(value: string | URL | undefined): string { } } -function moduleOptions( +function isHttpUrl(url: string): boolean { + return url.startsWith("http://") || url.startsWith("https://"); +} + +async function downloadWasmBinary(wasmUrl: string): Promise { + try { + const response = await fetch(wasmUrl); + if (!response.ok) { + throw new NecRuntimeError(`Failed to download WASM from ${wasmUrl}`, { + details: { status: response.status, wasmUrl }, + }); + } + return new Uint8Array(await response.arrayBuffer()); + } catch (error) { + if (error instanceof NecRuntimeError) { + throw error; + } + throw new NecRuntimeError("Failed to download WASM", { + cause: error, + details: { wasmUrl }, + }); + } +} + +async function moduleOptions( options: CreateNecModelOptions | undefined, -): EmscriptenModuleOptions { +): Promise { if (options !== undefined && (typeof options !== "object" || options === null)) { throw new NecInputError("WASM loading options must be an object"); } @@ -56,6 +82,9 @@ function moduleOptions( } const wasmUrl = resolveWasmUrl(options?.wasmUrl); + if (isHttpUrl(wasmUrl)) { + return { wasmBinary: await downloadWasmBinary(wasmUrl) }; + } return { locateFile(path, prefix) { return path.endsWith(".wasm") ? wasmUrl : `${prefix}${path}`; @@ -63,6 +92,21 @@ function moduleOptions( }; } +function decodeCString(module: NecWasmModule, pointer: number): string { + if ( + !Number.isSafeInteger(pointer) + || pointer <= 0 + || pointer >= module.HEAPU8.length + ) { + throw new NecRuntimeError("The native module returned an invalid version string"); + } + const end = module.HEAPU8.indexOf(0, pointer); + if (end < 0) { + throw new NecRuntimeError("The native module returned an unterminated version string"); + } + return textDecoder.decode(module.HEAPU8.slice(pointer, end)); +} + function validateModule(module: NecWasmModule): void { if ( typeof module !== "object" @@ -79,11 +123,32 @@ function validateModule(module: NecWasmModule): void { throw new NecRuntimeError("The loaded Emscripten module has an invalid surface"); } - const abiVersion = module._necpp_wasm_v1_abi_version(); - if (abiVersion !== EXPECTED_ABI_VERSION) { + const nativeAbiVersion = module._necpp_wasm_v1_abi_version(); + if (nativeAbiVersion !== abiVersion) { + throw new NecRuntimeError( + `Unsupported NEC WASM ABI version ${nativeAbiVersion}; expected ${abiVersion}`, + { + details: { + actualAbiVersion: nativeAbiVersion, + expectedAbiVersion: abiVersion, + }, + }, + ); + } + + const nativeEngineVersion = decodeCString( + module, + module._necpp_wasm_v1_engine_version(), + ); + if (nativeEngineVersion !== engineVersion) { throw new NecRuntimeError( - `Unsupported NEC WASM ABI version ${abiVersion}; expected ${EXPECTED_ABI_VERSION}`, - { details: { actualAbiVersion: abiVersion, expectedAbiVersion: EXPECTED_ABI_VERSION } }, + `NEC engine version ${nativeEngineVersion} does not match package engine ${engineVersion}`, + { + details: { + actualEngineVersion: nativeEngineVersion, + expectedEngineVersion: engineVersion, + }, + }, ); } } @@ -93,9 +158,8 @@ export async function instantiateNecModule( factory?: NecWasmModuleFactory, ): Promise { try { - const selectedOptions = moduleOptions(options); - const selectedFactory = factory - ?? (await import("./nec2pp.generated.js")).default; + const selectedOptions = await moduleOptions(options); + const selectedFactory = factory ?? generatedFactory; if (typeof selectedFactory !== "function") { throw new NecRuntimeError( "The generated Emscripten module does not export a default factory", diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts new file mode 100644 index 00000000..61698f53 --- /dev/null +++ b/packages/necpp-wasm/src/versions.ts @@ -0,0 +1,10 @@ +/** + * Public version identifiers for the packed package. + * + * `packageVersion` must match `package.json`. `engineVersion` must match the + * CMake `project(necpp VERSION ...)` value compiled into the shipped WASM. + * `abiVersion` is the stable C ABI prefix `necpp_wasm_v1`. + */ +export const packageVersion = "0.0.0-wp7"; +export const abiVersion = 1; +export const engineVersion = "2.3.4"; diff --git a/packages/necpp-wasm/src/worker-entry.ts b/packages/necpp-wasm/src/worker-entry.ts index 36f56168..27e1a33e 100644 --- a/packages/necpp-wasm/src/worker-entry.ts +++ b/packages/necpp-wasm/src/worker-entry.ts @@ -64,57 +64,61 @@ function isWorkerRequest(value: unknown): value is WorkerRequest { && (record.kind === "create" || record.kind === "invoke"); } -const parent = await connectParent(); -const session: WorkerSession = { model: undefined }; -let queue: Promise = Promise.resolve(); +void startWorker(); -function post(response: WorkerResponse, transfer: readonly ArrayBuffer[] = []): void { - parent.postMessage(response, transfer); -} +async function startWorker(): Promise { + const parent = await connectParent(); + const session: WorkerSession = { model: undefined }; + let queue: Promise = Promise.resolve(); -parent.onMessage((value) => { - queue = queue.then(async () => { - if (!isWorkerRequest(value)) { - post({ - kind: "crash", - error: serializeError( - new NecRuntimeError("The NEC worker received a malformed request"), - ), - }); - return; - } - try { - const { response, transfer } = await handleWorkerRequest( - session, - value, - { - createModel: createNecModel, - emitProgress(event) { - post({ - kind: "progress", - operation: event.operation, - phase: event.phase, - }); - }, - }, - ); - if (!isWorkerResponse(response)) { + function post(response: WorkerResponse, transfer: readonly ArrayBuffer[] = []): void { + parent.postMessage(response, transfer); + } + + parent.onMessage((value) => { + queue = queue.then(async () => { + if (!isWorkerRequest(value)) { post({ - id: value.id, - kind: "error", + kind: "crash", error: serializeError( - new NecRuntimeError("The NEC worker produced an invalid response"), + new NecRuntimeError("The NEC worker received a malformed request"), ), }); return; } - post(response, transfer); - } catch (error) { - post({ - id: value.id, - kind: "error", - error: serializeError(error), - }); - } - }, () => undefined); -}); + try { + const { response, transfer } = await handleWorkerRequest( + session, + value, + { + createModel: createNecModel, + emitProgress(event) { + post({ + kind: "progress", + operation: event.operation, + phase: event.phase, + }); + }, + }, + ); + if (!isWorkerResponse(response)) { + post({ + id: value.id, + kind: "error", + error: serializeError( + new NecRuntimeError("The NEC worker produced an invalid response"), + ), + }); + return; + } + post(response, transfer); + } catch (error) { + post({ + id: value.id, + kind: "error", + error: serializeError(error), + }); + } + }, () => undefined); + }); +} diff --git a/packages/necpp-wasm/src/worker.ts b/packages/necpp-wasm/src/worker.ts index 90cc44e5..a141ec8d 100644 --- a/packages/necpp-wasm/src/worker.ts +++ b/packages/necpp-wasm/src/worker.ts @@ -9,6 +9,8 @@ export { NecStateError, } from "./errors.js"; +export { abiVersion, engineVersion, packageVersion } from "./versions.js"; + export type { NecErrorCode, NecErrorOptions } from "./errors.js"; export { createNecWorkerModel } from "./worker-client.js"; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index a4ecad07..9e0419a7 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -1,6 +1,9 @@ import { NecStateError, + abiVersion, createNecModel, + engineVersion, + packageVersion, runDeck, type ComplexMatrix, type FarFieldResult, @@ -43,6 +46,10 @@ async function validConsumer(): Promise { field.eThetaReal[0]; model.dispose(); + packageVersion satisfies string; + engineVersion satisfies string; + abiVersion satisfies 1; + const deck = await runDeck("CE\nEN\n"); deck.report.toUpperCase(); } diff --git a/packages/necpp-wasm/test-d/worker-api.test.ts b/packages/necpp-wasm/test-d/worker-api.test.ts index b3d8c07a..68c0a7ba 100644 --- a/packages/necpp-wasm/test-d/worker-api.test.ts +++ b/packages/necpp-wasm/test-d/worker-api.test.ts @@ -1,6 +1,9 @@ import { NecStateError, + abiVersion, createNecWorkerModel, + engineVersion, + packageVersion, type ComplexMatrix, type FarFieldResult, type NecWorkerProgressEvent, @@ -46,6 +49,10 @@ async function validWorkerConsumer(): Promise { unsubscribe(); await model.dispose(); model.terminate(); + + packageVersion satisfies string; + engineVersion satisfies string; + abiVersion satisfies 1; } void validWorkerConsumer; diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 963c3a2e..3fee9c1f 100644 --- a/packages/necpp-wasm/test/facade-runtime.test.mjs +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; +import { createServer } from "node:http"; import test from "node:test"; import { NecInputError, NecStateError, + abiVersion, createNecModel, + engineVersion, + packageVersion, runDeck, } from "../.test-build/src/index.js"; @@ -49,6 +53,12 @@ function addDipole(model) { model.prepare({ frequencyMHz: 300 }); } +test("package, engine, and ABI versions are exported", () => { + assert.equal(packageVersion, "0.0.0-wp7"); + assert.equal(engineVersion, "2.3.4"); + assert.equal(abiVersion, 1); +}); + test("loading options reject ambiguous input before module loading", async () => { await assert.rejects( createNecModel({ @@ -181,6 +191,39 @@ test("default, explicit URL, and binary WASM loading behave alike", { binaryModel.dispose(); }); +test("http wasmUrl downloads bytes for a CDN-style origin", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const bytes = readFileSync(wasmUrl); + const server = createServer((request, response) => { + response.writeHead(200, { + "Content-Type": "application/wasm", + "Content-Length": bytes.length, + }); + response.end(bytes); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + try { + const model = await createNecModel({ + wasmUrl: `http://127.0.0.1:${address.port}/nec2pp.wasm`, + }); + assert.equal(model.state, "empty"); + model.dispose(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +}); + test("runDeck returns an owned report and engine version", { skip: !hasWasm && "WASM artifacts have not been built", }, async () => { diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs new file mode 100644 index 00000000..d48d0ed5 --- /dev/null +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { + VITE_VERSION, + cdnDipoleScript, + createCleanFixture, + dipoleScript, + hasWasmArtifacts, + installFixture, + packPackage, + readPackedWasm, + run, + runAsync, + serveWasm, + workerDipoleScript, + writeFixtureFile, +} from "./helpers.mjs"; + +const skip = !hasWasmArtifacts && "WASM artifacts have not been built"; + +function parseJsonLine(stdout) { + return JSON.parse(stdout.trim()); +} + +function collectFiles(root) { + const files = []; + function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + walk(path); + continue; + } + files.push(path); + } + } + walk(root); + return files; +} + +function listenPort() { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("failed to allocate a preview port")); + return; + } + const port = address.port; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on("error", reject); + }); +} + +async function startVitePreview(root) { + const port = await listenPort(); + const viteBin = join(root, "node_modules", "vite", "bin", "vite.js"); + const child = spawn( + process.execPath, + [viteBin, "preview", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], + { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + + let output = ""; + let settled = false; + + const ready = new Promise((resolve, reject) => { + const fail = (reason) => { + if (settled) { + return; + } + settled = true; + reject(reason instanceof Error ? reason : new Error(String(reason))); + }; + const succeed = () => { + if (settled) { + return; + } + settled = true; + resolve(); + }; + const onChunk = (chunk) => { + output += chunk.toString(); + if (output.includes(`http://127.0.0.1:${port}`)) { + succeed(); + } + }; + child.stdout?.on("data", onChunk); + child.stderr?.on("data", onChunk); + child.on("error", fail); + child.on("exit", (code) => { + fail(new Error(`vite preview exited ${code}: ${output}`)); + }); + setTimeout(() => { + fail(new Error(`vite preview did not start:\n${output}`)); + }, 30_000); + }); + + await ready; + return { + origin: `http://127.0.0.1:${port}`, + close() { + child.kill(); + }, + }; +} + +test("a clean Node fixture imports the tarball by name and solves a dipole", { + skip, +}, () => { + const fixture = createCleanFixture("node"); + installFixture(fixture.root); + writeFixtureFile(fixture.root, "dipole.mjs", dipoleScript); + writeFixtureFile(fixture.root, "worker-dipole.mjs", workerDipoleScript); + + const direct = parseJsonLine(run("node", ["dipole.mjs"], { + cwd: fixture.root, + stdio: ["ignore", "pipe", "inherit"], + }).stdout); + assert.equal(direct.packageVersion, "0.0.0-wp7"); + assert.equal(direct.engineVersion, "2.3.4"); + assert.equal(direct.abiVersion, 1); + assert.ok(direct.resistanceOhm > 0); + assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp\/wasm/); + assert.doesNotMatch(direct.resolved, /packages[/\\]necpp-wasm[/\\]src[/\\]/); + + const worker = parseJsonLine( + run("node", ["worker-dipole.mjs"], { + cwd: fixture.root, + stdio: ["ignore", "pipe", "inherit"], + }).stdout, + ); + assert.equal(worker.packageVersion, "0.0.0-wp7"); + assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); +}); + +test("custom wasmUrl loads the binary from an HTTP CDN-style origin", { + skip, +}, async () => { + const fixture = createCleanFixture("cdn"); + installFixture(fixture.root); + writeFixtureFile(fixture.root, "cdn-dipole.mjs", cdnDipoleScript); + packPackage(); + const server = await serveWasm(readPackedWasm()); + try { + const result = parseJsonLine((await runAsync("node", ["cdn-dipole.mjs"], { + cwd: fixture.root, + env: { + ...process.env, + NEC_WASM_URL: server.url, + }, + stdio: ["ignore", "pipe", "inherit"], + })).stdout); + assert.equal(result.wasmUrl, server.url); + assert.ok(result.resistanceOhm > 0); + } finally { + await server.close(); + } +}); + +test("a clean Vite fixture builds, serves WASM with the correct MIME type, and bundles the worker", { + skip, +}, async () => { + const fixture = createCleanFixture("vite"); + writeFixtureFile(fixture.root, "vite.config.js", `export default { + build: { + target: "es2022", + }, + worker: { + format: "es", + }, +}; +`); + writeFixtureFile(fixture.root, "index.html", ` + + + + NEC WASM Vite fixture + + +
loading
+ + + +`); + writeFixtureFile(fixture.root, "main.js", `import { + abiVersion, + createNecModel, + engineVersion, + packageVersion, +} from "@necpp/wasm"; +import { createNecWorkerModel } from "@necpp/wasm/worker"; + +const out = document.getElementById("out"); + +async function runDirect() { + const model = await createNecModel(); + try { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + model.definePorts([{ tag: 1, segment: 6 }]); + model.prepare({ frequencyMHz: 300 }); + const matrices = model.computeImpedanceMatrix(); + return { + abiVersion, + engineVersion, + packageVersion, + resistanceOhm: matrices.impedance.real[0], + }; + } finally { + model.dispose(); + } +} + +async function runWorker() { + const model = await createNecWorkerModel(); + try { + await model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + await model.prepare({ frequencyMHz: 300 }); + const matrices = await model.computeImpedanceMatrix(); + return matrices.impedance.real[0]; + } finally { + await model.dispose(); + } +} + +try { + const direct = await runDirect(); + const workerResistanceOhm = await runWorker(); + const payload = { + ...direct, + workerResistanceOhm, + workerOk: Number.isFinite(workerResistanceOhm), + }; + out.textContent = JSON.stringify(payload); + window.__NEC_RESULT__ = payload; +} catch (error) { + const payload = { error: String(error) }; + out.textContent = JSON.stringify(payload); + window.__NEC_RESULT__ = payload; +} +`); + + installFixture(fixture.root, [`vite@${VITE_VERSION}`]); + const viteBin = join(fixture.root, "node_modules", "vite", "bin", "vite.js"); + run(process.execPath, [viteBin, "build"], { cwd: fixture.root }); + + const builtRoot = join(fixture.root, "dist"); + const builtFiles = collectFiles(builtRoot); + const wasmFiles = builtFiles.filter((path) => path.endsWith(".wasm")); + assert.ok(wasmFiles.length >= 1, "Vite build must emit the WASM binary"); + const builtJs = builtFiles + .filter((path) => path.endsWith(".js")) + .map((path) => readFileSync(path, "utf8")); + assert.ok( + builtJs.some((source) => source.includes("Worker")), + "Vite build must retain the worker constructor", + ); + assert.ok( + builtJs.every((source) => { + return !source.includes("packages/necpp-wasm/src/") + && !source.includes("packages\\\\necpp-wasm\\\\src\\\\"); + }), + "bundled output must not reference the original repository source tree", + ); + + const preview = await startVitePreview(fixture.root); + try { + const htmlResponse = await fetch(`${preview.origin}/`); + assert.equal(htmlResponse.ok, true); + assert.match(await htmlResponse.text(), /NEC WASM Vite fixture/); + + const wasmPath = wasmFiles[0].slice(builtRoot.length).replaceAll("\\", "/"); + const wasmResponse = await fetch(preview.origin + wasmPath); + assert.equal(wasmResponse.ok, true, `WASM asset ${wasmPath} must be served`); + const mime = wasmResponse.headers.get("content-type") ?? ""; + assert.match(mime, /application\/wasm/); + assert.ok((await wasmResponse.arrayBuffer()).byteLength > 0); + } finally { + preview.close(); + } +}); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs new file mode 100644 index 00000000..4d5927f3 --- /dev/null +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -0,0 +1,302 @@ +import { spawn, spawnSync } from "node:child_process"; +import { + createServer, +} from "node:http"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const packageDirectory = fileURLToPath(new URL("../../", import.meta.url)); +export const VITE_VERSION = "6.3.5"; + +const sourceWasm = join(packageDirectory, "src", "nec2pp.wasm"); +const sourceLoader = join(packageDirectory, "src", "nec2pp.generated.js"); + +export const hasWasmArtifacts = existsSync(sourceWasm) && existsSync(sourceLoader); + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? packageDirectory, + encoding: "utf8", + env: options.env ?? process.env, + shell: options.shell ?? command === "npm", + stdio: options.stdio ?? "inherit", + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with status ${result.status}\n` + + `${result.stdout}\n${result.stderr}`, + ); + } + return result; +} + +export function runAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? packageDirectory, + env: options.env ?? process.env, + shell: options.shell ?? command === "npm", + stdio: options.stdio ?? "inherit", + windowsHide: true, + }); + let stdout = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (status) => { + if (status !== 0) { + reject(new Error( + `${command} ${args.join(" ")} failed with status ${status}\n${stdout}`, + )); + return; + } + resolve({ status, stdout }); + }); + }); +} + +export function parseNpmJson(stdout) { + const arrayIndex = stdout.indexOf("["); + const objectIndex = stdout.indexOf("{"); + const start = arrayIndex >= 0 && (objectIndex < 0 || arrayIndex <= objectIndex) + ? arrayIndex + : objectIndex; + if (start < 0) { + throw new Error(`npm output did not contain JSON:\n${stdout}`); + } + return JSON.parse(stdout.slice(start)); +} + +let packedCache; + +export function packPackage() { + if (packedCache !== undefined) { + return packedCache; + } + const workDirectory = mkdtempSync(join(tmpdir(), "necpp-wasm-pack-")); + const result = run("npm", ["pack", "--pack-destination", workDirectory, "--json"], { + stdio: ["ignore", "pipe", "inherit"], + }); + const reports = parseNpmJson(result.stdout); + const report = Array.isArray(reports) ? reports[0] : reports; + const filename = report?.filename; + if (typeof filename !== "string" || filename.length === 0) { + throw new Error(`npm pack did not report a filename:\n${result.stdout}`); + } + const tarball = join(workDirectory, filename); + if (!existsSync(tarball)) { + throw new Error(`npm pack did not write ${tarball}`); + } + packedCache = { + files: (report.files ?? []).map((file) => file.path.replaceAll("\\", "/")), + filename, + tarball, + version: report.version, + workDirectory, + }; + return packedCache; +} + +export function writeFixtureFile(root, relativePath, contents) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +export function createCleanFixture(name) { + const root = mkdtempSync(join(tmpdir(), `necpp-wasm-${name}-`)); + const packed = packPackage(); + const tarballPosix = packed.tarball.replaceAll("\\", "/"); + writeFixtureFile(root, "package.json", `${JSON.stringify({ + name: `necpp-wasm-${name}-fixture`, + private: true, + type: "module", + dependencies: { + "@necpp/wasm": `file:${tarballPosix}`, + }, + }, null, 2)}\n`); + return { packed, root, tarballPosix }; +} + +export function installFixture(root, extraPackages = []) { + const args = ["install", "--ignore-scripts", "--no-fund", "--no-audit"]; + if (extraPackages.length > 0) { + args.push("--save-dev", ...extraPackages); + } + run("npm", args, { cwd: root }); +} + +export const dipoleScript = `import { + abiVersion, + createNecModel, + engineVersion, + packageVersion, +} from "@necpp/wasm"; + +const resolved = import.meta.resolve("@necpp/wasm"); +if (!resolved.includes("node_modules")) { + throw new Error(\`Package did not resolve from node_modules: \${resolved}\`); +} +if ( + resolved.includes("/packages/necpp-wasm/src/") + || resolved.includes("\\\\packages\\\\necpp-wasm\\\\src\\\\") +) { + throw new Error(\`Package resolved to workspace source: \${resolved}\`); +} + +const model = await createNecModel(); +try { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + model.definePorts([{ tag: 1, segment: 6 }]); + model.prepare({ frequencyMHz: 300 }); + const matrices = model.computeImpedanceMatrix(); + if (!(matrices.impedance.real[0] > 0)) { + throw new Error("expected a positive feed resistance"); + } + process.stdout.write(JSON.stringify({ + abiVersion, + engineVersion, + packageVersion, + resistanceOhm: matrices.impedance.real[0], + resolved, + })); +} finally { + model.dispose(); +} +`; + +export const workerDipoleScript = `import { + abiVersion, + createNecWorkerModel, + engineVersion, + packageVersion, +} from "@necpp/wasm/worker"; + +const resolved = import.meta.resolve("@necpp/wasm/worker"); +if (!resolved.includes("node_modules")) { + throw new Error(\`Worker entry did not resolve from node_modules: \${resolved}\`); +} + +const model = await createNecWorkerModel(); +try { + await model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + await model.prepare({ frequencyMHz: 300 }); + const matrices = await model.computeImpedanceMatrix(); + if (!(matrices.impedance.real[0] > 0)) { + throw new Error("expected a positive feed resistance"); + } + process.stdout.write(JSON.stringify({ + abiVersion, + engineVersion, + packageVersion, + resistanceOhm: matrices.impedance.real[0], + resolved, + })); +} finally { + await model.dispose(); +} +`; + +export const cdnDipoleScript = `import { createNecModel } from "@necpp/wasm"; + +const wasmUrl = process.env.NEC_WASM_URL; +if (typeof wasmUrl !== "string" || wasmUrl.length === 0) { + throw new Error("NEC_WASM_URL is required"); +} + +const model = await createNecModel({ wasmUrl }); +try { + model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + model.completeGeometry(); + model.definePorts([{ tag: 1, segment: 6 }]); + model.prepare({ frequencyMHz: 300 }); + const matrices = model.computeImpedanceMatrix(); + if (!(matrices.impedance.real[0] > 0)) { + throw new Error("expected a positive feed resistance"); + } + process.stdout.write(JSON.stringify({ + resistanceOhm: matrices.impedance.real[0], + wasmUrl, + })); +} finally { + model.dispose(); +} +`; + +export function serveWasm(bytes) { + return new Promise((resolve, reject) => { + const server = createServer((request, response) => { + if (request.url !== "/nec2pp.wasm") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/wasm", + "Content-Length": bytes.length, + }); + response.end(bytes); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("failed to bind WASM fixture server")); + return; + } + resolve({ + close() { + return new Promise((closeResolve, closeReject) => { + server.close((error) => { + if (error) { + closeReject(error); + return; + } + closeResolve(); + }); + }); + }, + url: `http://127.0.0.1:${address.port}/nec2pp.wasm`, + }); + }); + server.on("error", reject); + }); +} + +export function readPackedWasm() { + return readFileSync(join(packageDirectory, "dist", "nec2pp.wasm")); +} diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs new file mode 100644 index 00000000..2345add0 --- /dev/null +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hasWasmArtifacts, + packPackage, +} from "./helpers.mjs"; + +const skip = !hasWasmArtifacts && "WASM artifacts have not been built"; + +test("npm pack contains only the documented publish files", { skip }, () => { + const packed = packPackage(); + assert.equal(packed.version, "0.0.0-wp7"); + assert.match(packed.filename, /^necpp-wasm-0\.0\.0-wp7\.tgz$/); + + const files = new Set(packed.files); + const required = [ + "package.json", + "README.md", + "COPYING", + "dist/index.js", + "dist/index.d.ts", + "dist/worker.js", + "dist/worker.d.ts", + "dist/worker-entry.js", + "dist/nec2pp.generated.js", + "dist/nec2pp.wasm", + ]; + for (const path of required) { + assert.ok(files.has(path), `missing ${path} in packed tarball`); + } + + for (const path of files) { + const allowed = path === "package.json" + || path === "README.md" + || path === "COPYING" + || path.startsWith("dist/"); + assert.ok(allowed, `packed unexpected path ${path}`); + assert.equal(path.includes(".."), false); + assert.doesNotMatch(path, /\.map$/); + assert.doesNotMatch(path, /(^|\/)src\//); + assert.doesNotMatch(path, /(^|\/)test\//); + } +}); diff --git a/packages/necpp-wasm/tsconfig.dist.json b/packages/necpp-wasm/tsconfig.dist.json new file mode 100644 index 00000000..2cb3073d --- /dev/null +++ b/packages/necpp-wasm/tsconfig.dist.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": false + }, + "include": ["src/**/*.ts"] +} From 6f2b2830c51fbffffd1d9c91ecffb2dbba8d2b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 14:18:10 +0200 Subject: [PATCH 24/46] WP7 Fixes --- .github/workflows/build.yml | 7 ++++ docs/local-build-environment.md | 11 ++--- docs/ts_engine_plan.md | 12 +++++- docs/wasm-api.md | 1 + docs/wp7-npm-package.md | 7 ++-- packages/necpp-wasm/README.md | 10 ++--- packages/necpp-wasm/package-lock.json | 20 ++++++++- packages/necpp-wasm/package.json | 3 +- packages/necpp-wasm/scripts/build-dist.mjs | 20 +++++---- .../necpp-wasm/src/node-worker-threads.d.ts | 20 --------- packages/necpp-wasm/src/worker-client.ts | 42 +++++++++++++++---- packages/necpp-wasm/src/worker-protocol.ts | 25 +++++++---- packages/necpp-wasm/test/build.mjs | 31 ++++++-------- .../necpp-wasm/test/pack/consumer.test.mjs | 17 ++++++-- packages/necpp-wasm/test/pack/helpers.mjs | 31 +++++++++++--- .../necpp-wasm/test/pack/manifest.test.mjs | 5 +++ .../necpp-wasm/test/worker-client.test.mjs | 35 ++++++++++++++++ .../necpp-wasm/test/worker-protocol.test.mjs | 6 ++- packages/necpp-wasm/tsconfig.json | 4 +- scripts/build_wasm_docker.ps1 | 20 +++++++++ scripts/build_wasm_docker.sh | 9 ++++ scripts/build_wasm_inner.sh | 19 --------- 22 files changed, 244 insertions(+), 111 deletions(-) delete mode 100644 packages/necpp-wasm/src/node-worker-threads.d.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 907f0fb0..143e77eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - name: Build optimized WASM run: ./scripts/build_wasm_docker.sh diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 8cf80b68..7d224d39 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -120,8 +120,9 @@ Emscripten flags manually: .\scripts\build_wasm_docker.ps1 ``` -It uses `emscripten/emsdk:4.0.7`, TypeScript 5.8.3, and a container-local build -directory under `/tmp`. Building on the container filesystem is intentional: +It uses `emscripten/emsdk:4.0.7`, Node 24, TypeScript 5.8.3, and a +container-local build directory under `/tmp`. Building on the container +filesystem is intentional: Emscripten link steps can fail when writing intermediate files directly to a Windows bind mount. Successful artifacts are copied to: @@ -147,16 +148,16 @@ The WP5 implementation was verified with: successfully in the Docker Release build; - Emscripten 4.0.7: the versioned ABI matrix, solve, combined/embedded field, memory-growth, controlled-error, and complete-deck smoke paths passed. -- TypeScript 5.8.3 and Node ESM: the public facade passed strict compilation, +- TypeScript 5.8.3 and Node 24 ESM: the public facade passed strict compilation, real matrix/solve/field operations, copied-result lifetime and disposal checks, default/URL/binary WASM loading, and complete-deck execution. The WP6 worker facade was verified on the same host with TypeScript 5.8.3 and -Node ESM: all 24 package tests passed, including transferable result buffers, +Node 24 ESM: all 24 package tests passed, including transferable result buffers, client-thread heartbeats during outstanding work, independent worker models, termination, and real WASM Z-matrix/far-field agreement with direct mode. -The WP7 package assembly is packed with `npm pack` from +The WP7 package assembly requires Node 24 or later and is packed with `npm pack` from `packages/necpp-wasm`. Clean-consumer tests install that tarball in a temporary fixture, import `@necpp/wasm` and `@necpp/wasm/worker` by name, and build a Vite app. Run them after a WASM build: diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 5a3977ba..8fd12164 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -674,6 +674,9 @@ packages/necpp-wasm/ ```json { "type": "module", + "engines": { + "node": ">=24" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -737,7 +740,8 @@ DoD: `./worker`, a `dist/` emit of the handwritten facade, and the generated `nec2pp.generated.js` plus `nec2pp.wasm` copied beside it. `prepack` builds that tree, copies `COPYING`, and rejects source maps, oversize artifacts, - and version drift against `package.json` and CMake. + and version drift against `package.json` and CMake. Node 24 is the minimum + Node runtime, with Node 24 typings and ES2024 output. - Exported `packageVersion`, `engineVersion`, and `abiVersion`. Module instantiation checks the native ABI and engine strings. HTTP(S) `wasmUrl` values are fetched into a copied `wasmBinary` so CDN loading works in Node. @@ -747,7 +751,7 @@ DoD: the worker and serves `.wasm` as `application/wasm`. Those tests never import workspace `src/` or `.test-build`. Direct mode needs no bundler config; the Vite worker fixture sets `worker: { format: "es" }` and - `build.target: "es2022"` because the package ships an ES2022 module worker. + `build.target: "es2024"` because the package ships an ES2024 module worker. - Documented GPL-2.0-or-later distribution implications in the package README and [`docs/wp7-npm-package.md`](wp7-npm-package.md). The package remains `private` until the `necpp` npm scope is available. @@ -772,6 +776,10 @@ Required CI jobs: 8. Browser worker integration test. 9. Artifact size and checksum reporting. +The Node ABI, facade, and packed-consumer jobs run on Node 24. The Emscripten +container only compiles the WASM artifacts; JavaScript verification runs on +the Node 24 host after the artifacts are copied out. + Keep Emscripten and TypeScript versions pinned. Upgrade them deliberately in isolated changes. Emscripten’s `--emit-tsd` may continue producing internal glue typings, but the handwritten package types remain authoritative. [Emscripten compiler documentation](https://emscripten.org/docs/tools_reference/emcc.html) Initial accidental-debug-build guards can be generous: diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 5951a230..af3c2b9e 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -12,6 +12,7 @@ The final npm package name is **`@necpp/wasm`**. The unscoped name while the scoped name identifies this repository and leaves room for future `@necpp/*` packages. Publication requires control of the `necpp` 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 packed package exports three version identifiers that can be imported without constructing a model: diff --git a/docs/wp7-npm-package.md b/docs/wp7-npm-package.md index 727c57bf..81eb6aff 100644 --- a/docs/wp7-npm-package.md +++ b/docs/wp7-npm-package.md @@ -27,7 +27,8 @@ packages/necpp-wasm/ `package.json` is `"type": "module"` with encapsulated `exports` for `.` and `./worker`. The `files` allowlist is `dist`, `README.md`, and `COPYING`. The package remains `private` until the `necpp` npm scope is available; -`npm pack` is the distribution path. +`npm pack` is the distribution path. Node 24 is the minimum supported Node +runtime and the TypeScript facade is emitted as ES2024. `prepack` runs `scripts/build-dist.mjs`, which compiles `src/` to `dist/`, copies the generated loader and WASM, copies `COPYING`, and rejects source @@ -59,8 +60,8 @@ Package tests never import workspace `src/` or `.test-build`. They: emitted `.wasm` with `Content-Type: application/wasm` Direct `createNecModel()` needs no bundler config. Vite apps that import the -worker subpath set `worker: { format: "es" }` and `build.target: "es2022"`, -which match the module worker and ES2022 syntax the package ships. +worker subpath set `worker: { format: "es" }` and `build.target: "es2024"`, +which match the module worker and ES2024 syntax the package ships. The fixture's resolved module path must contain `node_modules/@necpp/wasm` and must not contain `packages/necpp-wasm/src`. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index b4bc9c99..9ae6ef4a 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -24,7 +24,7 @@ From a packed tarball: npm install ./necpp-wasm-0.0.0-wp7.tgz ``` -The package is ESM-only (`"type": "module"`). Node 18.19 or later is required. +The package is ESM-only (`"type": "module"`). Node 24 or later is required. ## Quick start @@ -72,8 +72,8 @@ try { ``` Geometry is metres, frequency is MHz, port current is positive into the -antenna, and far fields are complex V/m. The numerical contract is -[`docs/wasm-api.md`](../../docs/wasm-api.md). +antenna, and far fields are complex V/m. See the +[numerical and API contract](https://github.com/tmolteno/necpp/blob/master/docs/wasm-api.md). ## Versions @@ -105,13 +105,13 @@ Apps that import `@necpp/wasm/worker` and bundle with Vite should set: ```js export default { - build: { target: "es2022" }, + build: { target: "es2024" }, worker: { format: "es" }, }; ``` `worker.format` is Vite's setting for `{ type: "module" }` workers. `build.target` -must support the ES2022 syntax used by the package. Direct `createNecModel()` +must support the ES2024 syntax used by the package. Direct `createNecModel()` still needs no application source changes and no artifact copying. ## Worker entry diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index b0eea84e..1bfad525 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -9,10 +9,21 @@ "version": "0.0.0-wp7", "license": "GPL-2.0-or-later", "devDependencies": { + "@types/node": "^24.13.3", "typescript": "5.8.3" }, "engines": { - "node": ">=18.19" + "node": ">=24" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" } }, "node_modules/typescript": { @@ -28,6 +39,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index a2b43a2a..c8b76f50 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -12,7 +12,7 @@ }, "homepage": "https://github.com/tmolteno/necpp", "engines": { - "node": ">=18.19" + "node": ">=24" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -43,6 +43,7 @@ "typecheck": "tsc --project tsconfig.json" }, "devDependencies": { + "@types/node": "^24.13.3", "typescript": "5.8.3" } } diff --git a/packages/necpp-wasm/scripts/build-dist.mjs b/packages/necpp-wasm/scripts/build-dist.mjs index 0791acf3..bf3333e4 100644 --- a/packages/necpp-wasm/scripts/build-dist.mjs +++ b/packages/necpp-wasm/scripts/build-dist.mjs @@ -7,11 +7,10 @@ import { statSync, } from "node:fs"; import { spawnSync } from "node:child_process"; -import { dirname, join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, join, relative, resolve } from "node:path"; -const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); -const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const packageDirectory = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(import.meta.dirname, "../../.."); const sourceDirectory = join(packageDirectory, "src"); const distDirectory = join(packageDirectory, "dist"); const localCompiler = join(packageDirectory, "node_modules", "typescript", "bin", "tsc"); @@ -55,11 +54,14 @@ if (!existsSync(licenseSource)) { rmSync(distDirectory, { force: true, recursive: true }); -const compiler = existsSync(localCompiler) ? process.execPath : "tsc"; -const compilerArguments = existsSync(localCompiler) - ? [localCompiler, "--project", "tsconfig.dist.json"] - : ["--project", "tsconfig.dist.json"]; -const result = spawnSync(compiler, compilerArguments, { +if (!existsSync(localCompiler)) { + throw new Error("The pinned TypeScript compiler is missing; run npm install first"); +} +const result = spawnSync(process.execPath, [ + localCompiler, + "--project", + "tsconfig.dist.json", +], { cwd: packageDirectory, stdio: "inherit", }); diff --git a/packages/necpp-wasm/src/node-worker-threads.d.ts b/packages/necpp-wasm/src/node-worker-threads.d.ts deleted file mode 100644 index e1ee03d9..00000000 --- a/packages/necpp-wasm/src/node-worker-threads.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -declare module "node:worker_threads" { - export class Worker { - constructor(filename: string | URL, options?: { type?: "module" }); - postMessage(value: unknown, transferList?: readonly ArrayBuffer[]): void; - terminate(): Promise; - on(event: "message", listener: (value: unknown) => void): this; - on(event: "error", listener: (error: Error) => void): this; - on(event: "exit", listener: (code: number) => void): this; - off(event: "message", listener: (value: unknown) => void): this; - off(event: "error", listener: (error: Error) => void): this; - off(event: "exit", listener: (code: number) => void): this; - } - - export interface MessagePort { - postMessage(value: unknown, transferList?: readonly ArrayBuffer[]): void; - on(event: "message", listener: (value: unknown) => void): this; - } - - export const parentPort: MessagePort | null; -} diff --git a/packages/necpp-wasm/src/worker-client.ts b/packages/necpp-wasm/src/worker-client.ts index d97ca756..850bf854 100644 --- a/packages/necpp-wasm/src/worker-client.ts +++ b/packages/necpp-wasm/src/worker-client.ts @@ -36,6 +36,7 @@ export interface WorkerHost { postMessage(data: unknown, transfer?: readonly ArrayBuffer[]): void; subscribe(listener: (data: unknown) => void): () => void; subscribeError(listener: (error: unknown) => void): () => void; + subscribeExit?(listener: (code: number) => void): () => void; terminate(): void; } @@ -53,10 +54,7 @@ function createWebWorker(): Worker { async function openWorkerHost(): Promise { if (isNodeRuntime()) { const { Worker: NodeWorker } = await import("node:worker_threads"); - const worker = new NodeWorker( - new URL("./worker-entry.js", import.meta.url), - { type: "module" }, - ); + const worker = new NodeWorker(new URL("./worker-entry.js", import.meta.url)); return { postMessage(data, transfer = []) { worker.postMessage(data, transfer); @@ -79,6 +77,15 @@ async function openWorkerHost(): Promise { worker.off("error", handler); }; }, + subscribeExit(listener) { + const handler = (code: number): void => { + listener(code); + }; + worker.on("exit", handler); + return () => { + worker.off("exit", handler); + }; + }, terminate() { void worker.terminate(); }, @@ -100,12 +107,17 @@ async function openWorkerHost(): Promise { }; }, subscribeError(listener) { - const handler = (event: ErrorEvent): void => { + const errorHandler = (event: ErrorEvent): void => { listener(event.error ?? event.message); }; - worker.addEventListener("error", handler); + const messageErrorHandler = (): void => { + listener(new Error("The NEC worker could not deserialize a message")); + }; + worker.addEventListener("error", errorHandler); + worker.addEventListener("messageerror", messageErrorHandler); return () => { - worker.removeEventListener("error", handler); + worker.removeEventListener("error", errorHandler); + worker.removeEventListener("messageerror", messageErrorHandler); }; }, terminate() { @@ -136,6 +148,7 @@ class WorkerNecModel implements NecWorkerModel { readonly #listeners = new Set(); readonly #unsubscribeMessage: () => void; readonly #unsubscribeError: () => void; + readonly #unsubscribeExit: () => void; constructor(host: WorkerHost, onProgress?: NecWorkerProgressListener) { this.#host = host; @@ -150,6 +163,14 @@ class WorkerNecModel implements NecWorkerModel { new NecRuntimeError("The NEC worker failed", { cause: error }), ); }); + this.#unsubscribeExit = host.subscribeExit?.((code) => { + if (!this.#terminated) { + this.#failAll(new NecRuntimeError( + `The NEC worker exited unexpectedly with code ${code}`, + { details: { exitCode: code } }, + )); + } + }) ?? (() => undefined); } get state(): NecModelState { @@ -257,6 +278,7 @@ class WorkerNecModel implements NecWorkerModel { this.#state = "disposed"; this.#unsubscribeMessage(); this.#unsubscribeError(); + this.#unsubscribeExit(); const error = new NecRuntimeError("The NEC worker was terminated"); for (const pending of this.#pending.values()) { pending.reject(error); @@ -280,8 +302,14 @@ class WorkerNecModel implements NecWorkerModel { } #failAll(error: NecRuntimeError): void { + if (this.#terminated) { + return; + } this.#terminated = true; this.#state = "disposed"; + this.#unsubscribeMessage(); + this.#unsubscribeError(); + this.#unsubscribeExit(); for (const pending of this.#pending.values()) { pending.reject(error); } diff --git a/packages/necpp-wasm/src/worker-protocol.ts b/packages/necpp-wasm/src/worker-protocol.ts index 9ad0acf9..457ea5db 100644 --- a/packages/necpp-wasm/src/worker-protocol.ts +++ b/packages/necpp-wasm/src/worker-protocol.ts @@ -318,8 +318,10 @@ export function reviveEmbeddedFarFieldResult( }; } -export function cloneFloat64(source: Float64Array): Float64Array { - return new Float64Array(source); +export function cloneFloat64( + source: Float64Array, +): Float64Array { + return Float64Array.from(source); } export function serializeCreateOptions( @@ -348,15 +350,20 @@ export function serializeCreateOptions( } if (options.wasmBinary !== undefined) { - let bytes: Uint8Array; + let bytes: Uint8Array; try { - bytes = options.wasmBinary instanceof Uint8Array - ? options.wasmBinary.slice() - : new Uint8Array(options.wasmBinary.slice(0)); + if (options.wasmBinary instanceof Uint8Array) { + bytes = Uint8Array.from(options.wasmBinary); + } else if (options.wasmBinary instanceof ArrayBuffer) { + bytes = new Uint8Array(options.wasmBinary.slice(0)); + } else { + throw new TypeError("wasmBinary is not an ArrayBuffer or Uint8Array"); + } } catch (cause) { - throw new NecInputError("wasmBinary must reference readable WASM bytes", { - cause, - }); + throw new NecInputError( + "wasmBinary must be an ArrayBuffer or Uint8Array with readable WASM bytes", + { cause }, + ); } payload.wasmBinary = bytes.buffer; transfer.push(bytes.buffer); diff --git a/packages/necpp-wasm/test/build.mjs b/packages/necpp-wasm/test/build.mjs index 0444b872..008cd8ad 100644 --- a/packages/necpp-wasm/test/build.mjs +++ b/packages/necpp-wasm/test/build.mjs @@ -5,26 +5,23 @@ import { rmSync, } from "node:fs"; import { spawnSync } from "node:child_process"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; -const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); -const outputDirectory = fileURLToPath( - new URL("../.test-build/", import.meta.url), -); -const localCompiler = fileURLToPath( - new URL("../node_modules/typescript/bin/tsc", import.meta.url), +const packageDirectory = resolve(import.meta.dirname, ".."); +const outputDirectory = resolve(packageDirectory, ".test-build"); +const localCompiler = resolve( + packageDirectory, + "node_modules/typescript/bin/tsc", ); rmSync(outputDirectory, { force: true, recursive: true }); -const compiler = existsSync(localCompiler) ? process.execPath : "tsc"; -const compilerArguments = existsSync(localCompiler) - ? [localCompiler, "--project", "tsconfig.build.json"] - : ["--project", "tsconfig.build.json"]; +if (!existsSync(localCompiler)) { + throw new Error("The pinned TypeScript compiler is missing; run npm install first"); +} const result = spawnSync( - compiler, - compilerArguments, + process.execPath, + [localCompiler, "--project", "tsconfig.build.json"], { cwd: packageDirectory, stdio: "inherit", @@ -37,10 +34,8 @@ if (result.status !== 0) { process.exit(result.status ?? 1); } -const sourceDirectory = fileURLToPath(new URL("../src/", import.meta.url)); -const builtSourceDirectory = fileURLToPath( - new URL("../.test-build/src/", import.meta.url), -); +const sourceDirectory = resolve(packageDirectory, "src"); +const builtSourceDirectory = resolve(outputDirectory, "src"); mkdirSync(builtSourceDirectory, { recursive: true }); for (const name of ["nec2pp.generated.js", "nec2pp.wasm"]) { diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index d48d0ed5..ab539024 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { once } from "node:events"; import { createServer as createNetServer } from "node:net"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -80,6 +81,7 @@ async function startVitePreview(root) { let output = ""; let settled = false; + let timeout; const ready = new Promise((resolve, reject) => { const fail = (reason) => { @@ -87,6 +89,7 @@ async function startVitePreview(root) { return; } settled = true; + clearTimeout(timeout); reject(reason instanceof Error ? reason : new Error(String(reason))); }; const succeed = () => { @@ -94,6 +97,7 @@ async function startVitePreview(root) { return; } settled = true; + clearTimeout(timeout); resolve(); }; const onChunk = (chunk) => { @@ -108,7 +112,7 @@ async function startVitePreview(root) { child.on("exit", (code) => { fail(new Error(`vite preview exited ${code}: ${output}`)); }); - setTimeout(() => { + timeout = setTimeout(() => { fail(new Error(`vite preview did not start:\n${output}`)); }, 30_000); }); @@ -116,8 +120,13 @@ async function startVitePreview(root) { await ready; return { origin: `http://127.0.0.1:${port}`, - close() { + async close() { + if (child.exitCode !== null) { + return; + } + const exited = once(child, "exit"); child.kill(); + await exited; }, }; } @@ -181,7 +190,7 @@ test("a clean Vite fixture builds, serves WASM with the correct MIME type, and b const fixture = createCleanFixture("vite"); writeFixtureFile(fixture.root, "vite.config.js", `export default { build: { - target: "es2022", + target: "es2024", }, worker: { format: "es", @@ -308,6 +317,6 @@ try { assert.match(mime, /application\/wasm/); assert.ok((await wasmResponse.arrayBuffer()).byteLength > 0); } finally { - preview.close(); + await preview.close(); } }); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 4d5927f3..de883da3 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -10,10 +10,9 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; -export const packageDirectory = fileURLToPath(new URL("../../", import.meta.url)); +export const packageDirectory = resolve(import.meta.dirname, "../.."); export const VITE_VERSION = "6.3.5"; const sourceWasm = join(packageDirectory, "src", "nec2pp.wasm"); @@ -21,12 +20,34 @@ const sourceLoader = join(packageDirectory, "src", "nec2pp.generated.js"); export const hasWasmArtifacts = existsSync(sourceWasm) && existsSync(sourceLoader); +function resolveSpawn(command, args) { + if (command !== "npm") { + return { command, args }; + } + const configuredNpmCli = process.env.npm_execpath; + const bundledNpmCli = resolve( + dirname(process.execPath), + "node_modules/npm/bin/npm-cli.js", + ); + const npmCli = typeof configuredNpmCli === "string" && configuredNpmCli.length > 0 + ? configuredNpmCli + : bundledNpmCli; + if (!existsSync(npmCli)) { + throw new Error(`Could not locate the npm CLI at ${npmCli}`); + } + return { + command: process.execPath, + args: [npmCli, ...args], + }; +} + export function run(command, args, options = {}) { - const result = spawnSync(command, args, { + const invocation = resolveSpawn(command, args); + const result = spawnSync(invocation.command, invocation.args, { cwd: options.cwd ?? packageDirectory, encoding: "utf8", env: options.env ?? process.env, - shell: options.shell ?? command === "npm", + shell: options.shell ?? false, stdio: options.stdio ?? "inherit", windowsHide: true, }); diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 2345add0..d34c9575 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { @@ -7,11 +8,15 @@ import { } from "./helpers.mjs"; const skip = !hasWasmArtifacts && "WASM artifacts have not been built"; +const packageJson = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +); test("npm pack contains only the documented publish files", { skip }, () => { const packed = packPackage(); assert.equal(packed.version, "0.0.0-wp7"); assert.match(packed.filename, /^necpp-wasm-0\.0\.0-wp7\.tgz$/); + assert.equal(packageJson.engines.node, ">=24"); const files = new Set(packed.files); const required = [ diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs index 6c2734da..96836fb3 100644 --- a/packages/necpp-wasm/test/worker-client.test.mjs +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -128,6 +128,7 @@ function createLoopbackHost(createModel, options = {}) { const { port1, port2 } = new MessageChannel(); const session = { model: undefined }; let queue = Promise.resolve(); + let exitListener; const calls = []; port2.on("message", (request) => { @@ -152,6 +153,9 @@ function createLoopbackHost(createModel, options = {}) { return { calls, + simulateExit(code) { + exitListener?.(code); + }, postMessage(data, transfer = []) { port1.postMessage(data, transfer); }, @@ -163,6 +167,12 @@ function createLoopbackHost(createModel, options = {}) { subscribeError() { return () => undefined; }, + subscribeExit(listener) { + exitListener = listener; + return () => { + exitListener = undefined; + }; + }, terminate() { port1.close(); port2.close(); @@ -351,6 +361,31 @@ test("termination releases the worker and rejects outstanding operations", async release(); }); +test("an unexpected worker exit rejects the outstanding operation", async () => { + let release; + const hang = { + filter: (request) => request.method === "addWire", + gate: new Promise((resolve) => { + release = resolve; + }), + }; + const host = createLoopbackHost(async () => createFakeModel(), { hang }); + const model = await createNecWorkerModelFromHost(host); + + const pending = model.addWire(dipoleWire); + await new Promise((resolve) => setImmediate(resolve)); + host.simulateExit(2); + + await assert.rejects(pending, (error) => ( + error instanceof NecRuntimeError + && error.message.includes("exited unexpectedly") + && error.details?.exitCode === 2 + )); + assert.equal(model.state, "disposed"); + await assert.rejects(model.addWire(dipoleWire), NecStateError); + release(); +}); + test("two worker models run independently", async () => { const firstFake = createFakeModel({ computeImpedanceMatrix() { diff --git a/packages/necpp-wasm/test/worker-protocol.test.mjs b/packages/necpp-wasm/test/worker-protocol.test.mjs index 98209e52..b6b4cb0e 100644 --- a/packages/necpp-wasm/test/worker-protocol.test.mjs +++ b/packages/necpp-wasm/test/worker-protocol.test.mjs @@ -68,7 +68,7 @@ test("typed errors round-trip through the worker protocol", () => { assert.equal(runtime.message, "boom"); }); -test("create options copy wasm bytes and reject mixed overrides", () => { +test("create options copy wasm bytes and reject invalid overrides", () => { const bytes = new Uint8Array([1, 2, 3]); const serialized = serializeCreateOptions({ wasmBinary: bytes }); assert.ok(serialized.payload.wasmBinary instanceof ArrayBuffer); @@ -83,4 +83,8 @@ test("create options copy wasm bytes and reject mixed overrides", () => { }), NecInputError, ); + assert.throws( + () => serializeCreateOptions({ wasmBinary: "not WASM" }), + NecInputError, + ); }); diff --git a/packages/necpp-wasm/tsconfig.json b/packages/necpp-wasm/tsconfig.json index 2a0b8b6d..4f06a165 100644 --- a/packages/necpp-wasm/tsconfig.json +++ b/packages/necpp-wasm/tsconfig.json @@ -3,7 +3,7 @@ "allowImportingTsExtensions": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, - "lib": ["ES2022", "DOM"], + "lib": ["ES2024", "DOM"], "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, @@ -12,7 +12,7 @@ "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "strict": true, - "target": "ES2022", + "target": "ES2024", "useUnknownInCatchVariables": true, "verbatimModuleSyntax": true }, diff --git a/scripts/build_wasm_docker.ps1 b/scripts/build_wasm_docker.ps1 index e49d1f4c..2dd59318 100644 --- a/scripts/build_wasm_docker.ps1 +++ b/scripts/build_wasm_docker.ps1 @@ -17,6 +17,16 @@ $WasmImage = "emscripten/emsdk:4.0.7" Set-Location $ProjectDir +$NodeMajor = node -p "Number(process.versions.node.split('.')[0])" +if ($LASTEXITCODE -ne 0 -or [int]$NodeMajor -lt 24) { + throw "Node 24 or later is required to build and test the WASM package" +} + +npm --prefix packages/necpp-wasm ci +if ($LASTEXITCODE -ne 0) { + throw "npm ci failed with exit code $LASTEXITCODE" +} + $WasmOutDir = Join-Path $ProjectDir "wasm" New-Item -ItemType Directory -Force -Path $WasmOutDir | Out-Null @@ -38,4 +48,14 @@ if ($LASTEXITCODE -ne 0) { throw "Docker build failed with exit code $LASTEXITCODE" } +node scripts/wasm_smoke_test.mjs packages/necpp-wasm/src/nec2pp.generated.js +if ($LASTEXITCODE -ne 0) { + throw "WASM smoke test failed with exit code $LASTEXITCODE" +} + +npm --prefix packages/necpp-wasm run test:wasm +if ($LASTEXITCODE -ne 0) { + throw "WASM package tests failed with exit code $LASTEXITCODE" +} + Write-Host "=== WASM build complete ===" diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 4992f3ab..55a7c4a1 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -15,6 +15,12 @@ WASM_IMAGE="emscripten/emsdk:4.0.7" cd "$PROJECT_DIR" +node -e 'if (Number(process.versions.node.split(".")[0]) < 24) process.exit(1)' || { + echo "Node 24 or later is required to build and test the WASM package" >&2 + exit 1 +} +npm --prefix packages/necpp-wasm ci + rm -f wasm/nec2pp.js wasm/nec2pp.wasm wasm/nec2pp.d.ts mkdir -p wasm @@ -28,4 +34,7 @@ docker run --rm \ "$WASM_IMAGE" \ bash scripts/build_wasm_inner.sh +node scripts/wasm_smoke_test.mjs packages/necpp-wasm/src/nec2pp.generated.js +npm --prefix packages/necpp-wasm run test:wasm + echo "=== WASM build complete ===" diff --git a/scripts/build_wasm_inner.sh b/scripts/build_wasm_inner.sh index e528e4b4..9aa93541 100644 --- a/scripts/build_wasm_inner.sh +++ b/scripts/build_wasm_inner.sh @@ -10,19 +10,6 @@ set -euo pipefail # writes from a non-root container user, which breaks emscripten link steps. CONTAINER_BUILD_DIR="/tmp/necpp-${BUILD_DIR}" -TS_TOOLS_DIR="/tmp/emscripten-ts-tools" -export npm_config_cache="/tmp/npm-cache" - -npm install \ - --prefix "$TS_TOOLS_DIR" \ - --no-save \ - --no-package-lock \ - typescript@5.8.3 - -export PATH="$TS_TOOLS_DIR/node_modules/.bin:$PATH" - -tsc --version - rm -f \ packages/necpp-wasm/src/nec2pp.generated.js \ packages/necpp-wasm/src/nec2pp.wasm @@ -55,17 +42,11 @@ test -s "$CONTAINER_BUILD_DIR/src/nec2pp.js" test -s "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" test -s "$CONTAINER_BUILD_DIR/src/nec2pp.d.ts" -node --experimental-default-type=module \ - scripts/wasm_smoke_test.mjs \ - "$CONTAINER_BUILD_DIR/src/nec2pp.js" - cp "$CONTAINER_BUILD_DIR/src/nec2pp.js" \ packages/necpp-wasm/src/nec2pp.generated.js cp "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" \ packages/necpp-wasm/src/nec2pp.wasm -npm --prefix packages/necpp-wasm run test:wasm - mkdir -p "$WASM_OUT_DIR" cp \ From da7e0e09c39d9e4822311b996e00366444800c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 14:48:15 +0200 Subject: [PATCH 25/46] WP8 --- .github/workflows/build.yml | 385 ++++++++++++++++-- docs/ts_engine_plan.md | 30 +- docs/wp7-npm-package.md | 8 +- docs/wp8-ci-release.md | 63 +++ packages/necpp-wasm/README.md | 5 +- packages/necpp-wasm/package-lock.json | 71 +++- packages/necpp-wasm/package.json | 10 +- packages/necpp-wasm/scripts/pack-release.mjs | 121 ++++++ packages/necpp-wasm/src/versions.ts | 2 +- .../necpp-wasm/test/browser-integration.mjs | 253 ++++++++++++ packages/necpp-wasm/test/ci-workflow.test.mjs | 79 ++++ .../necpp-wasm/test/facade-runtime.test.mjs | 2 +- .../necpp-wasm/test/pack/consumer.test.mjs | 8 +- packages/necpp-wasm/test/pack/helpers.mjs | 24 +- .../necpp-wasm/test/pack/manifest.test.mjs | 4 +- 15 files changed, 1013 insertions(+), 52 deletions(-) create mode 100644 docs/wp8-ci-release.md create mode 100644 packages/necpp-wasm/scripts/pack-release.mjs create mode 100644 packages/necpp-wasm/test/browser-integration.mjs create mode 100644 packages/necpp-wasm/test/ci-workflow.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 143e77eb..e9a73a9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,64 +1,387 @@ -name: Build WASM +name: Build, test, and release WASM on: push: branches: [master, main] + tags: ["wasm-v*"] pull_request: branches: [master, main] workflow_dispatch: -# Cancel an obsolete build when newer commits arrive on the same branch/PR. concurrency: group: wasm-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +env: + NODE_VERSION: "24" + EMSCRIPTEN_IMAGE: "emscripten/emsdk:4.0.7" + TYPESCRIPT_VERSION: "5.8.3" + PLAYWRIGHT_VERSION: "1.62.1" jobs: - wasm: - name: Build NEC++ WebAssembly + native: + name: Native build and legacy Catch2 tests runs-on: ubuntu-latest timeout-minutes: 20 - permissions: contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - name: Configure release build + run: >- + cmake -S . -B build-ci-native + -DCMAKE_BUILD_TYPE=Release + -DBUILD_SHARED_LIBS=OFF + -DNECPP_BUILD_TESTS=ON + -DNECPP_BUILD_WASM=OFF + - name: Build native engine and tests + run: cmake --build build-ci-native --config Release --parallel 2 + - name: Run legacy and CLI smoke tests + run: >- + ctest --test-dir build-ci-native --build-config Release + --output-on-failure + --tests-regex "^(necpp_unit|necpp_smoke_hertzian_dipole)$" - - name: Set up Node 24 - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - cache-dependency-path: packages/necpp-wasm/package-lock.json + native-api: + name: Native port, matrix, far-field, and C ABI tests + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Configure release build + run: >- + cmake -S . -B build-ci-api + -DCMAKE_BUILD_TYPE=Release + -DBUILD_SHARED_LIBS=OFF + -DNECPP_BUILD_TESTS=ON + -DNECPP_BUILD_WASM=OFF + - name: Build native API tests + run: cmake --build build-ci-api --config Release --parallel 2 + - name: Run stateful numerical and ABI partitions + run: >- + ctest --test-dir build-ci-api --build-config Release + --output-on-failure + --tests-regex "^necpp_wp[1-4]$" - - name: Build optimized WASM - run: ./scripts/build_wasm_docker.sh - - - name: Prepare package artifacts + wasm-build: + name: Reproducible Emscripten build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Compile with pinned Emscripten SDK + shell: bash + run: | + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e BUILD_DIR=build-wasm-ci \ + -v "$PWD:/src" \ + -w /src \ + "$EMSCRIPTEN_IMAGE" \ + bash scripts/build_wasm_inner.sh + - name: Validate raw WASM outputs shell: bash run: | test -s wasm/nec2pp.js test -s wasm/nec2pp.wasm test -s wasm/nec2pp.d.ts - - sha256sum \ - wasm/nec2pp.js \ - wasm/nec2pp.wasm \ - wasm/nec2pp.d.ts \ + sha256sum wasm/nec2pp.js wasm/nec2pp.wasm wasm/nec2pp.d.ts \ > wasm/SHA256SUMS - - echo "Generated artifacts:" ls -lh wasm - - - name: Upload WASM package + - name: Upload raw WASM build uses: actions/upload-artifact@v4 with: - name: necpp-wasm + name: necpp-wasm-build path: wasm/ if-no-files-found: error retention-days: 30 - compression-level: 6 + node-abi: + name: Node 24 WASM ABI tests + needs: wasm-build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: actions/download-artifact@v4 + with: + name: necpp-wasm-build + path: wasm-artifact + - name: Exercise every versioned ABI family + run: node scripts/wasm_smoke_test.mjs wasm-artifact/nec2pp.js - + ts-facade: + name: Node 24 TypeScript facade tests + needs: wasm-build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: necpp-wasm-build + path: wasm-artifact + - name: Install pinned TypeScript toolchain + run: npm --prefix packages/necpp-wasm ci + - name: Verify pinned TypeScript version + shell: bash + run: | + actual="$(node -p "require('./packages/necpp-wasm/node_modules/typescript/package.json').version")" + test "$actual" = "$TYPESCRIPT_VERSION" + - name: Install WASM outputs into the facade source tree + shell: bash + run: | + cp wasm-artifact/nec2pp.js packages/necpp-wasm/src/nec2pp.generated.js + cp wasm-artifact/nec2pp.wasm packages/necpp-wasm/src/nec2pp.wasm + - name: Run strict types, lifecycle, facade, and worker tests + run: npm --prefix packages/necpp-wasm test + + package: + name: Build the release tarball once + needs: [wasm-build, ts-facade] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: necpp-wasm-build + path: wasm-artifact + - run: npm --prefix packages/necpp-wasm ci + - name: Install WASM outputs into the package + shell: bash + run: | + cp wasm-artifact/nec2pp.js packages/necpp-wasm/src/nec2pp.generated.js + cp wasm-artifact/nec2pp.wasm packages/necpp-wasm/src/nec2pp.wasm + - name: Pack and validate the publish allowlist + run: >- + npm --prefix packages/necpp-wasm run pack:release -- + "${{ runner.temp }}/necpp-package" + - name: Upload the exact release tarball + uses: actions/upload-artifact@v4 + with: + name: necpp-npm-package + path: ${{ runner.temp }}/necpp-package/ + if-no-files-found: error + retention-days: 30 + + package-consumer: + name: npm pack clean-consumer test + needs: package + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: necpp-npm-package + path: package-artifact + - run: npm --prefix packages/necpp-wasm ci + - name: Install and execute the exact tarball in clean Node and Vite fixtures + shell: bash + run: | + tarball="$(find "$PWD/package-artifact" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$tarball" + NECPP_WASM_TARBALL="$tarball" node \ + --test --test-timeout=180000 --test-concurrency=1 \ + packages/necpp-wasm/test/pack/consumer.test.mjs + + browser-direct: + name: Browser direct-mode integration + needs: package + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: necpp-npm-package + path: package-artifact + - run: npm --prefix packages/necpp-wasm ci + - name: Verify pinned Playwright and install Chromium + shell: bash + run: | + actual="$(node -p "require('./packages/necpp-wasm/node_modules/playwright/package.json').version")" + test "$actual" = "$PLAYWRIGHT_VERSION" + npm --prefix packages/necpp-wasm exec -- \ + playwright install --with-deps chromium + - name: Run a direct solve in Chromium + shell: bash + run: | + tarball="$(find "$PWD/package-artifact" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$tarball" + NECPP_WASM_TARBALL="$tarball" npm --prefix packages/necpp-wasm \ + run test:browser -- direct + + browser-worker: + name: Browser worker integration + needs: package + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: packages/necpp-wasm/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: necpp-npm-package + path: package-artifact + - run: npm --prefix packages/necpp-wasm ci + - name: Verify pinned Playwright and install Chromium + shell: bash + run: | + actual="$(node -p "require('./packages/necpp-wasm/node_modules/playwright/package.json').version")" + test "$actual" = "$PLAYWRIGHT_VERSION" + npm --prefix packages/necpp-wasm exec -- \ + playwright install --with-deps chromium + - name: Run an isolated worker solve in Chromium + shell: bash + run: | + tarball="$(find "$PWD/package-artifact" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$tarball" + NECPP_WASM_TARBALL="$tarball" npm --prefix packages/necpp-wasm \ + run test:browser -- worker + + artifact-report: + name: Artifact size and checksum report + needs: + - native + - native-api + - node-abi + - ts-facade + - package-consumer + - browser-direct + - browser-worker + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/download-artifact@v4 + with: + name: necpp-wasm-build + path: wasm-artifact + - uses: actions/download-artifact@v4 + with: + name: necpp-npm-package + path: package-artifact + - name: Enforce release guards and report exact bytes + shell: bash + run: | + test "$(stat -c %s wasm-artifact/nec2pp.wasm)" -lt 1048576 + test "$(stat -c %s wasm-artifact/nec2pp.js)" -lt 204800 + test -z "$(find package-artifact -type f \ + \( -name '*.map' -o -name '*.debug' -o -name '*.dSYM' \) -print -quit)" + mkdir release + cp package-artifact/*.tgz release/ + cp wasm-artifact/nec2pp.js wasm-artifact/nec2pp.wasm \ + wasm-artifact/nec2pp.d.ts release/ + (cd release && sha256sum *.tgz nec2pp.js nec2pp.wasm nec2pp.d.ts > SHA256SUMS) + { + echo "Pinned Emscripten image: $EMSCRIPTEN_IMAGE" + echo "Pinned TypeScript: $TYPESCRIPT_VERSION" + echo "Pinned Playwright: $PLAYWRIGHT_VERSION" + echo + find release -maxdepth 1 -type f ! -name ARTIFACTS.txt \ + -printf '%f %s bytes\n' | sort + } > release/ARTIFACTS.txt + cat release/ARTIFACTS.txt + cat release/SHA256SUMS + { + echo '### WASM release artifacts' + echo '```text' + cat release/ARTIFACTS.txt + cat release/SHA256SUMS + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload tested release artifacts + uses: actions/upload-artifact@v4 + with: + name: necpp-release-artifacts + path: release/ + if-no-files-found: error + retention-days: 90 + + release: + name: Publish tested tag artifacts + if: startsWith(github.ref, 'refs/tags/wasm-v') + needs: artifact-report + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v4 + with: + name: necpp-release-artifacts + path: release + - name: Verify tag and checksums + shell: bash + run: | + expected="wasm-v$(node -p "require('./packages/necpp-wasm/package.json').version")" + test "$GITHUB_REF_NAME" = "$expected" + (cd release && sha256sum --check SHA256SUMS) + - name: Publish the exact tested tarball + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + tarball="$(find release -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$tarball" + npm publish "$tarball" --access public --provenance + - name: Attach tarball and checksums to the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$GITHUB_REF_NAME" + release/* + --verify-tag --generate-notes diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 8fd12164..828dd9b5 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -762,6 +762,8 @@ The next open package on the critical path is WP8. ## WP8 — CI and release pipeline +**Status: complete (2026-08-28).** + Extend the existing WASM workflow in [.github/workflows/build.yml](C:/Users/andre/VSCode_Projects/necpp/.github/workflows/build.yml:1). Required CI jobs: @@ -804,6 +806,29 @@ DoD: - Failed numerical, browser, packaging or licensing checks prevent publication. - A release can be reproduced from a tagged checkout and pinned toolchain. +### WP8 progress + +- Replaced the compile-only workflow with dependency-ordered native legacy, + native stateful/API, pinned Emscripten, Node 24 ABI, strict TypeScript + facade, exact-tarball consumer, Chromium direct, Chromium worker, and final + artifact-report jobs. +- The Emscripten 4.0.7 container only compiles. Node 24 performs all JavaScript + verification after downloading the raw artifacts. TypeScript 5.8.3 and + Playwright 1.62.1 are pinned and checked in CI. +- Added real Vite/Chromium acceptance for both public browser entry points. + Each creates the canonical dipole, extracts impedance, solves, computes a + complex far field, and verifies that the browser fetched WASM as + `application/wasm`. +- Added a release packer that creates and checks one tarball, records its npm + file manifest and SHA-256 digest, and lets every clean-consumer and browser + job install that exact file through `NECPP_WASM_TARBALL`. +- Added size/debug guards and a final checksum report. `wasm-v` + tags publish the tested tarball with provenance and attach the same tarball + and checksums to a GitHub release only after the full graph succeeds. +- Documented toolchain pins, job boundaries, artifact identity, tag naming, + permissions, and the required protected npm environment in + [`docs/wp8-ci-release.md`](wp8-ci-release.md). + --- ## WP9 — Documentation and example application @@ -862,6 +887,5 @@ The package is ready when all of the following are true: - The exact packed tarball passes clean-consumer tests before publication. - Versioning, licensing, release artifacts and documentation are complete. -The completed critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7. -Remaining release work is WP8 (CI and release pipeline) and WP9 (documentation -and example application). +The completed critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7 → WP8. +The remaining release work is WP9 (documentation and example application). diff --git a/docs/wp7-npm-package.md b/docs/wp7-npm-package.md index 81eb6aff..4c6e5434 100644 --- a/docs/wp7-npm-package.md +++ b/docs/wp7-npm-package.md @@ -26,9 +26,11 @@ packages/necpp-wasm/ `package.json` is `"type": "module"` with encapsulated `exports` for `.` and `./worker`. The `files` allowlist is `dist`, `README.md`, and `COPYING`. The -package remains `private` until the `necpp` npm scope is available; -`npm pack` is the distribution path. Node 24 is the minimum supported Node -runtime and the TypeScript facade is emitted as ES2024. +package was initially kept `private` until the WP8 release gate existed; +`npm pack` was the distribution path. WP8 makes the manifest publishable and +guards registry publication behind the complete tagged release workflow. Node +24 is the minimum supported Node runtime and the TypeScript facade is emitted +as ES2024. `prepack` runs `scripts/build-dist.mjs`, which compiles `src/` to `dist/`, copies the generated loader and WASM, copies `COPYING`, and rejects source diff --git a/docs/wp8-ci-release.md b/docs/wp8-ci-release.md new file mode 100644 index 00000000..88cb1905 --- /dev/null +++ b/docs/wp8-ci-release.md @@ -0,0 +1,63 @@ +# WP8 CI and release pipeline + +WP8 makes [the WASM workflow](../.github/workflows/build.yml) the release gate +for `@necpp/wasm`. Pull requests and pushes run the complete native, WASM, +facade, package, and browser verification graph. A `wasm-v*` tag runs the same +graph and publishes only after every required job succeeds. + +## Pinned toolchain + +The workflow declares its release tool versions once: + +- Node 24; +- `emscripten/emsdk:4.0.7`; +- TypeScript 5.8.3; +- Playwright 1.62.1. + +The Emscripten container only invokes `scripts/build_wasm_inner.sh`. Node ABI, +TypeScript, package, and browser verification all run on the Node 24 host using +the artifacts copied out of that container. The npm lockfile fixes the complete +JavaScript test dependency graph. + +## Verification graph + +The native jobs independently cover the legacy Catch2/CLI suite and the WP1–4 +stateful port, matrix, far-field, and C ABI partitions. The generated loader, +WASM binary, and internal declaration file are uploaded once and consumed by +the Node ABI and facade jobs. + +After the facade passes, `scripts/pack-release.mjs` creates one npm tarball. It +validates the publish allowlist, license file, package version, source/debug +exclusions, and the size guards already enforced by `prepack`. It records the +tarball's SHA-256 digest and npm file report next to the tarball. + +Every downstream consumer uses `NECPP_WASM_TARBALL` to install that exact file: + +- clean Node direct and worker solves; +- CDN-style `wasmUrl` loading; +- a clean Vite bundle and preview; +- a real Chromium direct-mode solve; +- a real Chromium module-worker solve. + +The browser tests validate impedance and complex far-field output and observe +that the emitted WASM request is served as `application/wasm`. The artifact job +then enforces a WASM size below 1 MiB, a generated loader below 200 KiB, and no +source maps or debug symbols. It emits final sizes and SHA-256 checksums. + +## Tag release + +The public package version and tag must match exactly. For package version +`X.Y.Z`, create tag `wasm-vX.Y.Z`. Prerelease identifiers are retained, so the +WP8 development version uses `wasm-v0.0.0-wp8`. + +The repository must configure an `npm` GitHub environment with an `NPM_TOKEN` +secret authorized to publish the `@necpp/wasm` scope. The release job has only +`contents: write` and `id-token: write` permissions. It verifies the tag and +all checksums, publishes the already-tested `.tgz` with npm provenance, then +attaches that same tarball, `SHA256SUMS`, and `ARTIFACTS.txt` to a GitHub +release. It never runs `npm pack` again. + +Any failure in native numerics, ABI compatibility, strict TypeScript, package +contents, GPL license inclusion, clean-consumer installation, either browser +mode, size limits, version matching, or checksum verification prevents the +publish job from starting. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 9ae6ef4a..ae30a882 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -14,14 +14,15 @@ obligations for the intended product before shipping. The full license text is in `COPYING`. Publication to the public npm registry requires control of the `necpp` scope. -Until then, install the packed tarball produced by `npm pack`. +Tagged releases are gated by the full WP8 CI pipeline; until registry access is +configured, install the exact packed tarball produced by that workflow. ## Install From a packed tarball: ```bash -npm install ./necpp-wasm-0.0.0-wp7.tgz +npm install ./necpp-wasm-0.0.0-wp8.tgz ``` The package is ESM-only (`"type": "module"`). Node 24 or later is required. diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 1bfad525..012b8de4 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,16 +1,18 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp7", + "version": "0.0.0-wp8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp/wasm", - "version": "0.0.0-wp7", + "version": "0.0.0-wp8", "license": "GPL-2.0-or-later", "devDependencies": { "@types/node": "^24.13.3", - "typescript": "5.8.3" + "playwright": "1.62.1", + "typescript": "5.8.3", + "yaml": "2.9.0" }, "engines": { "node": ">=24" @@ -26,6 +28,53 @@ "undici-types": "~7.18.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -46,6 +95,22 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index c8b76f50..a0256551 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,7 +1,7 @@ { "name": "@necpp/wasm", - "version": "0.0.0-wp7", - "private": true, + "version": "0.0.0-wp8", + "private": false, "type": "module", "description": "Stateful NEC2++ electromagnetic solver for Node and the browser", "license": "GPL-2.0-or-later", @@ -40,10 +40,14 @@ "test": "npm run build:test && node --test test/*.test.mjs && npm run typecheck", "test:wasm": "node test/require-wasm.mjs && npm test && npm run test:pack", "test:pack": "node test/require-wasm.mjs && node --test --test-timeout=180000 --test-concurrency=1 test/pack/manifest.test.mjs test/pack/consumer.test.mjs", + "test:browser": "node test/browser-integration.mjs", + "pack:release": "node scripts/pack-release.mjs", "typecheck": "tsc --project tsconfig.json" }, "devDependencies": { "@types/node": "^24.13.3", - "typescript": "5.8.3" + "playwright": "1.62.1", + "typescript": "5.8.3", + "yaml": "2.9.0" } } diff --git a/packages/necpp-wasm/scripts/pack-release.mjs b/packages/necpp-wasm/scripts/pack-release.mjs new file mode 100644 index 00000000..2e8841f6 --- /dev/null +++ b/packages/necpp-wasm/scripts/pack-release.mjs @@ -0,0 +1,121 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +const packageDirectory = resolve(import.meta.dirname, ".."); +const outputDirectory = resolve(process.argv[2] ?? join(packageDirectory, ".pack-work")); +const packageJson = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), +); + +if (packageJson.private === true) { + throw new Error("Refusing to create a release tarball while package.json is private"); +} + +mkdirSync(outputDirectory, { recursive: true }); + +const configuredNpmCli = process.env.npm_execpath; +const bundledNpmCli = resolve( + dirname(process.execPath), + "node_modules/npm/bin/npm-cli.js", +); +const npmCli = typeof configuredNpmCli === "string" && configuredNpmCli.length > 0 + ? configuredNpmCli + : bundledNpmCli; +if (!existsSync(npmCli)) { + throw new Error(`Could not locate the npm CLI at ${npmCli}`); +} + +const packed = spawnSync(process.execPath, [ + npmCli, + "pack", + "--pack-destination", + outputDirectory, + "--json", +], { + cwd: packageDirectory, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], +}); +if (packed.error) { + throw packed.error; +} +if (packed.status !== 0) { + throw new Error(`npm pack failed with status ${packed.status}`); +} + +const jsonStart = packed.stdout.indexOf("["); +if (jsonStart < 0) { + throw new Error(`npm pack did not return a JSON report:\n${packed.stdout}`); +} +const reports = JSON.parse(packed.stdout.slice(jsonStart)); +const report = reports[0]; +if (report?.version !== packageJson.version) { + throw new Error( + `Packed version ${report?.version ?? ""} does not match ${packageJson.version}`, + ); +} + +const requiredFiles = new Set([ + "package.json", + "README.md", + "COPYING", + "dist/index.js", + "dist/index.d.ts", + "dist/worker.js", + "dist/worker.d.ts", + "dist/worker-entry.js", + "dist/nec2pp.generated.js", + "dist/nec2pp.wasm", +]); +const packedFiles = new Set( + (report.files ?? []).map(({ path }) => path.replaceAll("\\", "/")), +); +for (const path of requiredFiles) { + if (!packedFiles.has(path)) { + throw new Error(`Release tarball is missing ${path}`); + } +} +for (const path of packedFiles) { + const allowed = path === "package.json" + || path === "README.md" + || path === "COPYING" + || path.startsWith("dist/"); + if (!allowed) { + throw new Error(`Release tarball contains unexpected path ${path}`); + } + if (/\.map$|\.tsbuildinfo$|(^|\/)src\/|(^|\/)test\//.test(path)) { + throw new Error(`Release tarball contains a source or debug artifact: ${path}`); + } +} + +const tarball = join(outputDirectory, report.filename); +if (!existsSync(tarball)) { + throw new Error(`npm pack did not create ${tarball}`); +} +const digest = createHash("sha256").update(readFileSync(tarball)).digest("hex"); +const normalizedReport = { + ...report, + files: report.files ?? [], + sha256: digest, + tarballBytes: statSync(tarball).size, +}; +writeFileSync( + join(outputDirectory, "package-report.json"), + `${JSON.stringify(normalizedReport, null, 2)}\n`, +); +writeFileSync( + join(outputDirectory, "SHA256SUMS"), + `${digest} ${basename(tarball)}\n`, +); + +process.stdout.write( + `Packed ${report.filename} (${normalizedReport.tarballBytes} bytes, sha256 ${digest})\n`, +); diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index 61698f53..ca86f9d4 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.0.0-wp7"; +export const packageVersion = "0.0.0-wp8"; export const abiVersion = 1; export const engineVersion = "2.3.4"; diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs new file mode 100644 index 00000000..ecfc4c89 --- /dev/null +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { chromium } from "playwright"; + +const mode = process.argv[2]; +if (mode !== "direct" && mode !== "worker") { + throw new Error("usage: npm run test:browser -- direct|worker"); +} + +const tarballValue = process.env.NECPP_WASM_TARBALL; +if (typeof tarballValue !== "string" || tarballValue.length === 0) { + throw new Error("NECPP_WASM_TARBALL must identify the already-tested release tarball"); +} +const tarball = resolve(tarballValue); +if (!existsSync(tarball)) { + throw new Error(`Release tarball does not exist: ${tarball}`); +} + +const packageDirectory = resolve(import.meta.dirname, ".."); +const packageJson = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), +); +const fixture = mkdtempSync(join(tmpdir(), `necpp-wasm-browser-${mode}-`)); + +function writeFixture(relativePath, contents) { + const path = join(fixture, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +function run(command, args) { + const npmCli = process.env.npm_execpath ?? resolve( + dirname(process.execPath), + "node_modules/npm/bin/npm-cli.js", + ); + const invocation = command === "npm" + ? { command: process.execPath, args: [npmCli, ...args] } + : { command, args }; + const result = spawnSync(invocation.command, invocation.args, { + cwd: fixture, + encoding: "utf8", + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with status ${result.status}\n` + + `${result.stdout}\n${result.stderr}`, + ); + } +} + +function allocatePort() { + return new Promise((resolvePort, reject) => { + const server = createNetServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("failed to allocate a Vite preview port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolvePort(address.port); + }); + }); + server.on("error", reject); + }); +} + +async function startPreview() { + const port = await allocatePort(); + const viteBin = join(fixture, "node_modules", "vite", "bin", "vite.js"); + const child = spawn( + process.execPath, + [viteBin, "preview", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], + { + cwd: fixture, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + let output = ""; + await new Promise((resolveReady, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Vite preview did not start:\n${output}`)); + }, 30_000); + const consume = (chunk) => { + output += chunk.toString(); + if (output.includes(`http://127.0.0.1:${port}`)) { + clearTimeout(timeout); + resolveReady(); + } + }; + child.stdout?.on("data", consume); + child.stderr?.on("data", consume); + child.on("error", reject); + child.on("exit", (code) => { + reject(new Error(`Vite preview exited ${code}:\n${output}`)); + }); + }); + return { + origin: `http://127.0.0.1:${port}`, + async close() { + if (child.exitCode !== null) { + return; + } + const exited = once(child, "exit"); + child.kill(); + await exited; + }, + }; +} + +const modelImport = mode === "direct" + ? `import { abiVersion, createNecModel, engineVersion, packageVersion } from "@necpp/wasm";` + : `import { abiVersion, engineVersion, packageVersion } from "@necpp/wasm";\nimport { createNecWorkerModel } from "@necpp/wasm/worker";`; +const factory = mode === "direct" ? "createNecModel" : "createNecWorkerModel"; +const awaitPrefix = mode === "direct" ? "" : "await "; + +writeFixture("package.json", `${JSON.stringify({ + name: `necpp-browser-${mode}-fixture`, + private: true, + type: "module", + dependencies: { + "@necpp/wasm": `file:${tarball.replaceAll("\\", "/")}`, + }, + devDependencies: { + vite: "6.3.5", + }, +}, null, 2)}\n`); +writeFixture("vite.config.js", `export default { + build: { target: "es2024" }, + worker: { format: "es" }, +}; +`); +writeFixture("index.html", ` +
loading
+`); +writeFixture("main.js", `${modelImport} + +const out = document.getElementById("out"); +try { + const model = await ${factory}(); + try { + ${awaitPrefix}model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + ${awaitPrefix}model.completeGeometry(); + ${awaitPrefix}model.definePorts([{ tag: 1, segment: 6 }]); + ${awaitPrefix}model.prepare({ frequencyMHz: 300 }); + const matrices = ${awaitPrefix}model.computeImpedanceMatrix(); + ${awaitPrefix}model.solveVoltages({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + const field = ${awaitPrefix}model.computeFarField({ + radiusM: 1, + theta: { startDeg: 0, count: 3, stepDeg: 90 }, + phi: { startDeg: 0, count: 1, stepDeg: 1 }, + }); + window.__NEC_RESULT__ = { + abiVersion, + engineVersion, + packageVersion, + resistanceOhm: matrices.impedance.real[0], + fieldSamples: field.eThetaReal.length, + fieldFinite: [...field.eThetaReal, ...field.eThetaImag, + ...field.ePhiReal, ...field.ePhiImag].every(Number.isFinite), + mode: ${JSON.stringify(mode)}, + }; + } finally { + ${awaitPrefix}model.dispose(); + } +} catch (error) { + window.__NEC_RESULT__ = { error: error?.stack ?? String(error) }; +} +out.textContent = JSON.stringify(window.__NEC_RESULT__); +`); + +let preview; +let browser; +try { + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"]); + const viteBin = join(fixture, "node_modules", "vite", "bin", "vite.js"); + run(process.execPath, [viteBin, "build"]); + preview = await startPreview(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const browserErrors = []; + const wasmResponses = []; + page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => browserErrors.push(`page: ${error.stack ?? error}`)); + page.on("response", (response) => { + if (response.url().includes(".wasm")) { + wasmResponses.push(response.headers()["content-type"] ?? ""); + } + }); + await page.goto(preview.origin, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__NEC_RESULT__ !== undefined, null, { + timeout: 120_000, + }); + const result = await page.evaluate(() => window.__NEC_RESULT__); + assert.deepEqual(browserErrors, []); + assert.equal(result.error, undefined, result.error); + assert.equal(result.mode, mode); + assert.equal(result.packageVersion, packageJson.version); + assert.equal(result.abiVersion, 1); + assert.equal(result.engineVersion, "2.3.4"); + assert.ok(result.resistanceOhm > 0); + assert.equal(result.fieldSamples, 3); + assert.equal(result.fieldFinite, true); + assert.ok(wasmResponses.length >= 1, "the browser must request the emitted WASM asset"); + assert.ok( + wasmResponses.every((contentType) => /application\/wasm/.test(contentType)), + `unexpected WASM MIME types: ${wasmResponses.join(", ")}`, + ); + process.stdout.write( + `Browser ${mode} integration passed (${result.resistanceOhm} ohm)\n`, + ); +} finally { + await browser?.close(); + await preview?.close(); + rmSync(fixture, { force: true, recursive: true }); +} diff --git a/packages/necpp-wasm/test/ci-workflow.test.mjs b/packages/necpp-wasm/test/ci-workflow.test.mjs new file mode 100644 index 00000000..057908cc --- /dev/null +++ b/packages/necpp-wasm/test/ci-workflow.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { parse } from "yaml"; + +const packageDirectory = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(packageDirectory, "../.."); +const workflowSource = readFileSync( + resolve(repositoryRoot, ".github/workflows/build.yml"), + "utf8", +); +const workflow = parse(workflowSource); +const packageJson = JSON.parse( + readFileSync(resolve(packageDirectory, "package.json"), "utf8"), +); + +test("WP8 workflow parses and contains every required release gate", () => { + const requiredJobs = [ + "native", + "native-api", + "wasm-build", + "node-abi", + "ts-facade", + "package", + "package-consumer", + "browser-direct", + "browser-worker", + "artifact-report", + "release", + ]; + for (const job of requiredJobs) { + assert.ok(workflow.jobs[job], `missing workflow job ${job}`); + } + + assert.deepEqual(workflow.on.push.tags, ["wasm-v*"]); + assert.equal(workflow.env.NODE_VERSION, "24"); + assert.equal(workflow.env.EMSCRIPTEN_IMAGE, "emscripten/emsdk:4.0.7"); + assert.equal(workflow.env.TYPESCRIPT_VERSION, packageJson.devDependencies.typescript); + assert.equal(workflow.env.PLAYWRIGHT_VERSION, packageJson.devDependencies.playwright); + assert.equal(packageJson.private, false); + + assert.equal(workflow.jobs["node-abi"].needs, "wasm-build"); + assert.equal(workflow.jobs["ts-facade"].needs, "wasm-build"); + assert.equal(workflow.jobs["package-consumer"].needs, "package"); + assert.equal(workflow.jobs["browser-direct"].needs, "package"); + assert.equal(workflow.jobs["browser-worker"].needs, "package"); + assert.equal(workflow.jobs.release.needs, "artifact-report"); + + const finalGates = new Set(workflow.jobs["artifact-report"].needs); + for (const job of [ + "native", + "native-api", + "node-abi", + "ts-facade", + "package-consumer", + "browser-direct", + "browser-worker", + ]) { + assert.ok(finalGates.has(job), `artifact report does not require ${job}`); + } +}); + +test("WP8 workflow packs once and publishes the tested tarball", () => { + assert.equal((workflowSource.match(/run pack:release/g) ?? []).length, 1); + assert.equal((workflowSource.match(/npm publish/g) ?? []).length, 1); + assert.ok(workflowSource.includes('NECPP_WASM_TARBALL="$tarball"')); + assert.match(workflowSource, /sha256sum --check SHA256SUMS/); + assert.match(workflowSource, /npm publish "\$tarball" --access public --provenance/); + assert.match(workflowSource, /gh release create/); + + const wasmSteps = workflow.jobs["wasm-build"].steps; + assert.equal( + wasmSteps.some((step) => step.uses?.startsWith("actions/setup-node@")), + false, + "the Emscripten container job must compile only", + ); +}); diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 3fee9c1f..89874b4e 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.0.0-wp7"); + assert.equal(packageVersion, "0.0.0-wp8"); assert.equal(engineVersion, "2.3.4"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index ab539024..78fae557 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -22,6 +22,10 @@ import { writeFixtureFile, } from "./helpers.mjs"; +const packageJson = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +); + const skip = !hasWasmArtifacts && "WASM artifacts have not been built"; function parseJsonLine(stdout) { @@ -143,7 +147,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { cwd: fixture.root, stdio: ["ignore", "pipe", "inherit"], }).stdout); - assert.equal(direct.packageVersion, "0.0.0-wp7"); + assert.equal(direct.packageVersion, packageJson.version); assert.equal(direct.engineVersion, "2.3.4"); assert.equal(direct.abiVersion, 1); assert.ok(direct.resistanceOhm > 0); @@ -156,7 +160,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { stdio: ["ignore", "pipe", "inherit"], }).stdout, ); - assert.equal(worker.packageVersion, "0.0.0-wp7"); + assert.equal(worker.packageVersion, packageJson.version); assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); }); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index de883da3..6156dbe5 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -17,8 +17,13 @@ export const VITE_VERSION = "6.3.5"; const sourceWasm = join(packageDirectory, "src", "nec2pp.wasm"); const sourceLoader = join(packageDirectory, "src", "nec2pp.generated.js"); +const suppliedTarball = process.env.NECPP_WASM_TARBALL; -export const hasWasmArtifacts = existsSync(sourceWasm) && existsSync(sourceLoader); +export const hasWasmArtifacts = ( + typeof suppliedTarball === "string" + && suppliedTarball.length > 0 + && existsSync(resolve(suppliedTarball)) +) || (existsSync(sourceWasm) && existsSync(sourceLoader)); function resolveSpawn(command, args) { if (command !== "npm") { @@ -107,6 +112,23 @@ export function packPackage() { if (packedCache !== undefined) { return packedCache; } + if (typeof suppliedTarball === "string" && suppliedTarball.length > 0) { + const tarball = resolve(suppliedTarball); + if (!existsSync(tarball)) { + throw new Error(`NECPP_WASM_TARBALL does not exist: ${tarball}`); + } + const packageJson = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), + ); + packedCache = { + files: [], + filename: tarball.slice(Math.max(tarball.lastIndexOf("/"), tarball.lastIndexOf("\\")) + 1), + tarball, + version: packageJson.version, + workDirectory: dirname(tarball), + }; + return packedCache; + } const workDirectory = mkdtempSync(join(tmpdir(), "necpp-wasm-pack-")); const result = run("npm", ["pack", "--pack-destination", workDirectory, "--json"], { stdio: ["ignore", "pipe", "inherit"], diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index d34c9575..61f55c2e 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -14,8 +14,8 @@ const packageJson = JSON.parse( test("npm pack contains only the documented publish files", { skip }, () => { const packed = packPackage(); - assert.equal(packed.version, "0.0.0-wp7"); - assert.match(packed.filename, /^necpp-wasm-0\.0\.0-wp7\.tgz$/); + assert.equal(packed.version, packageJson.version); + assert.equal(packed.filename, `necpp-wasm-${packageJson.version}.tgz`); assert.equal(packageJson.engines.node, ">=24"); const files = new Set(packed.files); From c2f9256133e6581500ddfcf6c5a880724d1432ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 15:08:51 +0200 Subject: [PATCH 26/46] Rename WASM package to @necpp-engine/wasm --- docs/local-build-environment.md | 2 +- docs/ts_engine_plan.md | 17 +++++++++-------- docs/wasm-api.md | 15 ++++++++------- docs/wp6-web-worker.md | 2 +- docs/wp7-npm-package.md | 8 ++++---- docs/wp8-ci-release.md | 10 +++++----- packages/necpp-wasm/README.md | 14 +++++++------- packages/necpp-wasm/package-lock.json | 4 ++-- packages/necpp-wasm/package.json | 6 +++--- .../necpp-wasm/test/browser-integration.mjs | 6 +++--- packages/necpp-wasm/test/pack/consumer.test.mjs | 6 +++--- packages/necpp-wasm/test/pack/helpers.mjs | 12 ++++++------ packages/necpp-wasm/test/pack/manifest.test.mjs | 3 ++- 13 files changed, 54 insertions(+), 51 deletions(-) diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 7d224d39..2b60b86c 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -159,7 +159,7 @@ termination, and real WASM Z-matrix/far-field agreement with direct mode. The WP7 package assembly requires Node 24 or later and is packed with `npm pack` from `packages/necpp-wasm`. Clean-consumer tests install that tarball in a -temporary fixture, import `@necpp/wasm` and `@necpp/wasm/worker` by name, and +temporary fixture, import `@necpp-engine/wasm` and `@necpp-engine/wasm/worker` by name, and build a Vite app. Run them after a WASM build: ```powershell diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 828dd9b5..65b790a9 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -1,9 +1,9 @@ -The target should be a versioned, stateful npm package—provisionally `@necpp/wasm`—with a high-level TypeScript API. Consumers should never touch raw WASM pointers, copy artifacts manually, parse NEC reports, or understand Emscripten. +The target should be a versioned, stateful npm package—`@necpp-engine/wasm`—with a high-level TypeScript API. Consumers should never touch raw WASM pointers, copy artifacts manually, parse NEC reports, or understand Emscripten. A successful consumer experience would look like: ```ts -import { createNecModel } from "@necpp/wasm"; +import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel(); @@ -120,9 +120,10 @@ DoD: specification. It fixes lifecycle behavior, units, coordinates, phasor and power conventions, matrix/field layouts, embedded-field normalization, ownership, method failures, canonical fixtures, and numerical tolerances. -- Chose `@necpp/wasm` as the final package name. The existing unscoped +- Initially chose `@necpp/wasm`; WP8 renamed the final package to + `@necpp-engine/wasm` so the project can claim a dedicated npm organization. The existing unscoped `necpp-wasm` name is occupied by a separately published distribution; - publication of the scoped package will require control of the `necpp` npm + publication of the scoped package requires control of the `necpp-engine` npm scope. - Added the strict public TypeScript contract under `packages/necpp-wasm/src`, including typed errors and an executable lifecycle @@ -585,7 +586,7 @@ complete and can land independently of package assembly. Browser solves are synchronous and potentially expensive, so include an optional worker facade: ```ts -import { createNecWorkerModel } from "@necpp/wasm/worker"; +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; const model = await createNecWorkerModel(); ``` @@ -615,7 +616,7 @@ DoD: ### WP6 progress -- Added `createNecWorkerModel()` on the `@necpp/wasm/worker` subpath. The +- Added `createNecWorkerModel()` on the `@necpp-engine/wasm/worker` subpath. The package ships `worker-entry.ts`; the client constructs `new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" })` in browsers and uses `node:worker_threads` in Node. No consumer bootstrap @@ -736,7 +737,7 @@ DoD: ### WP7 progress -- Assembled `@necpp/wasm` as an ESM package with `exports` for `.` and +- Assembled the package, now named `@necpp-engine/wasm`, as an ESM package with `exports` for `.` and `./worker`, a `dist/` emit of the handwritten facade, and the generated `nec2pp.generated.js` plus `nec2pp.wasm` copied beside it. `prepack` builds that tree, copies `COPYING`, and rejects source maps, oversize artifacts, @@ -754,7 +755,7 @@ DoD: `build.target: "es2024"` because the package ships an ES2024 module worker. - Documented GPL-2.0-or-later distribution implications in the package README and [`docs/wp7-npm-package.md`](wp7-npm-package.md). The package - remains `private` until the `necpp` npm scope is available. + remained `private` until the WP8 release gate and final namespace were available. The next open package on the critical path is WP8. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index af3c2b9e..520e870a 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,4 +1,4 @@ -# `@necpp/wasm` API and numerical contract +# `@necpp-engine/wasm` API and numerical contract Status: normative specification, updated through WP7 on 2026-08-28. The stateful native layer, versioned C/WASM ABI, handwritten TypeScript facade, @@ -7,11 +7,12 @@ The committed TypeScript surface is in [`packages/necpp-wasm/src`](../packages/n ## Package and runtime boundary -The final npm package name is **`@necpp/wasm`**. The unscoped name +The final npm package name is **`@necpp-engine/wasm`**. The unscoped name `necpp-wasm` is already occupied by a separately published distribution, -while the scoped name identifies this repository and leaves room for future `@necpp/*` -packages. Publication requires control of the `necpp` npm scope, but the API -name will not change if the package is initially distributed as a tarball. +while the scoped name identifies this repository and leaves room for future +`@necpp-engine/*` packages. Publication requires control of the `necpp-engine` +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 packed package exports three version identifiers that can be imported @@ -27,7 +28,7 @@ not match those constants. `createNecModel()` asynchronously initializes the Emscripten module and returns a stateful `NecModel`. After creation, those model methods are synchronous. Large browser calculations should use `createNecWorkerModel()` -from `@necpp/wasm/worker`; its methods are asynchronous, serialized per model, +from `@necpp-engine/wasm/worker`; its methods are asynchronous, serialized per model, and otherwise observe this contract. `runDeck()` is an asynchronous compatibility escape hatch for a complete NEC text deck. It is not part of a `NecModel` lifecycle and returns a `DeckResult` containing the formatted @@ -225,7 +226,7 @@ the native layer can prove they are intact; otherwise it rolls back to ## Worker facade -`createNecWorkerModel()` is imported from `@necpp/wasm/worker`. The package +`createNecWorkerModel()` is imported from `@necpp-engine/wasm/worker`. The package supplies the worker script; a consumer does not write a bootstrap file. Each call creates an isolated worker and Emscripten instance. Methods match `NecModel` but return promises and are serialized per model. Progress diff --git a/docs/wp6-web-worker.md b/docs/wp6-web-worker.md index debc9233..daa9249e 100644 --- a/docs/wp6-web-worker.md +++ b/docs/wp6-web-worker.md @@ -2,7 +2,7 @@ WP6 adds an optional worker facade so browser applications can keep realistic NEC solves off the UI thread. The public factory is -`createNecWorkerModel()` from the `@necpp/wasm/worker` subpath. Direct +`createNecWorkerModel()` from the `@necpp-engine/wasm/worker` subpath. Direct `createNecModel()` remains the Node, test, and small-model entry point. The package ships the worker script. Consumers import the documented subpath diff --git a/docs/wp7-npm-package.md b/docs/wp7-npm-package.md index 4c6e5434..674dbb04 100644 --- a/docs/wp7-npm-package.md +++ b/docs/wp7-npm-package.md @@ -1,7 +1,7 @@ # WP7 npm package assembly WP7 turns the handwritten TypeScript facade and generated WASM into a -packable ESM package named `@necpp/wasm`. Consumers install a tarball (or a +packable ESM package named `@necpp-engine/wasm`. Consumers install a tarball (or a future npm publish) and import the documented entry points. They do not copy artifacts, author a worker bootstrap, or depend on this repository's source tree. @@ -54,9 +54,9 @@ Package tests never import workspace `src/` or `.test-build`. They: 1. `npm pack` the assembled package 2. install the `.tgz` into a temporary fixture -3. `import { createNecModel } from "@necpp/wasm"` by name +3. `import { createNecModel } from "@necpp-engine/wasm"` by name 4. solve the centre-fed dipole -5. import `@necpp/wasm/worker` and repeat +5. import `@necpp-engine/wasm/worker` and repeat 6. load WASM from an HTTP `wasmUrl` 7. build a Vite fixture, confirm the worker is bundled, and fetch the emitted `.wasm` with `Content-Type: application/wasm` @@ -65,7 +65,7 @@ Direct `createNecModel()` needs no bundler config. Vite apps that import the worker subpath set `worker: { format: "es" }` and `build.target: "es2024"`, which match the module worker and ES2024 syntax the package ships. -The fixture's resolved module path must contain `node_modules/@necpp/wasm` +The fixture's resolved module path must contain `node_modules/@necpp-engine/wasm` and must not contain `packages/necpp-wasm/src`. ## License diff --git a/docs/wp8-ci-release.md b/docs/wp8-ci-release.md index 88cb1905..69a9a8f1 100644 --- a/docs/wp8-ci-release.md +++ b/docs/wp8-ci-release.md @@ -1,7 +1,7 @@ # WP8 CI and release pipeline WP8 makes [the WASM workflow](../.github/workflows/build.yml) the release gate -for `@necpp/wasm`. Pull requests and pushes run the complete native, WASM, +for `@necpp-engine/wasm`. Pull requests and pushes run the complete native, WASM, facade, package, and browser verification graph. A `wasm-v*` tag runs the same graph and publishes only after every required job succeeds. @@ -51,10 +51,10 @@ The public package version and tag must match exactly. For package version WP8 development version uses `wasm-v0.0.0-wp8`. The repository must configure an `npm` GitHub environment with an `NPM_TOKEN` -secret authorized to publish the `@necpp/wasm` scope. The release job has only -`contents: write` and `id-token: write` permissions. It verifies the tag and -all checksums, publishes the already-tested `.tgz` with npm provenance, then -attaches that same tarball, `SHA256SUMS`, and `ARTIFACTS.txt` to a GitHub +secret authorized to publish the `@necpp-engine/wasm` package. The release job +has only `contents: write` and `id-token: write` permissions. It verifies the +tag and all checksums, publishes the already-tested `.tgz` with npm provenance, +then attaches that same tarball, `SHA256SUMS`, and `ARTIFACTS.txt` to a GitHub release. It never runs `npm pack` again. Any failure in native numerics, ABI compatibility, strict TypeScript, package diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index ae30a882..77995171 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -1,4 +1,4 @@ -# `@necpp/wasm` +# `@necpp-engine/wasm` Stateful NEC2++ antenna solver for Node and the browser. Consumers import a high-level TypeScript API; they never copy WASM artifacts, parse NEC reports, @@ -13,7 +13,7 @@ later, including the obligation to provide corresponding source. Review those obligations for the intended product before shipping. The full license text is in `COPYING`. -Publication to the public npm registry requires control of the `necpp` scope. +Publication to the public npm registry requires control of the `necpp-engine` scope. Tagged releases are gated by the full WP8 CI pipeline; until registry access is configured, install the exact packed tarball produced by that workflow. @@ -22,7 +22,7 @@ configured, install the exact packed tarball produced by that workflow. From a packed tarball: ```bash -npm install ./necpp-wasm-0.0.0-wp8.tgz +npm install ./necpp-engine-wasm-0.0.0-wp8.tgz ``` The package is ESM-only (`"type": "module"`). Node 24 or later is required. @@ -35,7 +35,7 @@ import { createNecModel, engineVersion, packageVersion, -} from "@necpp/wasm"; +} from "@necpp-engine/wasm"; console.log({ packageVersion, engineVersion, abiVersion }); @@ -74,7 +74,7 @@ try { Geometry is metres, frequency is MHz, port current is positive into the antenna, and far fields are complex V/m. See the -[numerical and API contract](https://github.com/tmolteno/necpp/blob/master/docs/wasm-api.md). +[numerical and API contract](https://github.com/andrekuehne/necpp/blob/master/docs/wasm-api.md). ## Versions @@ -102,7 +102,7 @@ Overrides: Bundlers such as Vite rewrite the default `import.meta.url` resolution; no extra consumer config or artifact copying is required for `createNecModel()`. -Apps that import `@necpp/wasm/worker` and bundle with Vite should set: +Apps that import `@necpp-engine/wasm/worker` and bundle with Vite should set: ```js export default { @@ -120,7 +120,7 @@ still needs no application source changes and no artifact copying. Large browser solves should use the worker subpath: ```ts -import { createNecWorkerModel } from "@necpp/wasm/worker"; +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; const model = await createNecWorkerModel(); ``` diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 012b8de4..83b9d497 100644 --- a/packages/necpp-wasm/package-lock.json +++ b/packages/necpp-wasm/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@necpp/wasm", + "name": "@necpp-engine/wasm", "version": "0.0.0-wp8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@necpp/wasm", + "name": "@necpp-engine/wasm", "version": "0.0.0-wp8", "license": "GPL-2.0-or-later", "devDependencies": { diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index a0256551..2888a0ec 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,5 +1,5 @@ { - "name": "@necpp/wasm", + "name": "@necpp-engine/wasm", "version": "0.0.0-wp8", "private": false, "type": "module", @@ -7,10 +7,10 @@ "license": "GPL-2.0-or-later", "repository": { "type": "git", - "url": "git+https://github.com/tmolteno/necpp.git", + "url": "git+https://github.com/andrekuehne/necpp.git", "directory": "packages/necpp-wasm" }, - "homepage": "https://github.com/tmolteno/necpp", + "homepage": "https://github.com/andrekuehne/necpp", "engines": { "node": ">=24" }, diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index ecfc4c89..23ba8344 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -133,8 +133,8 @@ async function startPreview() { } const modelImport = mode === "direct" - ? `import { abiVersion, createNecModel, engineVersion, packageVersion } from "@necpp/wasm";` - : `import { abiVersion, engineVersion, packageVersion } from "@necpp/wasm";\nimport { createNecWorkerModel } from "@necpp/wasm/worker";`; + ? `import { abiVersion, createNecModel, engineVersion, packageVersion } from "@necpp-engine/wasm";` + : `import { abiVersion, engineVersion, packageVersion } from "@necpp-engine/wasm";\nimport { createNecWorkerModel } from "@necpp-engine/wasm/worker";`; const factory = mode === "direct" ? "createNecModel" : "createNecWorkerModel"; const awaitPrefix = mode === "direct" ? "" : "await "; @@ -143,7 +143,7 @@ writeFixture("package.json", `${JSON.stringify({ private: true, type: "module", dependencies: { - "@necpp/wasm": `file:${tarball.replaceAll("\\", "/")}`, + "@necpp-engine/wasm": `file:${tarball.replaceAll("\\", "/")}`, }, devDependencies: { vite: "6.3.5", diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 78fae557..63f3de52 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -151,7 +151,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.equal(direct.engineVersion, "2.3.4"); assert.equal(direct.abiVersion, 1); assert.ok(direct.resistanceOhm > 0); - assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp\/wasm/); + assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp-engine\/wasm/); assert.doesNotMatch(direct.resolved, /packages[/\\]necpp-wasm[/\\]src[/\\]/); const worker = parseJsonLine( @@ -218,8 +218,8 @@ test("a clean Vite fixture builds, serves WASM with the correct MIME type, and b createNecModel, engineVersion, packageVersion, -} from "@necpp/wasm"; -import { createNecWorkerModel } from "@necpp/wasm/worker"; +} from "@necpp-engine/wasm"; +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; const out = document.getElementById("out"); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 6156dbe5..2f858563 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -168,7 +168,7 @@ export function createCleanFixture(name) { private: true, type: "module", dependencies: { - "@necpp/wasm": `file:${tarballPosix}`, + "@necpp-engine/wasm": `file:${tarballPosix}`, }, }, null, 2)}\n`); return { packed, root, tarballPosix }; @@ -187,9 +187,9 @@ export const dipoleScript = `import { createNecModel, engineVersion, packageVersion, -} from "@necpp/wasm"; +} from "@necpp-engine/wasm"; -const resolved = import.meta.resolve("@necpp/wasm"); +const resolved = import.meta.resolve("@necpp-engine/wasm"); if (!resolved.includes("node_modules")) { throw new Error(\`Package did not resolve from node_modules: \${resolved}\`); } @@ -233,9 +233,9 @@ export const workerDipoleScript = `import { createNecWorkerModel, engineVersion, packageVersion, -} from "@necpp/wasm/worker"; +} from "@necpp-engine/wasm/worker"; -const resolved = import.meta.resolve("@necpp/wasm/worker"); +const resolved = import.meta.resolve("@necpp-engine/wasm/worker"); if (!resolved.includes("node_modules")) { throw new Error(\`Worker entry did not resolve from node_modules: \${resolved}\`); } @@ -268,7 +268,7 @@ try { } `; -export const cdnDipoleScript = `import { createNecModel } from "@necpp/wasm"; +export const cdnDipoleScript = `import { createNecModel } from "@necpp-engine/wasm"; const wasmUrl = process.env.NEC_WASM_URL; if (typeof wasmUrl !== "string" || wasmUrl.length === 0) { diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 61f55c2e..22f7ade3 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -15,7 +15,8 @@ const packageJson = JSON.parse( test("npm pack contains only the documented publish files", { skip }, () => { const packed = packPackage(); assert.equal(packed.version, packageJson.version); - assert.equal(packed.filename, `necpp-wasm-${packageJson.version}.tgz`); + const filenamePrefix = packageJson.name.slice(1).replace("/", "-"); + assert.equal(packed.filename, `${filenamePrefix}-${packageJson.version}.tgz`); assert.equal(packageJson.engines.node, ">=24"); const files = new Set(packed.files); From 2951e4df269264dbcb000a22b76cc135d9c1c41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 18:02:46 +0200 Subject: [PATCH 27/46] WP 9 --- .github/workflows/build.yml | 9 +- CHANGELOG.md | 4 + README.md | 6 + docs/ts_engine_plan.md | 29 +- docs/wasm-api.md | 3 +- docs/wp8-ci-release.md | 47 +- docs/wp9-documentation-example.md | 89 ++ examples/wasm-array-vite/README.md | 35 + examples/wasm-array-vite/index.html | 73 + examples/wasm-array-vite/package-lock.json | 1182 +++++++++++++++++ examples/wasm-array-vite/package.json | 18 + examples/wasm-array-vite/src/main.ts | 238 ++++ examples/wasm-array-vite/src/style.css | 197 +++ examples/wasm-array-vite/tsconfig.json | 14 + examples/wasm-array-vite/vite.config.ts | 6 + packages/necpp-wasm/README.md | 417 ++++-- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 20 +- packages/necpp-wasm/scripts/pack-release.mjs | 6 + packages/necpp-wasm/src/versions.ts | 2 +- .../necpp-wasm/test/browser-integration.mjs | 76 +- packages/necpp-wasm/test/ci-workflow.test.mjs | 1 + .../necpp-wasm/test/facade-runtime.test.mjs | 2 +- .../necpp-wasm/test/pack/consumer.test.mjs | 39 + .../necpp-wasm/test/pack/manifest.test.mjs | 9 + 25 files changed, 2408 insertions(+), 118 deletions(-) create mode 100644 docs/wp9-documentation-example.md create mode 100644 examples/wasm-array-vite/README.md create mode 100644 examples/wasm-array-vite/index.html create mode 100644 examples/wasm-array-vite/package-lock.json create mode 100644 examples/wasm-array-vite/package.json create mode 100644 examples/wasm-array-vite/src/main.ts create mode 100644 examples/wasm-array-vite/src/style.css create mode 100644 examples/wasm-array-vite/tsconfig.json create mode 100644 examples/wasm-array-vite/vite.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9a73a9f..48d0ab18 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -251,7 +251,7 @@ jobs: run test:browser -- direct browser-worker: - name: Browser worker integration + name: Browser worker and Vite example integration needs: package runs-on: ubuntu-latest timeout-minutes: 15 @@ -283,6 +283,13 @@ jobs: test -n "$tarball" NECPP_WASM_TARBALL="$tarball" npm --prefix packages/necpp-wasm \ run test:browser -- worker + - name: Run the documented four-element Vite example in Chromium + shell: bash + run: | + tarball="$(find "$PWD/package-artifact" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$tarball" + NECPP_WASM_TARBALL="$tarball" npm --prefix packages/necpp-wasm \ + run test:browser -- example artifact-report: name: Artifact size and checksum report diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e9785d..0e049124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### Added +* **Initial `@necpp-engine/wasm` 0.1.0 package:** a stateful, high-level TypeScript API for Node and browsers, direct and Web Worker entry points, complex multi-port Z/Y matrices and solves, complex far fields and embedded patterns, packed-tarball consumer tests, and a four-element Vite array example. +* Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. + ### Bug Fixes * **`nec2diff` no longer reports spurious radiation-pattern differences:** at angles where the polarization is undefined (HORIZ gain `-999.99`, e.g. at the horizon) NEC leaves the SENSE column blank. The `RadiationInput` parser read the next numeric token as the sense string, shifting the remaining columns left by one, and `read_fixed`/`read_sci` returned **uninitialized memory** when the stream was exhausted — so comparing a file against itself reported a nonzero difference with garbage values (observed on `bruce_sommerfeld`). The parser now detects the blank SENSE column, and the readers return 0.0 on extraction failure. All 52 testharness decks now self-compare exactly clean; genuine differences are still flagged. diff --git a/README.md b/README.md index 099bf4e8..1c1016a0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ Timothy C.A. Molteno, ''NEC2++: An NEC-2 compatible Numerical Electromagnetics C Online documentation built form the source code is available at http://tmolteno.github.io/necpp/. A guide to [using nec2++ from python](http://astroelec.blogspot.co.nz/2015/05/modeling-antennas-in-python-with-nec2.html). +For Node and browser applications, the versioned `@necpp-engine/wasm` npm +package provides a high-level TypeScript API with multi-port matrices, complex +far fields, and an optional Web Worker facade. See the +[package guide](packages/necpp-wasm/README.md) and the +[four-element Vite example](examples/wasm-array-vite/README.md). + ## Installation nec2++ builds with CMake (≥ 3.16) and a C++17 compiler — Eigen is bundled, so diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 65b790a9..8ac19ef3 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -834,6 +834,8 @@ DoD: ## WP9 — Documentation and example application +**Status: complete (2026-08-28).** + Documentation must include: - Five-minute installation and dipole example. @@ -867,6 +869,28 @@ DoD: - Every README code example is compiled or executed in CI. - The example demonstrates the exact intended downstream integration path. +### WP9 progress + +- Replaced the npm README with a complete consumer guide covering the + five-minute dipole, geometry/ports, Z/Y layout, voltage/current drive, + active impedance, complex-field conventions, theta/phi coordinates, + embedded-pattern beamforming, direct/worker operation, lifecycle, loading, + typed errors, performance, memory, versions, and GPL obligations. +- Added a standalone four-element Vite application under + [`examples/wasm-array-vite`](../examples/wasm-array-vite/README.md). It uses + the public worker subpath, computes the 4 x 4 matrix, drives progressive + complex currents, renders all port quantities, and plots a 361-point + azimuth cut without a plotting dependency. +- Added a clean-tarball documentation gate that compiles every TypeScript + README fence under strict TypeScript and executes the five-minute dipole in + Node. Added Chromium acceptance that copies, installs, builds, and runs the + actual Vite example with the exact release tarball and validates its UI, + finite results, plot, and WASM MIME response. +- Set the initial public API version to `0.1.0`, added npm discovery, issue, + and public-registry metadata, documented first-release prerequisites and the + post-first-publish migration from a granular token to npm trusted + publishing, and added [`wp9-documentation-example.md`](wp9-documentation-example.md). + # Overall release Definition of Done The package is ready when all of the following are true: @@ -888,5 +912,6 @@ The package is ready when all of the following are true: - The exact packed tarball passes clean-consumer tests before publication. - Versioning, licensing, release artifacts and documentation are complete. -The completed critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7 → WP8. -The remaining release work is WP9 (documentation and example application). +The completed critical path is WP0 → WP1 → WP2 → WP3 → WP4 → WP5 → WP7 → WP8 +→ WP9. Publication is an external release action performed from a tagged +`main` commit after the protected npm environment and scope access are ready. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 520e870a..77ce346f 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,6 +1,6 @@ # `@necpp-engine/wasm` API and numerical contract -Status: normative specification, updated through WP7 on 2026-08-28. The +Status: normative specification, updated through WP9 on 2026-08-28. 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). @@ -14,6 +14,7 @@ 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 initial public TypeScript API release is `0.1.0`. The packed package exports three version identifiers that can be imported without constructing a model: diff --git a/docs/wp8-ci-release.md b/docs/wp8-ci-release.md index 69a9a8f1..9972036e 100644 --- a/docs/wp8-ci-release.md +++ b/docs/wp8-ci-release.md @@ -47,15 +47,44 @@ source maps or debug symbols. It emits final sizes and SHA-256 checksums. ## Tag release The public package version and tag must match exactly. For package version -`X.Y.Z`, create tag `wasm-vX.Y.Z`. Prerelease identifiers are retained, so the -WP8 development version uses `wasm-v0.0.0-wp8`. - -The repository must configure an `npm` GitHub environment with an `NPM_TOKEN` -secret authorized to publish the `@necpp-engine/wasm` package. The release job -has only `contents: write` and `id-token: write` permissions. It verifies the -tag and all checksums, publishes the already-tested `.tgz` with npm provenance, -then attaches that same tarball, `SHA256SUMS`, and `ARTIFACTS.txt` to a GitHub -release. It never runs `npm pack` again. +`X.Y.Z`, create tag `wasm-vX.Y.Z`. The initial public package version is +`0.1.0`, so its release tag is `wasm-v0.1.0`. + +The first publication must use the repository's `npm` GitHub environment with +an `NPM_TOKEN` secret authorized to create a public package in the +`necpp-engine` scope. The token needs publishing access and npm's required 2FA +bypass setting. The release job has only `contents: write` and `id-token: +write` permissions. It verifies the tag and all checksums, publishes the +already-tested `.tgz` with npm provenance, then attaches that same tarball, +`SHA256SUMS`, and `ARTIFACTS.txt` to a GitHub release. It never runs `npm pack` +again. + +After `0.1.0` exists on npm, replace the long-lived token with npm trusted +publishing. Configure the GitHub publisher for repository `andrekuehne/necpp`, +workflow `build.yml`, environment `npm`, and allowed action `npm publish`; +then remove `NODE_AUTH_TOKEN` from the release step and revoke the token. npm +requires a package to exist before a trusted relationship can be configured. + +## Initial 0.1.0 release checklist + +Before merging, the checked-in `package.json`, `packageVersion` export, and tag +must all identify `0.1.0`. After the WP9 branch reaches `main`: + +1. Confirm the public npm organization/scope `necpp-engine` exists and the + publishing account can create packages in it. +2. Protect the GitHub `npm` environment with required reviewers and add the + initial granular `NPM_TOKEN` secret. +3. Wait for the complete `main` workflow to pass. +4. Create and push `wasm-v0.1.0` at that exact tested commit. Do not publish + manually and do not rebuild the tarball locally. +5. Confirm the tag workflow passes every native, WASM, Node, package, + documentation, Vite-example, and Chromium gate before approving the + environment deployment. +6. Verify the npm page reports public access, version `0.1.0`, repository + provenance, `GPL-2.0-or-later`, and both documented exports. +7. Download the GitHub release tarball and verify it against `SHA256SUMS`. +8. Configure npm trusted publishing as described above, remove the publish + token, and retain environment approval plus tag protection. Any failure in native numerics, ABI compatibility, strict TypeScript, package contents, GPL license inclusion, clean-consumer installation, either browser diff --git a/docs/wp9-documentation-example.md b/docs/wp9-documentation-example.md new file mode 100644 index 00000000..4f58c1e5 --- /dev/null +++ b/docs/wp9-documentation-example.md @@ -0,0 +1,89 @@ +# WP9 documentation and example application + +WP9 completes the public onboarding surface for `@necpp-engine/wasm` and makes +that surface executable in the release pipeline. + +## Documentation coverage + +The npm README is the primary consumer guide because it travels in the +tarball. It covers: + +- a complete five-minute centre-fed dipole; +- metres/MHz units, geometry tags, one-based port segments, and stable port + ordering; +- row-major Z/Y indexing and the physical meaning of matrix entries; +- simultaneous voltage and current drives, achieved quantities, powers, and + active versus matrix impedance; +- the `e^(+j omega t)` phasor, `e^(-jkR)/R` propagation, V/m units, 1 m + default radius, theta/phi axes, and theta-fast sample ordering; +- unit-current embedded-pattern superposition for JavaScript-side + beamforming; +- direct and worker entry points, cancellation, lifecycle, reuse, and + deterministic disposal; +- default adjacent-WASM loading plus Node, browser, Vite, CDN/CORS, and + caller-supplied-byte paths; +- typed error handling, performance, result ownership, browser memory sizing, + and GPL-2.0-or-later distribution implications. + +The normative detail remains in [`wasm-api.md`](wasm-api.md). The README links +to that document on the `main` branch so the link also works from npm, where +repository-relative paths are unavailable. + +## Four-element Vite application + +[`examples/wasm-array-vite`](../examples/wasm-array-vite/README.md) is a +standalone downstream application. It has no relative import into the +monorepo. Its package manifest contains only pinned TypeScript and Vite +development tools; the solver is installed separately from either the public +registry or an exact `.tgz`. + +The application: + +1. creates four parallel, centre-fed half-wave dipoles in a package-managed + Web Worker; +2. prepares the model at 300 MHz and computes the full complex 4 x 4 Z/Y + result; +3. requests unit-amplitude currents with a -60 degree progressive phase; +4. displays requested and achieved current, required voltage, active + impedance, and time-average power for every port; +5. displays the row-major impedance matrix and condition estimate; and +6. computes and plots the combined complex-field magnitude for a 361-point + azimuth cut at theta = 90 degrees and radius = 1 m. + +No plotting dependency is needed: the example generates a compact accessible +SVG. It exposes a test-only result summary on `window` after the UI has been +rendered. + +## Executable documentation gates + +The exact release tarball is built once by WP8. The clean-consumer job installs +that tarball, extracts every `ts` fence from the npm README, compiles every +snippet with strict TypeScript 5.8.3, and executes the first dipole example in +Node. Vite is present in the fixture so the documented worker configuration is +also type-checked. + +The browser-worker job additionally copies the checked-in example to a clean +temporary directory, injects only the exact tarball as its solver dependency, +runs its strict type-check plus Vite production build, and opens it in +Chromium. Acceptance requires: + +- package version `0.1.0` from the installed package; +- four rendered port rows and a 4 x 4 matrix; +- 361 finite complex-field samples and a rendered plot; +- at least one `.wasm` request, all served as `application/wasm`; and +- no browser console or page errors. + +This is the intended downstream integration path, not a special monorepo test +entry point. + +## Release state + +The initial npm version is `0.1.0`; the internal engine remains `2.3.4` and +the stable C ABI remains v1. Package, engine, and ABI versions are exposed +separately because they follow different compatibility lines. + +The public registry returned 404 for `@necpp-engine/wasm` during the WP9 +preflight on 2026-08-28. Publication still requires control of the +`necpp-engine` scope and the protected release environment described in +[`wp8-ci-release.md`](wp8-ci-release.md). The release workflow—not a local +command—must publish the tested tarball after the branch is merged to `main`. diff --git a/examples/wasm-array-vite/README.md b/examples/wasm-array-vite/README.md new file mode 100644 index 00000000..3690c96e --- /dev/null +++ b/examples/wasm-array-vite/README.md @@ -0,0 +1,35 @@ +# Four-element array Vite example + +This is a downstream application for `@necpp-engine/wasm`, not a monorepo +source import. It creates four parallel half-wave dipoles in a package-supplied +Web Worker, computes the complex 4 x 4 impedance matrix, applies progressive +complex current weights, shows achieved port quantities, and plots the +combined azimuth far-field cut at 1 m. + +## Run with the published package + +From this directory, run `npm install`, then +`npm install @necpp-engine/wasm@0.1.0`, followed by `npm run dev`. Open the URL +printed by Vite. Use `npm run build` and `npm run preview` to inspect the +production bundle. + +The package dependency is intentionally installed as a separate command so +this example remains runnable before the first registry release and so CI can +substitute the exact release tarball. + +## Run from a release tarball + +First build the repository's WASM artifacts and release tarball as described +in [`docs/wp8-ci-release.md`](../../docs/wp8-ci-release.md). In this directory, +run `npm install`, then `npm install --no-save `, followed +by `npm run build` or `npm run dev`. + +CI copies this directory to a clean temporary location, installs the exact +tarball produced by the release packer, type-checks and bundles it, opens the +production build in Chromium, and verifies four finite port results, 361 field +samples, the rendered plot, and an `application/wasm` response. No file is +resolved from `packages/necpp-wasm/src`. + +The app and installed solver are GPL-2.0-or-later. Distributing the built app +conveys the solver and requires GPL compliance, including corresponding source +and notices. See the package README for the full technical notice. diff --git a/examples/wasm-array-vite/index.html b/examples/wasm-array-vite/index.html new file mode 100644 index 00000000..af3f2763 --- /dev/null +++ b/examples/wasm-array-vite/index.html @@ -0,0 +1,73 @@ + + + + + + + NEC2++ four-element array + + +
+
+

@necpp-engine/wasm · Web Worker

+

Four-element array

+

+ Four half-wave dipoles at 300 MHz, driven with a −60° progressive + current phase. Values below come directly from the stateful solver. +

+
+ +

Starting solver…

+ +
+
+
+

Solved excitation

+

Port quantities

+
+

Current positive into each antenna; active Z = V / I.

+
+
+ + + + + + + + + + + + +
PortRequested I (A)Achieved I (A)Required V (V)Active Z (Ω)Power (W)
+
+
+ +
+
+
+

Prepared model

+

Impedance matrix Z (Ω)

+
+

+
+
+
+
+
+ +
+
+
+

Complex far field at 1 m

+

Azimuth cut · θ = 90°

+
+

Combined |Eθ| and |Eφ|, normalized to the cut maximum.

+
+ +
+
+ + + diff --git a/examples/wasm-array-vite/package-lock.json b/examples/wasm-array-vite/package-lock.json new file mode 100644 index 00000000..eaec3860 --- /dev/null +++ b/examples/wasm-array-vite/package-lock.json @@ -0,0 +1,1182 @@ +{ + "name": "necpp-wasm-array-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "necpp-wasm-array-example", + "version": "0.0.0", + "devDependencies": { + "typescript": "5.8.3", + "vite": "6.3.5" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/examples/wasm-array-vite/package.json b/examples/wasm-array-vite/package.json new file mode 100644 index 00000000..b7d29a38 --- /dev/null +++ b/examples/wasm-array-vite/package.json @@ -0,0 +1,18 @@ +{ + "name": "necpp-wasm-array-example", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "tsc --noEmit && vite build", + "dev": "vite", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "5.8.3", + "vite": "6.3.5" + } +} diff --git a/examples/wasm-array-vite/src/main.ts b/examples/wasm-array-vite/src/main.ts new file mode 100644 index 00000000..da13938c --- /dev/null +++ b/examples/wasm-array-vite/src/main.ts @@ -0,0 +1,238 @@ +import { + NecError, + packageVersion, + type ComplexMatrix, + type FarFieldResult, + type PortSolution, +} from "@necpp-engine/wasm"; +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; + +import "./style.css"; + +interface ExampleResult { + readonly ready: true; + readonly packageVersion: string; + readonly portCount: number; + readonly fieldSamples: number; + readonly finite: boolean; + readonly wasmResponsesExpected: true; +} + +declare global { + interface Window { + __NECPP_EXAMPLE_RESULT__?: ExampleResult | { readonly error: string }; + } +} + +const elementPositionsM = [-0.45, -0.15, 0.15, 0.45] as const; +const phaseStepRad = -Math.PI / 3; +const status = requiredElement("status"); + +function requiredElement(id: string): T { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`Missing #${id}`); + } + return element as T; +} + +function formatComplex(real: number, imag: number, digits = 3): string { + const sign = imag < 0 ? "−" : "+"; + return `${real.toFixed(digits)} ${sign} j${Math.abs(imag).toFixed(digits)}`; +} + +function matrixEntry(matrix: ComplexMatrix, row: number, column: number) { + const index = row * matrix.columns + column; + return { + real: matrix.real[index]!, + imag: matrix.imag[index]!, + }; +} + +function renderMatrix(matrix: ComplexMatrix, conditionEstimate?: number): void { + const body = requiredElement("matrix").tBodies[0]!; + const header = document.createElement("tr"); + header.append(document.createElement("th")); + for (let column = 0; column < matrix.columns; column += 1) { + const cell = document.createElement("th"); + cell.textContent = `Port ${column + 1}`; + header.append(cell); + } + body.append(header); + + for (let row = 0; row < matrix.rows; row += 1) { + const tableRow = document.createElement("tr"); + const heading = document.createElement("th"); + heading.textContent = `Port ${row + 1}`; + tableRow.append(heading); + for (let column = 0; column < matrix.columns; column += 1) { + const value = matrixEntry(matrix, row, column); + const cell = document.createElement("td"); + cell.textContent = formatComplex(value.real, value.imag, 2); + tableRow.append(cell); + } + body.append(tableRow); + } + + requiredElement("condition").textContent = conditionEstimate === undefined + ? "Condition estimate unavailable" + : `2-norm condition estimate: ${conditionEstimate.toExponential(3)}`; +} + +function renderPorts(solution: PortSolution): void { + const body = requiredElement("ports").tBodies[0]!; + for (let port = 0; port < solution.ports.length; port += 1) { + const row = document.createElement("tr"); + const values = [ + solution.ports[port]!.name ?? String(port + 1), + formatComplex(solution.requested.real[port]!, solution.requested.imag[port]!), + formatComplex(solution.currents.real[port]!, solution.currents.imag[port]!), + formatComplex(solution.voltages.real[port]!, solution.voltages.imag[port]!), + formatComplex( + solution.activeImpedances.real[port]!, + solution.activeImpedances.imag[port]!, + 2, + ), + solution.powersW[port]!.toFixed(3), + ]; + for (const value of values) { + const cell = document.createElement("td"); + cell.textContent = value; + row.append(cell); + } + body.append(row); + } +} + +function renderPlot(field: FarFieldResult): void { + const svg = requiredElement("plot"); + const width = 960; + const height = 360; + const margin = { top: 24, right: 24, bottom: 44, left: 58 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + if (field.eThetaReal.length < 2) { + throw new Error(`Expected at least two field samples, received ${field.eThetaReal.length}`); + } + const magnitudes = field.eThetaReal.map((thetaReal, index) => Math.hypot( + thetaReal, + field.eThetaImag[index]!, + field.ePhiReal[index]!, + field.ePhiImag[index]!, + )); + const peak = Math.max(...magnitudes); + if (!Number.isFinite(peak) || peak <= 0) { + throw new Error(`Expected a positive finite field peak, received ${peak}`); + } + const decibels = magnitudes.map((value) => Math.max( + -40, + 20 * Math.log10(Math.max(value / peak, Number.EPSILON)), + )); + const pointList = Array.from(decibels, (db, index) => { + const x = margin.left + (index / (decibels.length - 1)) * innerWidth; + const y = margin.top + (-db / 40) * innerHeight; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }).join(" "); + if (pointList.includes("NaN")) { + throw new Error( + `Invalid plot coordinates: samples=${decibels.length}, peak=${peak}, firstDb=${decibels[0]}`, + ); + } + + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.innerHTML = ` + Normalized azimuth far-field cut + Field magnitude from 0 to 360 degrees, clipped at minus 40 decibels. + + + + ${[0, -10, -20, -30, -40].map((db) => { + const y = margin.top + (-db / 40) * innerHeight; + return ` + ${db} dB`; + }).join("")} + ${[0, 90, 180, 270, 360].map((phi) => { + const x = margin.left + (phi / 360) * innerWidth; + return `${phi}°`; + }).join("")} + + + `; +} + +async function run(): Promise { + const model = await createNecWorkerModel({ + onProgress: ({ operation, phase }) => { + status.textContent = `${phase === "start" ? "Running" : "Completed"} ${operation}…`; + }, + }); + + try { + for (const [index, xM] of elementPositionsM.entries()) { + await model.addWire({ + tag: index + 1, + segments: 11, + start: [xM, 0, -0.25], + end: [xM, 0, 0.25], + radiusM: 0.001, + }); + } + await model.completeGeometry(); + await model.definePorts(elementPositionsM.map((_, index) => ({ + tag: index + 1, + segment: 6, + name: `Element ${index + 1}`, + }))); + await model.prepare({ frequencyMHz: 300 }); + + const matrices = await model.computeImpedanceMatrix(); + const currents = { + real: Float64Array.from(elementPositionsM, (_, index) => Math.cos(index * phaseStepRad)), + imag: Float64Array.from(elementPositionsM, (_, index) => Math.sin(index * phaseStepRad)), + }; + const solution = await model.solveCurrents(currents); + const field = await model.computeFarField({ + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + }); + + renderMatrix(matrices.impedance, matrices.conditionEstimate); + renderPorts(solution); + renderPlot(field); + + const finite = [ + ...matrices.impedance.real, + ...matrices.impedance.imag, + ...solution.voltages.real, + ...solution.voltages.imag, + ...field.eThetaReal, + ...field.eThetaImag, + ...field.ePhiReal, + ...field.ePhiImag, + ].every(Number.isFinite); + return { + ready: true, + packageVersion, + portCount: solution.ports.length, + fieldSamples: field.eThetaReal.length, + finite, + wasmResponsesExpected: true, + }; + } finally { + await model.dispose(); + } +} + +try { + window.__NECPP_EXAMPLE_RESULT__ = await run(); + status.textContent = `Ready · @necpp-engine/wasm ${packageVersion}`; + status.classList.add("ready"); +} catch (error: unknown) { + const message = error instanceof NecError + ? `${error.code}: ${error.message}` + : error instanceof Error ? error.stack ?? error.message : String(error); + status.textContent = message; + status.classList.add("error"); + window.__NECPP_EXAMPLE_RESULT__ = { error: message }; +} diff --git a/examples/wasm-array-vite/src/style.css b/examples/wasm-array-vite/src/style.css new file mode 100644 index 00000000..16078010 --- /dev/null +++ b/examples/wasm-array-vite/src/style.css @@ -0,0 +1,197 @@ +:root { + color: #19231f; + background: #eef3ee; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +main { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0 80px; +} + +header { + max-width: 780px; + margin-bottom: 28px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 14px; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(3rem, 9vw, 6.6rem); + font-weight: 500; + letter-spacing: -0.055em; + line-height: 0.9; +} + +h2 { + margin-bottom: 0; + font-size: 1.35rem; +} + +.eyebrow { + margin-bottom: 12px; + color: #32725b; + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.lede { + color: #52635c; + font-size: 1.08rem; + line-height: 1.65; +} + +#status { + width: fit-content; + margin-bottom: 22px; + padding: 9px 13px; + border: 1px solid #ccd9d0; + border-radius: 999px; + color: #52635c; + background: #f8faf8; + font-size: 0.82rem; +} + +#status.ready { + border-color: #9bcbb9; + color: #1f654d; + background: #e7f5ed; +} + +#status.error { + border-color: #dfaaa0; + color: #8b2e20; + background: #fff0ed; + white-space: pre-wrap; +} + +section { + margin-top: 18px; + padding: 24px; + overflow: hidden; + border: 1px solid #d6dfd8; + border-radius: 18px; + background: #fbfcfa; + box-shadow: 0 14px 38px rgb(35 55 45 / 7%); +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 28px; + margin-bottom: 20px; +} + +.section-heading p:last-child { + max-width: 480px; + margin-bottom: 0; + color: #66746e; + font-size: 0.88rem; + text-align: right; +} + +.table-scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +th, +td { + padding: 11px 12px; + border-bottom: 1px solid #e0e7e2; + text-align: right; +} + +th:first-child, +td:first-child { + text-align: left; +} + +th { + color: #52635c; + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +td { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; +} + +tr:last-child td, +tr:last-child th { + border-bottom: 0; +} + +#plot { + display: block; + width: 100%; + min-width: 640px; + color: #697970; +} + +.grid line { + stroke: #dbe3dd; + stroke-width: 1; +} + +.grid text { + fill: currentColor; + font-size: 11px; +} + +.pattern { + fill: none; + stroke: #e55c3c; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.5; +} + +@media (max-width: 720px) { + main { + width: min(100% - 20px, 1120px); + padding-top: 34px; + } + + section { + padding: 18px; + } + + .section-heading { + display: block; + } + + .section-heading p:last-child { + margin-top: 9px; + text-align: left; + } +} diff --git a/examples/wasm-array-vite/tsconfig.json b/examples/wasm-array-vite/tsconfig.json new file mode 100644 index 00000000..24e7290d --- /dev/null +++ b/examples/wasm-array-vite/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "lib": ["ES2024", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2024", + "useUnknownInCatchVariables": true + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/examples/wasm-array-vite/vite.config.ts b/examples/wasm-array-vite/vite.config.ts new file mode 100644 index 00000000..5c0f7a7f --- /dev/null +++ b/examples/wasm-array-vite/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { target: "es2024" }, + worker: { format: "es" }, +}); diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 77995171..6c82cc70 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -1,43 +1,22 @@ # `@necpp-engine/wasm` -Stateful NEC2++ antenna solver for Node and the browser. Consumers import a -high-level TypeScript API; they never copy WASM artifacts, parse NEC reports, -or touch Emscripten handles. +Stateful NEC2++ antenna simulation for Node and browsers, exposed through a +handwritten TypeScript API. The package owns the WebAssembly details: callers +do not copy artifacts, build NEC decks, parse reports, or handle native +pointers. -## License - -This package is **GPL-2.0-or-later**, the same license as NEC2++. Shipping the -JavaScript loader or `nec2pp.wasm` binary to users is distribution of GPL -software. A product that includes this package must comply with GPL-2.0 or -later, including the obligation to provide corresponding source. Review those -obligations for the intended product before shipping. The full license text is -in `COPYING`. - -Publication to the public npm registry requires control of the `necpp-engine` scope. -Tagged releases are gated by the full WP8 CI pipeline; until registry access is -configured, install the exact packed tarball produced by that workflow. +> **License:** this package and its `nec2pp.wasm` engine are +> **GPL-2.0-or-later**. Distributing an application that includes or serves the +> package is distribution of GPL software. Read [License](#license) before +> shipping it in a product. -## Install - -From a packed tarball: - -```bash -npm install ./necpp-engine-wasm-0.0.0-wp8.tgz -``` +## Five-minute dipole -The package is ESM-only (`"type": "module"`). Node 24 or later is required. - -## Quick start +Install with `npm install @necpp-engine/wasm`. The package is ESM-only and +requires Node 24 or newer. ```ts -import { - abiVersion, - createNecModel, - engineVersion, - packageVersion, -} from "@necpp-engine/wasm"; - -console.log({ packageVersion, engineVersion, abiVersion }); +import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel(); @@ -50,10 +29,10 @@ try { radiusM: 0.001, }); model.completeGeometry(); - model.definePorts([{ tag: 1, segment: 6 }]); + model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); model.prepare({ frequencyMHz: 300 }); - const impedance = model.computeImpedanceMatrix(); + const { impedance, admittance } = model.computeImpedanceMatrix(); const solution = model.solveCurrents({ real: new Float64Array([1]), imag: new Float64Array([0]), @@ -61,75 +40,363 @@ try { const field = model.computeFarField({ radiusM: 1, theta: { startDeg: 0, count: 181, stepDeg: 1 }, - phi: { startDeg: 0, count: 361, stepDeg: 1 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, }); - void impedance; - void solution; - void field; + console.log({ + zOhm: [impedance.real[0], impedance.imag[0]], + ySiemens: [admittance.real[0], admittance.imag[0]], + requiredVoltageV: [solution.voltages.real[0], solution.voltages.imag[0]], + fieldSamples: field.eThetaReal.length, + }); } finally { model.dispose(); } ``` -Geometry is metres, frequency is MHz, port current is positive into the -antenna, and far fields are complex V/m. See the -[numerical and API contract](https://github.com/andrekuehne/necpp/blob/master/docs/wasm-api.md). +This complete example is executed from the packed npm tarball in CI. + +## Numerical conventions + +- Coordinates, wire radius, and field radius are metres. Public frequencies + are MHz. +- Phasors use `e^(+j omega t)` and outgoing propagation uses `e^(-jkR)`. +- Port voltage is in complex volts. Port current is in complex amperes and is + positive **into** the modeled antenna. +- `V = Z I` and `I = Y V`; impedance is in ohms and admittance is in siemens. +- Complex far-field components are V/m. `radiusM` defaults to 1 m and is + retained in every result. +- Theta is the polar angle down from +Z. Phi is azimuth from +X toward +Y. +- Matrices are row-major: `index = row * columns + column`. Far-field samples + are theta-fast: `index = phiIndex * thetaCount + thetaIndex`. +- Fields are referenced to the model origin and remain far-field + approximations even when a small radius is requested. +- Every returned typed array is a JavaScript-owned copy. It remains valid + across later solves, WebAssembly memory growth, and model disposal. + +Coordinate orientation: + +```text + +Z theta=0 deg + | + |\ r + | \ + | * sample + | / theta + |/ + +Y origin -------- +X phi=0 deg + \ / + \ phi / + \------/ + +Phi increases from +X toward +Y; theta increases from +Z toward the XY plane. +``` + +The full normative contract, including loads, ground, tolerances, and every +state transition, is in +[`docs/wasm-api.md`](https://github.com/andrekuehne/necpp/blob/main/docs/wasm-api.md). -## Versions +## Geometry and ports -| Identifier | Meaning | -|---|---| -| `packageVersion` | npm package version of this TypeScript API | -| `engineVersion` | NEC2++ version compiled into the shipped WASM | -| `abiVersion` | Stable C ABI (`necpp_wasm_v1`); currently `1` | +Build geometry first, complete it once, then define an ordered port list. +Wire tags are positive integers. A port segment is one-based among all +segments carrying that tag, so an 11-segment dipole is centre-fed at segment +6. Port order fixes the order used by every vector, matrix row/column, and +embedded field basis. + +The initial environment is free space with no loads. Call `addLoad()`, +`clearLoads()`, or `setGround()` after geometry completion and before +`prepare()`. Changing ground or loads later is allowed, but invalidates the +factorization and returns the model to `geometry-complete`. + +## Z and Y matrices + +`computeImpedanceMatrix()` factors the electromagnetic interaction matrix once +and returns both port matrices. Entry `Z[row,column]` is the voltage at port +`row` produced by a unit current at port `column`, with all other requested +port currents zero. `Y` has the analogous voltage-driven interpretation. + +```ts +import type { ComplexMatrix } from "@necpp-engine/wasm"; + +function entry(matrix: ComplexMatrix, row: number, column: number) { + const index = row * matrix.columns + column; + return { + real: matrix.real[index], + imag: matrix.imag[index], + }; +} + +declare const z: ComplexMatrix; +const selfImpedance = entry(z, 0, 0); +const mutualImpedance = entry(z, 0, 1); +console.log({ selfImpedance, mutualImpedance }); +``` + +The returned `conditionEstimate` is omitted only when the native implementation +cannot estimate it. Matrix formation throws `NecConditioningError` instead of +returning a singular or excessively ill-conditioned inverse. + +## Voltage- and current-driven arrays + +`solveVoltages()` applies exactly the requested simultaneous complex voltages. +`solveCurrents()` first computes the required voltages with `V = Z I`, then +executes one simultaneous source solve. Both return achieved voltages and +currents, per-port powers, and active impedances in stable port order. + +```ts +import type { NecModel } from "@necpp-engine/wasm"; + +declare const model: NecModel; + +const voltageDriven = model.solveVoltages({ + real: new Float64Array([1, 0]), + imag: new Float64Array([0, 1]), +}); + +const currentDriven = model.solveCurrents({ + real: new Float64Array([1, 0]), + imag: new Float64Array([0, -1]), +}); + +console.log(voltageDriven.currents, currentDriven.voltages); +``` + +Matrix impedance and active impedance are different quantities. `Z[i,j]` is a +fixed property of the prepared model. Active impedance is `V[i] / I[i]` for +one particular simultaneous excitation, so mutual coupling makes it change +when array weights change. An exactly zero achieved current produces +`NaN + jNaN` active impedance; inspect the voltage/current vectors instead of +dividing by zero. Time-average input power is +`0.5 * Re(V * conjugate(I))` watts. + +## Complex far fields and beamforming + +`computeFarField()` uses the most recent public solve. At the default 1 m, +`eTheta*` and `ePhi*` are split real/imaginary V/m arrays. At another range, +the field follows `e^(-jkR) / R` while retaining the same angular far-field +approximation. + +`computeEmbeddedFarFields()` returns one complex basis pattern per port. +Unit-current normalization makes array beamforming a direct weighted sum. The +arrays are basis-major, followed by the normal theta-fast sample layout. + +```ts +import type { + EmbeddedFarFieldResult, + NecModel, +} from "@necpp-engine/wasm"; + +declare const model: NecModel; -The facade refuses to instantiate a binary whose ABI or engine version does -not match these constants. +const embedded = model.computeEmbeddedFarFields( + { + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + }, + { kind: "unit-current", valueA: 1 }, +); + +const phaseStepRad = Math.PI / 3; +const weights = embedded.ports.map((_, port) => ({ + real: Math.cos(port * phaseStepRad), + imag: Math.sin(port * phaseStepRad), +})); + +function combineETheta(basis: EmbeddedFarFieldResult) { + const real = new Float64Array(basis.samplesPerPort); + const imag = new Float64Array(basis.samplesPerPort); + for (let port = 0; port < basis.ports.length; port += 1) { + const weight = weights[port]!; + for (let sample = 0; sample < basis.samplesPerPort; sample += 1) { + const index = port * basis.samplesPerPort + sample; + const er = basis.eThetaReal[index]!; + const ei = basis.eThetaImag[index]!; + real[sample] = real[sample]! + weight.real * er - weight.imag * ei; + imag[sample] = imag[sample]! + weight.real * ei + weight.imag * er; + } + } + return { real, imag }; +} + +console.log(combineETheta(embedded)); +``` + +For a one-off excitation, `solveCurrents()` plus `computeFarField()` is simpler. +Embedded fields are useful when many weight sets share one geometry and +frequency: compute the bases once, then combine them in JavaScript without +additional native solves. -## Loading WASM +## Direct mode and worker mode -By default the adjacent `nec2pp.wasm` is resolved with: +| Mode | Import | Calls | Best for | +|---|---|---|---| +| Direct | `@necpp-engine/wasm` | Synchronous after creation | Node, tests, small browser models | +| Worker | `@necpp-engine/wasm/worker` | Asynchronous and serialized | Browser UI and realistic solves | + +The factory is always asynchronous because it instantiates WebAssembly. A +direct browser solve then occupies the main thread until it finishes. The +worker facade preserves model state in a package-supplied module worker and +transfers large result buffers back to the caller. ```ts -new URL("./nec2pp.wasm", import.meta.url) +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; + +const model = await createNecWorkerModel({ + onProgress: ({ operation, phase }) => console.log(operation, phase), +}); + +try { + await model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6 }]); + await model.prepare({ frequencyMHz: 300 }); + console.log(await model.computeImpedanceMatrix()); +} finally { + await model.dispose(); +} ``` -Overrides: +Worker calls cannot interrupt a synchronous native calculation. Use +`model.terminate()` for immediate cancellation; it kills the worker and +rejects outstanding operations. Create a new model to continue afterward. + +## Lifecycle and disposal + +The normal lifecycle is +`empty -> geometry-building -> geometry-complete -> prepared -> solved`. +`computeImpedanceMatrix()` and embedded-field calculation are legal while +prepared; combined far fields require a latest solution. Repeating +`prepare()` at the same frequency is idempotent. New excitations and field +grids reuse the retained factorization. Geometry cannot change after +`completeGeometry()`. + +Always dispose in `finally`. Direct `dispose()` and worker `await dispose()` +are idempotent. Every other operation after disposal throws `NecStateError`. -- `wasmUrl` — file URL, HTTP(S) CDN URL, or path resolved against the package -- `wasmBinary` — caller-owned `ArrayBuffer` or `Uint8Array` (copied) +## Node, browser, Vite, and CDN loading -Bundlers such as Vite rewrite the default `import.meta.url` resolution; no -extra consumer config or artifact copying is required for `createNecModel()`. -Apps that import `@necpp-engine/wasm/worker` and bundle with Vite should set: +Node and browsers use the same package and public types. By default, +`nec2pp.wasm` is resolved beside the installed JavaScript with +`new URL("./nec2pp.wasm", import.meta.url)`; consumers do not copy it. -```js -export default { +Direct mode needs no Vite configuration. For the module-worker entry point, +use this Vite configuration: + +```ts +import { defineConfig } from "vite"; + +export default defineConfig({ build: { target: "es2024" }, worker: { format: "es" }, -}; +}); ``` -`worker.format` is Vite's setting for `{ type: "module" }` workers. `build.target` -must support the ES2024 syntax used by the package. Direct `createNecModel()` -still needs no application source changes and no artifact copying. +Production servers should serve `.wasm` as `application/wasm`. To host the +binary on a CDN, pass an HTTP(S) URL. Cross-origin servers must also send an +appropriate CORS header. -## Worker entry +```ts +import { createNecModel } from "@necpp-engine/wasm"; + +const model = await createNecModel({ + wasmUrl: new URL("https://cdn.example.test/necpp/0.1.0/nec2pp.wasm"), +}); +model.dispose(); +``` -Large browser solves should use the worker subpath: +`wasmBinary` accepts an `ArrayBuffer` or `Uint8Array` when the host application +wants to fetch/cache the bytes itself. `wasmUrl` and `wasmBinary` are mutually +exclusive. `runDeck(deck)` remains available as a compatibility escape hatch +for complete NEC text decks. + +## Error handling + +Every package-defined operational error derives from `NecError` and has a +stable `code`: `NEC_STATE`, `NEC_INPUT`, `NEC_GEOMETRY`, `NEC_PORT`, +`NEC_CONDITIONING`, `NEC_SOLVER`, or `NEC_RUNTIME`. Messages and `details` are +diagnostic rather than a compatibility contract. ```ts -import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; +import { + NecConditioningError, + NecError, + createNecModel, +} from "@necpp-engine/wasm"; -const model = await createNecWorkerModel(); +try { + const model = await createNecModel(); + try { + model.computeImpedanceMatrix(); + } finally { + model.dispose(); + } +} catch (error: unknown) { + if (error instanceof NecConditioningError) { + console.error("Port matrix cannot be inverted reliably", error.details); + } else if (error instanceof NecError) { + console.error(error.code, error.message, error.details); + } else { + throw error; + } +} ``` -The package ships the worker script. Methods are asynchronous, serialized per -model, and otherwise match `NecModel`. `terminate()` is cancellation; -`dispose()` destroys the native handle first. +## Performance and browser memory + +- Segment count dominates factorization time and memory. Thin-wire modeling + still requires physically sensible segment length/radius ratios; begin with + modest odd segment counts and refine while checking convergence. +- Keep a prepared model alive while changing excitations or angular grids. + Recreating it discards the expensive factorization. +- Prefer embedded fields when exploring many array weights at one frequency. +- Field storage scales with `theta.count * phi.count`; embedded storage also + multiplies by port count and by four `Float64Array` components. A full + 181 x 361 field is about 2 MiB for the four component arrays; four embedded + bases are about 8 MiB, excluding axes and temporary/native storage. +- Returned arrays are copies, so release references when results are no longer + needed. Compute cuts instead of dense spheres when possible. +- Use worker mode for browser responsiveness. Each worker model owns an + isolated WebAssembly instance and memory, so dispose unused models rather + than pooling many idle workers. +- There is no shared-memory or thread requirement. Normal cross-origin + isolation headers are not needed for this package. + +## Complete Vite array example + +The repository's +[four-element array application](https://github.com/andrekuehne/necpp/tree/main/examples/wasm-array-vite) +installs the packed package, computes Z/Y, applies progressive complex current +weights, displays achieved port quantities, and plots a normalized azimuth +cut. CI builds and runs that exact application in Chromium from the same +tarball used by every release gate. + +## Versions + +| Export | Meaning | +|---|---| +| `packageVersion` | Semantic version of the public TypeScript API | +| `engineVersion` | NEC2++ version compiled into the shipped WebAssembly | +| `abiVersion` | Stable internal C ABI; currently `1` | + +Instantiation rejects a binary whose ABI or engine version does not match the +facade. Package and engine versions intentionally have independent version +lines; the release records both. + +## License -## Compatibility deck runner +`@necpp-engine/wasm` is distributed under **GPL-2.0-or-later**, matching +NEC2++. The npm tarball includes `COPYING` with the full license text. -`runDeck(deck)` executes a complete NEC text deck and returns the formatted -report. It is independent of `NecModel`. +If you convey the JavaScript loader, WebAssembly binary, or an application +containing them, review and satisfy the GPL's corresponding-source, license, +and notice requirements for your distribution. This README is a technical +notice, not legal advice. Consult qualified counsel for a product-specific +licensing decision. diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 83b9d497..c3dfc816 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.0.0-wp8", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.0.0-wp8", + "version": "0.1.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 2888a0ec..6899fd78 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,16 +1,32 @@ { "name": "@necpp-engine/wasm", - "version": "0.0.0-wp8", + "version": "0.1.0", "private": false, "type": "module", "description": "Stateful NEC2++ electromagnetic solver for Node and the browser", + "keywords": [ + "antenna", + "electromagnetics", + "nec2", + "simulation", + "typescript", + "wasm", + "webassembly" + ], "license": "GPL-2.0-or-later", "repository": { "type": "git", "url": "git+https://github.com/andrekuehne/necpp.git", "directory": "packages/necpp-wasm" }, - "homepage": "https://github.com/andrekuehne/necpp", + "homepage": "https://github.com/andrekuehne/necpp#readme", + "bugs": { + "url": "https://github.com/andrekuehne/necpp/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "engines": { "node": ">=24" }, diff --git a/packages/necpp-wasm/scripts/pack-release.mjs b/packages/necpp-wasm/scripts/pack-release.mjs index 2e8841f6..ab6ecb78 100644 --- a/packages/necpp-wasm/scripts/pack-release.mjs +++ b/packages/necpp-wasm/scripts/pack-release.mjs @@ -18,6 +18,12 @@ const packageJson = JSON.parse( if (packageJson.private === true) { throw new Error("Refusing to create a release tarball while package.json is private"); } +if ( + packageJson.publishConfig?.access !== "public" + || packageJson.publishConfig?.registry !== "https://registry.npmjs.org/" +) { + throw new Error("Release package must target the public npm registry with public access"); +} mkdirSync(outputDirectory, { recursive: true }); diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index ca86f9d4..61c8bc9b 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.0.0-wp8"; +export const packageVersion = "0.1.0"; export const abiVersion = 1; export const engineVersion = "2.3.4"; diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index 23ba8344..29fb383f 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { once } from "node:events"; import { + cpSync, existsSync, mkdtempSync, mkdirSync, @@ -16,8 +17,8 @@ import { dirname, join, resolve } from "node:path"; import { chromium } from "playwright"; const mode = process.argv[2]; -if (mode !== "direct" && mode !== "worker") { - throw new Error("usage: npm run test:browser -- direct|worker"); +if (mode !== "direct" && mode !== "worker" && mode !== "example") { + throw new Error("usage: npm run test:browser -- direct|worker|example"); } const tarballValue = process.env.NECPP_WASM_TARBALL; @@ -34,6 +35,7 @@ const packageJson = JSON.parse( readFileSync(join(packageDirectory, "package.json"), "utf8"), ); const fixture = mkdtempSync(join(tmpdir(), `necpp-wasm-browser-${mode}-`)); +const repositoryRoot = resolve(packageDirectory, "../.."); function writeFixture(relativePath, contents) { const path = join(fixture, relativePath); @@ -138,26 +140,37 @@ const modelImport = mode === "direct" const factory = mode === "direct" ? "createNecModel" : "createNecWorkerModel"; const awaitPrefix = mode === "direct" ? "" : "await "; -writeFixture("package.json", `${JSON.stringify({ - name: `necpp-browser-${mode}-fixture`, - private: true, - type: "module", - dependencies: { +if (mode === "example") { + cpSync(resolve(repositoryRoot, "examples/wasm-array-vite"), fixture, { + force: true, + recursive: true, + }); + const examplePackageJson = JSON.parse(readFileSync(join(fixture, "package.json"), "utf8")); + examplePackageJson.dependencies = { "@necpp-engine/wasm": `file:${tarball.replaceAll("\\", "/")}`, - }, - devDependencies: { - vite: "6.3.5", - }, -}, null, 2)}\n`); -writeFixture("vite.config.js", `export default { + }; + writeFixture("package.json", `${JSON.stringify(examplePackageJson, null, 2)}\n`); +} else { + writeFixture("package.json", `${JSON.stringify({ + name: `necpp-browser-${mode}-fixture`, + private: true, + type: "module", + dependencies: { + "@necpp-engine/wasm": `file:${tarball.replaceAll("\\", "/")}`, + }, + devDependencies: { + vite: "6.3.5", + }, + }, null, 2)}\n`); + writeFixture("vite.config.js", `export default { build: { target: "es2024" }, worker: { format: "es" }, }; `); -writeFixture("index.html", ` + writeFixture("index.html", `
loading
`); -writeFixture("main.js", `${modelImport} + writeFixture("main.js", `${modelImport} const out = document.getElementById("out"); try { @@ -201,6 +214,7 @@ try { } out.textContent = JSON.stringify(window.__NEC_RESULT__); `); +} let preview; let browser; @@ -225,26 +239,40 @@ try { } }); await page.goto(preview.origin, { waitUntil: "domcontentloaded" }); - await page.waitForFunction(() => window.__NEC_RESULT__ !== undefined, null, { + await page.waitForFunction((testMode) => testMode === "example" + ? window.__NECPP_EXAMPLE_RESULT__ !== undefined + : window.__NEC_RESULT__ !== undefined, mode, { timeout: 120_000, }); - const result = await page.evaluate(() => window.__NEC_RESULT__); + const result = await page.evaluate((testMode) => testMode === "example" + ? window.__NECPP_EXAMPLE_RESULT__ + : window.__NEC_RESULT__, mode); assert.deepEqual(browserErrors, []); assert.equal(result.error, undefined, result.error); - assert.equal(result.mode, mode); assert.equal(result.packageVersion, packageJson.version); - assert.equal(result.abiVersion, 1); - assert.equal(result.engineVersion, "2.3.4"); - assert.ok(result.resistanceOhm > 0); - assert.equal(result.fieldSamples, 3); - assert.equal(result.fieldFinite, true); + if (mode === "example") { + assert.equal(result.ready, true); + assert.equal(result.portCount, 4); + assert.equal(result.fieldSamples, 361); + assert.equal(result.finite, true); + assert.equal(await page.locator("#ports tbody tr").count(), 4); + assert.equal(await page.locator("#matrix tbody tr").count(), 5); + assert.equal(await page.locator("#plot .pattern").count(), 1); + } else { + assert.equal(result.mode, mode); + assert.equal(result.abiVersion, 1); + assert.equal(result.engineVersion, "2.3.4"); + assert.ok(result.resistanceOhm > 0); + assert.equal(result.fieldSamples, 3); + assert.equal(result.fieldFinite, true); + } assert.ok(wasmResponses.length >= 1, "the browser must request the emitted WASM asset"); assert.ok( wasmResponses.every((contentType) => /application\/wasm/.test(contentType)), `unexpected WASM MIME types: ${wasmResponses.join(", ")}`, ); process.stdout.write( - `Browser ${mode} integration passed (${result.resistanceOhm} ohm)\n`, + `Browser ${mode} integration passed\n`, ); } finally { await browser?.close(); diff --git a/packages/necpp-wasm/test/ci-workflow.test.mjs b/packages/necpp-wasm/test/ci-workflow.test.mjs index 057908cc..a78870a3 100644 --- a/packages/necpp-wasm/test/ci-workflow.test.mjs +++ b/packages/necpp-wasm/test/ci-workflow.test.mjs @@ -69,6 +69,7 @@ test("WP8 workflow packs once and publishes the tested tarball", () => { assert.match(workflowSource, /sha256sum --check SHA256SUMS/); assert.match(workflowSource, /npm publish "\$tarball" --access public --provenance/); assert.match(workflowSource, /gh release create/); + assert.match(workflowSource, /run test:browser -- example/); const wasmSteps = workflow.jobs["wasm-build"].steps; assert.equal( diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 89874b4e..65dfb08d 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.0.0-wp8"); + assert.equal(packageVersion, "0.1.0"); assert.equal(engineVersion, "2.3.4"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 63f3de52..1f756107 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -164,6 +164,45 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); }); +test("every package README TypeScript example compiles and the quick start executes", { + skip, +}, () => { + const fixture = createCleanFixture("readme"); + installFixture(fixture.root, [ + `@types/node@${packageJson.devDependencies["@types/node"]}`, + `typescript@${packageJson.devDependencies.typescript}`, + `vite@${VITE_VERSION}`, + ]); + + const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8"); + const examples = [...readme.matchAll(/```ts\r?\n([\s\S]*?)```/g)] + .map((match) => match[1]); + assert.ok(examples.length >= 7, "expected all documented TypeScript examples"); + + const paths = examples.map((source, index) => { + const path = `readme-example-${index + 1}.ts`; + writeFixtureFile(fixture.root, path, source); + return path; + }); + const tsc = join(fixture.root, "node_modules", "typescript", "bin", "tsc"); + run(process.execPath, [ + tsc, + "--noEmit", + "--strict", + "--noUncheckedIndexedAccess", + "--exactOptionalPropertyTypes", + "--skipLibCheck", + "--target", "ES2024", + "--module", "NodeNext", + "--moduleResolution", "NodeNext", + "--lib", "ES2024,DOM", + ...paths, + ], { cwd: fixture.root }); + + writeFixtureFile(fixture.root, "readme-quick-start.mjs", examples[0]); + run("node", ["readme-quick-start.mjs"], { cwd: fixture.root }); +}); + test("custom wasmUrl loads the binary from an HTTP CDN-style origin", { skip, }, async () => { diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 22f7ade3..64931bf1 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -17,7 +17,16 @@ 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.1.0"); assert.equal(packageJson.engines.node, ">=24"); + assert.deepEqual(packageJson.publishConfig, { + access: "public", + registry: "https://registry.npmjs.org/", + }); + assert.equal(packageJson.repository.url, "git+https://github.com/andrekuehne/necpp.git"); + assert.equal(packageJson.bugs.url, "https://github.com/andrekuehne/necpp/issues"); + assert.ok(packageJson.keywords.includes("nec2")); + assert.ok(packageJson.keywords.includes("wasm")); const files = new Set(packed.files); const required = [ From 66e8f19fdee6b83220d6294d3f0b309fdfc97dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 18:56:37 +0200 Subject: [PATCH 28/46] Fix emscripten build --- .github/workflows/build.yml | 8 +++----- CHANGELOG.md | 1 + INSTALL.md | 2 +- docs/local-build-environment.md | 1 - docs/ts_engine_plan.md | 2 +- docs/wp8-ci-release.md | 14 ++++++++------ packages/necpp-wasm/test/ci-workflow.test.mjs | 6 ++++++ scripts/build_wasm_docker.ps1 | 1 - scripts/build_wasm_docker.sh | 1 - scripts/build_wasm_inner.sh | 6 ++---- 10 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48d0ab18..0848b8be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,8 +88,7 @@ jobs: run: | test -s wasm/nec2pp.js test -s wasm/nec2pp.wasm - test -s wasm/nec2pp.d.ts - sha256sum wasm/nec2pp.js wasm/nec2pp.wasm wasm/nec2pp.d.ts \ + sha256sum wasm/nec2pp.js wasm/nec2pp.wasm \ > wasm/SHA256SUMS ls -lh wasm - name: Upload raw WASM build @@ -323,9 +322,8 @@ jobs: \( -name '*.map' -o -name '*.debug' -o -name '*.dSYM' \) -print -quit)" mkdir release cp package-artifact/*.tgz release/ - cp wasm-artifact/nec2pp.js wasm-artifact/nec2pp.wasm \ - wasm-artifact/nec2pp.d.ts release/ - (cd release && sha256sum *.tgz nec2pp.js nec2pp.wasm nec2pp.d.ts > SHA256SUMS) + cp wasm-artifact/nec2pp.js wasm-artifact/nec2pp.wasm release/ + (cd release && sha256sum *.tgz nec2pp.js nec2pp.wasm > SHA256SUMS) { echo "Pinned Emscripten image: $EMSCRIPTEN_IMAGE" echo "Pinned TypeScript: $TYPESCRIPT_VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e049124..de2684fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. ### Bug Fixes +* **The pinned Emscripten CI build no longer requires an undeclared `tsc`:** removed the unused `--emit-tsd` output from the compile-only Docker job and its artifact/checksum pipeline. The package already uses a committed handwritten internal module declaration, so the generated declaration was redundant. A workflow regression test now keeps TypeScript out of the Emscripten container. * **`nec2diff` no longer reports spurious radiation-pattern differences:** at angles where the polarization is undefined (HORIZ gain `-999.99`, e.g. at the horizon) NEC leaves the SENSE column blank. The `RadiationInput` parser read the next numeric token as the sense string, shifting the remaining columns left by one, and `read_fixed`/`read_sci` returned **uninitialized memory** when the stream was exhausted — so comparing a file against itself reported a nonzero difference with garbage values (observed on `bruce_sommerfeld`). The parser now detects the blank SENSE column, and the readers return 0.0 on extraction failure. All 52 testharness decks now self-compare exactly clean; genuine differences are still flagged. ### Performance diff --git a/INSTALL.md b/INSTALL.md index ea817a45..8c010c4c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -89,7 +89,7 @@ Two ways: emcmake cmake -B build-wasm -DNECPP_BUILD_WASM=ON -DNECPP_BUILD_TESTS=OFF cmake --build build-wasm -j4 mkdir -p wasm - cp build-wasm/src/nec2pp.js build-wasm/src/nec2pp.wasm build-wasm/src/nec2pp.d.ts wasm/ + cp build-wasm/src/nec2pp.js build-wasm/src/nec2pp.wasm wasm/ **Docker wrapper** (no local emsdk needed): diff --git a/docs/local-build-environment.md b/docs/local-build-environment.md index 2b60b86c..ebd27434 100644 --- a/docs/local-build-environment.md +++ b/docs/local-build-environment.md @@ -129,7 +129,6 @@ Windows bind mount. Successful artifacts are copied to: ```text wasm/nec2pp.js wasm/nec2pp.wasm -wasm/nec2pp.d.ts ``` The wrapper runs `scripts/wasm_smoke_test.mjs`, stages the generated loader and diff --git a/docs/ts_engine_plan.md b/docs/ts_engine_plan.md index 8ac19ef3..53c87558 100644 --- a/docs/ts_engine_plan.md +++ b/docs/ts_engine_plan.md @@ -783,7 +783,7 @@ The Node ABI, facade, and packed-consumer jobs run on Node 24. The Emscripten container only compiles the WASM artifacts; JavaScript verification runs on the Node 24 host after the artifacts are copied out. -Keep Emscripten and TypeScript versions pinned. Upgrade them deliberately in isolated changes. Emscripten’s `--emit-tsd` may continue producing internal glue typings, but the handwritten package types remain authoritative. [Emscripten compiler documentation](https://emscripten.org/docs/tools_reference/emcc.html) +Keep Emscripten and TypeScript versions pinned. Upgrade them deliberately in isolated changes. The build does not use Emscripten's `--emit-tsd`: Emscripten 4.0.7 delegates that option to an external `tsc`, which would couple the compile-only container to the JavaScript toolchain. The committed handwritten internal glue declaration and public package types remain authoritative. [Emscripten compiler documentation](https://emscripten.org/docs/tools_reference/emcc.html) Initial accidental-debug-build guards can be generous: diff --git a/docs/wp8-ci-release.md b/docs/wp8-ci-release.md index 9972036e..853ae5fa 100644 --- a/docs/wp8-ci-release.md +++ b/docs/wp8-ci-release.md @@ -14,17 +14,19 @@ The workflow declares its release tool versions once: - TypeScript 5.8.3; - Playwright 1.62.1. -The Emscripten container only invokes `scripts/build_wasm_inner.sh`. Node ABI, -TypeScript, package, and browser verification all run on the Node 24 host using -the artifacts copied out of that container. The npm lockfile fixes the complete +The Emscripten container only invokes `scripts/build_wasm_inner.sh`. It emits +the loader and WASM binary without invoking TypeScript. Node ABI, TypeScript, +package, and browser verification all run on the Node 24 host using the +artifacts copied out of that container. The npm lockfile fixes the complete JavaScript test dependency graph. ## Verification graph The native jobs independently cover the legacy Catch2/CLI suite and the WP1–4 -stateful port, matrix, far-field, and C ABI partitions. The generated loader, -WASM binary, and internal declaration file are uploaded once and consumed by -the Node ABI and facade jobs. +stateful port, matrix, far-field, and C ABI partitions. The generated loader +and WASM binary are uploaded once and consumed by the Node ABI and facade jobs. +The committed handwritten declaration for the generated factory remains the +authoritative internal TypeScript boundary. After the facade passes, `scripts/pack-release.mjs` creates one npm tarball. It validates the publish allowlist, license file, package version, source/debug diff --git a/packages/necpp-wasm/test/ci-workflow.test.mjs b/packages/necpp-wasm/test/ci-workflow.test.mjs index a78870a3..fc8446f0 100644 --- a/packages/necpp-wasm/test/ci-workflow.test.mjs +++ b/packages/necpp-wasm/test/ci-workflow.test.mjs @@ -12,6 +12,10 @@ const workflowSource = readFileSync( "utf8", ); const workflow = parse(workflowSource); +const innerBuildSource = readFileSync( + resolve(repositoryRoot, "scripts/build_wasm_inner.sh"), + "utf8", +); const packageJson = JSON.parse( readFileSync(resolve(packageDirectory, "package.json"), "utf8"), ); @@ -77,4 +81,6 @@ test("WP8 workflow packs once and publishes the tested tarball", () => { false, "the Emscripten container job must compile only", ); + assert.doesNotMatch(innerBuildSource, /--emit-tsd/); + assert.doesNotMatch(workflowSource, /nec2pp\.d\.ts/); }); diff --git a/scripts/build_wasm_docker.ps1 b/scripts/build_wasm_docker.ps1 index 2dd59318..c9155f47 100644 --- a/scripts/build_wasm_docker.ps1 +++ b/scripts/build_wasm_docker.ps1 @@ -3,7 +3,6 @@ # Produces in wasm/: # nec2pp.js # nec2pp.wasm -# nec2pp.d.ts # # Usage (from repo root or scripts/): # .\scripts\build_wasm_docker.ps1 diff --git a/scripts/build_wasm_docker.sh b/scripts/build_wasm_docker.sh index 55a7c4a1..f5c6020e 100755 --- a/scripts/build_wasm_docker.sh +++ b/scripts/build_wasm_docker.sh @@ -4,7 +4,6 @@ # Produces in wasm/: # nec2pp.js # nec2pp.wasm -# nec2pp.d.ts set -euo pipefail diff --git a/scripts/build_wasm_inner.sh b/scripts/build_wasm_inner.sh index 9aa93541..cd56479d 100644 --- a/scripts/build_wasm_inner.sh +++ b/scripts/build_wasm_inner.sh @@ -13,6 +13,7 @@ CONTAINER_BUILD_DIR="/tmp/necpp-${BUILD_DIR}" rm -f \ packages/necpp-wasm/src/nec2pp.generated.js \ packages/necpp-wasm/src/nec2pp.wasm +rm -f "$WASM_OUT_DIR/nec2pp.d.ts" rm -rf "$CONTAINER_BUILD_DIR" CXX_FLAGS="-O3 -DNDEBUG -flto -fexceptions" @@ -25,8 +26,7 @@ LINK_FLAGS="-O3 -flto \ -sEXIT_RUNTIME=0 \ -sALLOW_MEMORY_GROWTH=1 \ -sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAP32,HEAPF64 \ --sDISABLE_EXCEPTION_CATCHING=0 \ ---emit-tsd nec2pp.d.ts" +-sDISABLE_EXCEPTION_CATCHING=0" emcmake cmake -B "$CONTAINER_BUILD_DIR" -S . \ -DCMAKE_BUILD_TYPE=Release \ @@ -40,7 +40,6 @@ cmake --build "$CONTAINER_BUILD_DIR" --config Release -j"$(nproc)" test -s "$CONTAINER_BUILD_DIR/src/nec2pp.js" test -s "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" -test -s "$CONTAINER_BUILD_DIR/src/nec2pp.d.ts" cp "$CONTAINER_BUILD_DIR/src/nec2pp.js" \ packages/necpp-wasm/src/nec2pp.generated.js @@ -52,5 +51,4 @@ mkdir -p "$WASM_OUT_DIR" cp \ "$CONTAINER_BUILD_DIR/src/nec2pp.js" \ "$CONTAINER_BUILD_DIR/src/nec2pp.wasm" \ - "$CONTAINER_BUILD_DIR/src/nec2pp.d.ts" \ "$WASM_OUT_DIR/" From f3963137981a41f4f1255dae570e278d2460276e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 19:48:17 +0200 Subject: [PATCH 29/46] Fix npm tests --- CHANGELOG.md | 1 + packages/necpp-wasm/scripts/npm-cli.mjs | 46 +++++++++++++++++++ packages/necpp-wasm/scripts/pack-release.mjs | 23 +++------- .../necpp-wasm/test/browser-integration.mjs | 12 ++--- packages/necpp-wasm/test/npm-cli.test.mjs | 20 ++++++++ packages/necpp-wasm/test/pack/helpers.mjs | 27 ++++------- 6 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 packages/necpp-wasm/scripts/npm-cli.mjs create mode 100644 packages/necpp-wasm/test/npm-cli.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index de2684fd..681c3687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. ### Bug Fixes +* **Clean-consumer tests now locate npm under GitHub `setup-node`:** direct `node --test` jobs do not set `npm_execpath`, and the old helper assumed npm lived beside the Node binary as it commonly does on Windows. npm invocation now supports the POSIX `../lib/node_modules/npm` layout used by hosted runners, the Windows layout, and a PATH fallback, with a regression test that explicitly removes `npm_execpath`. * **The pinned Emscripten CI build no longer requires an undeclared `tsc`:** removed the unused `--emit-tsd` output from the compile-only Docker job and its artifact/checksum pipeline. The package already uses a committed handwritten internal module declaration, so the generated declaration was redundant. A workflow regression test now keeps TypeScript out of the Emscripten container. * **`nec2diff` no longer reports spurious radiation-pattern differences:** at angles where the polarization is undefined (HORIZ gain `-999.99`, e.g. at the horizon) NEC leaves the SENSE column blank. The `RadiationInput` parser read the next numeric token as the sense string, shifting the remaining columns left by one, and `read_fixed`/`read_sci` returned **uninitialized memory** when the stream was exhausted — so comparing a file against itself reported a nonzero difference with garbage values (observed on `bruce_sommerfeld`). The parser now detects the blank SENSE column, and the readers return 0.0 on extraction failure. All 52 testharness decks now self-compare exactly clean; genuine differences are still flagged. diff --git a/packages/necpp-wasm/scripts/npm-cli.mjs b/packages/necpp-wasm/scripts/npm-cli.mjs new file mode 100644 index 00000000..b697d454 --- /dev/null +++ b/packages/necpp-wasm/scripts/npm-cli.mjs @@ -0,0 +1,46 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +/** + * Resolve npm without assuming one Node distribution layout. + * + * `npm_execpath` exists under `npm run`, but not when CI invokes a test with + * `node` directly. setup-node installs npm under ../lib/node_modules on + * POSIX, while common Windows distributions place it beside node.exe. + */ +export function resolveNpmInvocation( + args, + { + env = process.env, + execPath = process.execPath, + platform = process.platform, + } = {}, +) { + const nodeDirectory = dirname(execPath); + const candidates = [ + env.npm_execpath, + resolve(nodeDirectory, "node_modules/npm/bin/npm-cli.js"), + resolve(nodeDirectory, "../lib/node_modules/npm/bin/npm-cli.js"), + ]; + const npmCli = candidates.find((candidate) => ( + typeof candidate === "string" + && candidate.length > 0 + && existsSync(candidate) + )); + + if (npmCli !== undefined) { + return { + command: execPath, + args: [npmCli, ...args], + shell: false, + }; + } + + // Last resort for nonstandard installations. A shell is needed for + // npm.cmd on Windows; POSIX can execute npm's shebang launcher directly. + return { + command: "npm", + args, + shell: platform === "win32", + }; +} diff --git a/packages/necpp-wasm/scripts/pack-release.mjs b/packages/necpp-wasm/scripts/pack-release.mjs index ab6ecb78..afaca5ad 100644 --- a/packages/necpp-wasm/scripts/pack-release.mjs +++ b/packages/necpp-wasm/scripts/pack-release.mjs @@ -7,7 +7,9 @@ import { statSync, writeFileSync, } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; + +import { resolveNpmInvocation } from "./npm-cli.mjs"; const packageDirectory = resolve(import.meta.dirname, ".."); const outputDirectory = resolve(process.argv[2] ?? join(packageDirectory, ".pack-work")); @@ -27,27 +29,16 @@ if ( mkdirSync(outputDirectory, { recursive: true }); -const configuredNpmCli = process.env.npm_execpath; -const bundledNpmCli = resolve( - dirname(process.execPath), - "node_modules/npm/bin/npm-cli.js", -); -const npmCli = typeof configuredNpmCli === "string" && configuredNpmCli.length > 0 - ? configuredNpmCli - : bundledNpmCli; -if (!existsSync(npmCli)) { - throw new Error(`Could not locate the npm CLI at ${npmCli}`); -} - -const packed = spawnSync(process.execPath, [ - npmCli, +const invocation = resolveNpmInvocation([ "pack", "--pack-destination", outputDirectory, "--json", -], { +]); +const packed = spawnSync(invocation.command, invocation.args, { cwd: packageDirectory, encoding: "utf8", + shell: invocation.shell, stdio: ["ignore", "pipe", "inherit"], }); if (packed.error) { diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index 29fb383f..9a46080d 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -16,6 +16,8 @@ import { dirname, join, resolve } from "node:path"; import { chromium } from "playwright"; +import { resolveNpmInvocation } from "../scripts/npm-cli.mjs"; + const mode = process.argv[2]; if (mode !== "direct" && mode !== "worker" && mode !== "example") { throw new Error("usage: npm run test:browser -- direct|worker|example"); @@ -44,17 +46,13 @@ function writeFixture(relativePath, contents) { } function run(command, args) { - const npmCli = process.env.npm_execpath ?? resolve( - dirname(process.execPath), - "node_modules/npm/bin/npm-cli.js", - ); const invocation = command === "npm" - ? { command: process.execPath, args: [npmCli, ...args] } - : { command, args }; + ? resolveNpmInvocation(args) + : { command, args, shell: false }; const result = spawnSync(invocation.command, invocation.args, { cwd: fixture, encoding: "utf8", - shell: false, + shell: invocation.shell, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); diff --git a/packages/necpp-wasm/test/npm-cli.test.mjs b/packages/necpp-wasm/test/npm-cli.test.mjs new file mode 100644 index 00000000..c3bae3bf --- /dev/null +++ b/packages/necpp-wasm/test/npm-cli.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { resolveNpmInvocation } from "../scripts/npm-cli.mjs"; + +test("npm resolves when node is invoked directly without npm_execpath", () => { + const env = { ...process.env }; + delete env.npm_execpath; + const invocation = resolveNpmInvocation(["--version"], { env }); + const result = spawnSync(invocation.command, invocation.args, { + encoding: "utf8", + env, + shell: invocation.shell, + windowsHide: true, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+/); +}); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 2f858563..689abebe 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -12,6 +12,8 @@ import { import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { resolveNpmInvocation } from "../../scripts/npm-cli.mjs"; + export const packageDirectory = resolve(import.meta.dirname, "../.."); export const VITE_VERSION = "6.3.5"; @@ -27,23 +29,9 @@ export const hasWasmArtifacts = ( function resolveSpawn(command, args) { if (command !== "npm") { - return { command, args }; - } - const configuredNpmCli = process.env.npm_execpath; - const bundledNpmCli = resolve( - dirname(process.execPath), - "node_modules/npm/bin/npm-cli.js", - ); - const npmCli = typeof configuredNpmCli === "string" && configuredNpmCli.length > 0 - ? configuredNpmCli - : bundledNpmCli; - if (!existsSync(npmCli)) { - throw new Error(`Could not locate the npm CLI at ${npmCli}`); + return { command, args, shell: false }; } - return { - command: process.execPath, - args: [npmCli, ...args], - }; + return resolveNpmInvocation(args); } export function run(command, args, options = {}) { @@ -52,7 +40,7 @@ export function run(command, args, options = {}) { cwd: options.cwd ?? packageDirectory, encoding: "utf8", env: options.env ?? process.env, - shell: options.shell ?? false, + shell: options.shell ?? invocation.shell, stdio: options.stdio ?? "inherit", windowsHide: true, }); @@ -70,10 +58,11 @@ export function run(command, args, options = {}) { export function runAsync(command, args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const invocation = resolveSpawn(command, args); + const child = spawn(invocation.command, invocation.args, { cwd: options.cwd ?? packageDirectory, env: options.env ?? process.env, - shell: options.shell ?? command === "npm", + shell: options.shell ?? invocation.shell, stdio: options.stdio ?? "inherit", windowsHide: true, }); From ff94c4099e2a02d61f8aeefe6ef4a3efade95033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 21:21:56 +0200 Subject: [PATCH 30/46] "Prepare @necpp-engine/wasm 0.1.0 release" --- CHANGELOG.md | 3 +- .../necpp-wasm/test/browser-integration.mjs | 43 ++++----- .../necpp-wasm/test/http-server-process.mjs | 89 +++++++++++++++++++ .../test/http-server-process.test.mjs | 44 +++++++++ .../necpp-wasm/test/pack/consumer.test.mjs | 64 ++++--------- 5 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 packages/necpp-wasm/test/http-server-process.mjs create mode 100644 packages/necpp-wasm/test/http-server-process.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 681c3687..c10a6f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ -## Unreleased +## 0.1.0 - 2026-08-28 ### Added * **Initial `@necpp-engine/wasm` 0.1.0 package:** a stateful, high-level TypeScript API for Node and browsers, direct and Web Worker entry points, complex multi-port Z/Y matrices and solves, complex far fields and embedded patterns, packed-tarball consumer tests, and a four-element Vite array example. * Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. ### Bug Fixes +* **Browser and clean-consumer preview tests no longer parse Vite's styled startup log:** GitHub runners can insert ANSI control sequences into the displayed URL, causing a healthy preview server to time out and leave an orphan process behind. Tests now probe the assigned HTTP origin directly and reliably terminate the preview process on success or startup failure. * **Clean-consumer tests now locate npm under GitHub `setup-node`:** direct `node --test` jobs do not set `npm_execpath`, and the old helper assumed npm lived beside the Node binary as it commonly does on Windows. npm invocation now supports the POSIX `../lib/node_modules/npm` layout used by hosted runners, the Windows layout, and a PATH fallback, with a regression test that explicitly removes `npm_execpath`. * **The pinned Emscripten CI build no longer requires an undeclared `tsc`:** removed the unused `--emit-tsd` output from the compile-only Docker job and its artifact/checksum pipeline. The package already uses a committed handwritten internal module declaration, so the generated declaration was redundant. A workflow regression test now keeps TypeScript out of the Emscripten container. * **`nec2diff` no longer reports spurious radiation-pattern differences:** at angles where the polarization is undefined (HORIZ gain `-999.99`, e.g. at the horizon) NEC leaves the SENSE column blank. The `RadiationInput` parser read the next numeric token as the sense string, shifting the remaining columns left by one, and `read_fixed`/`read_sci` returned **uninitialized memory** when the stream was exhausted — so comparing a file against itself reported a nonzero difference with garbage values (observed on `bruce_sommerfeld`). The parser now detects the blank SENSE column, and the readers return 0.0 on extraction failure. All 52 testharness decks now self-compare exactly clean; genuine differences are still flagged. diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index 9a46080d..b65c34ae 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { once } from "node:events"; import { cpSync, existsSync, @@ -17,6 +16,7 @@ import { dirname, join, resolve } from "node:path"; import { chromium } from "playwright"; import { resolveNpmInvocation } from "../scripts/npm-cli.mjs"; +import { stopChild, waitForHttpServer } from "./http-server-process.mjs"; const mode = process.argv[2]; if (mode !== "direct" && mode !== "worker" && mode !== "example") { @@ -101,34 +101,21 @@ async function startPreview() { }, ); let output = ""; - await new Promise((resolveReady, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Vite preview did not start:\n${output}`)); - }, 30_000); - const consume = (chunk) => { - output += chunk.toString(); - if (output.includes(`http://127.0.0.1:${port}`)) { - clearTimeout(timeout); - resolveReady(); - } - }; - child.stdout?.on("data", consume); - child.stderr?.on("data", consume); - child.on("error", reject); - child.on("exit", (code) => { - reject(new Error(`Vite preview exited ${code}:\n${output}`)); - }); - }); + const consume = (chunk) => { + output += chunk.toString(); + }; + child.stdout?.on("data", consume); + child.stderr?.on("data", consume); + const origin = `http://127.0.0.1:${port}`; + try { + await waitForHttpServer(child, origin, () => output); + } catch (error) { + await stopChild(child); + throw error; + } return { - origin: `http://127.0.0.1:${port}`, - async close() { - if (child.exitCode !== null) { - return; - } - const exited = once(child, "exit"); - child.kill(); - await exited; - }, + origin, + close: () => stopChild(child), }; } diff --git a/packages/necpp-wasm/test/http-server-process.mjs b/packages/necpp-wasm/test/http-server-process.mjs new file mode 100644 index 00000000..23c1530a --- /dev/null +++ b/packages/necpp-wasm/test/http-server-process.mjs @@ -0,0 +1,89 @@ +import { once } from "node:events"; +import { setTimeout as delay } from "node:timers/promises"; + +const isRunning = (child) => child.exitCode === null && child.signalCode === null; + +/** + * Wait for a spawned HTTP server to answer instead of parsing its human-facing + * console output. Tools such as Vite add ANSI styling when CI enables colors, + * which can split an otherwise visible URL with escape sequences. + */ +export async function waitForHttpServer( + child, + url, + getOutput, + { timeoutMs = 30_000, pollIntervalMs = 50 } = {}, +) { + const deadline = Date.now() + timeoutMs; + let processFailure; + const onError = (error) => { + processFailure = error; + }; + const onExit = (code, signal) => { + processFailure = new Error( + `HTTP server exited ${code ?? `from signal ${signal}`} before becoming ready`, + ); + }; + child.on("error", onError); + child.on("exit", onExit); + + try { + while (Date.now() < deadline) { + if (processFailure !== undefined) { + throw new Error(`${processFailure.message}:\n${getOutput()}`, { + cause: processFailure, + }); + } + + try { + const remainingMs = deadline - Date.now(); + const response = await fetch(url, { + signal: AbortSignal.timeout(Math.max(1, Math.min(1_000, remainingMs))), + }); + await response.body?.cancel(); + if (response.ok) { + return; + } + } catch (error) { + if (processFailure !== undefined) { + throw new Error(`${processFailure.message}:\n${getOutput()}`, { + cause: processFailure, + }); + } + if (Date.now() >= deadline) { + break; + } + } + + await delay(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now()))); + } + } finally { + child.off("error", onError); + child.off("exit", onExit); + } + + throw new Error(`HTTP server did not start at ${url}:\n${getOutput()}`); +} + +export async function stopChild(child) { + if (!isRunning(child)) { + return; + } + + const exited = once(child, "exit"); + child.kill(); + let forceKillTimer; + const forceKillDelay = new Promise((resolveDelay) => { + forceKillTimer = setTimeout(resolveDelay, 5_000); + forceKillTimer.unref(); + }); + await Promise.race([ + exited, + forceKillDelay, + ]); + clearTimeout(forceKillTimer); + if (isRunning(child)) { + child.kill("SIGKILL"); + await exited; + } +} diff --git a/packages/necpp-wasm/test/http-server-process.test.mjs b/packages/necpp-wasm/test/http-server-process.test.mjs new file mode 100644 index 00000000..b6a210ec --- /dev/null +++ b/packages/necpp-wasm/test/http-server-process.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import test from "node:test"; + +import { stopChild, waitForHttpServer } from "./http-server-process.mjs"; + +test("HTTP readiness does not depend on colorized server output", async (context) => { + const serverScript = ` + const { createServer } = require("node:http"); + let ready = false; + setTimeout(() => { ready = true; }, 100); + const server = createServer((_request, response) => { + response.writeHead(ready ? 200 : 503); + response.end(ready ? "ready" : "starting"); + }); + server.listen(0, "127.0.0.1", () => { + process.stdout.write("\\u001b[36mLocal: http://\\u001b[1m127.0.0.1\\u001b[22m\\u001b[36m\\u001b[39m\\n"); + process.send(server.address().port); + }); + `; + const child = spawn(process.execPath, ["-e", serverScript], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + windowsHide: true, + }); + context.after(() => stopChild(child)); + + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + const [port] = await once(child, "message"); + const origin = `http://127.0.0.1:${port}`; + + assert.equal(output.includes(origin), false); + await waitForHttpServer(child, origin, () => output, { timeoutMs: 5_000 }); + assert.equal((await fetch(origin)).status, 200); + + await stopChild(child); + assert.equal(child.exitCode !== null || child.signalCode !== null, true); +}); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 1f756107..e9f5c78b 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { once } from "node:events"; import { createServer as createNetServer } from "node:net"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; +import { stopChild, waitForHttpServer } from "../http-server-process.mjs"; + import { VITE_VERSION, cdnDipoleScript, @@ -84,54 +85,21 @@ async function startVitePreview(root) { ); let output = ""; - let settled = false; - let timeout; - - const ready = new Promise((resolve, reject) => { - const fail = (reason) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - reject(reason instanceof Error ? reason : new Error(String(reason))); - }; - const succeed = () => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(); - }; - const onChunk = (chunk) => { - output += chunk.toString(); - if (output.includes(`http://127.0.0.1:${port}`)) { - succeed(); - } - }; - child.stdout?.on("data", onChunk); - child.stderr?.on("data", onChunk); - child.on("error", fail); - child.on("exit", (code) => { - fail(new Error(`vite preview exited ${code}: ${output}`)); - }); - timeout = setTimeout(() => { - fail(new Error(`vite preview did not start:\n${output}`)); - }, 30_000); - }); - - await ready; + const onChunk = (chunk) => { + output += chunk.toString(); + }; + child.stdout?.on("data", onChunk); + child.stderr?.on("data", onChunk); + const origin = `http://127.0.0.1:${port}`; + try { + await waitForHttpServer(child, origin, () => output); + } catch (error) { + await stopChild(child); + throw error; + } return { - origin: `http://127.0.0.1:${port}`, - async close() { - if (child.exitCode !== null) { - return; - } - const exited = once(child, "exit"); - child.kill(); - await exited; - }, + origin, + close: () => stopChild(child), }; } From 54f3f7df2194581fcacce561eb9d7c3eb124796d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 23:02:47 +0200 Subject: [PATCH 31/46] Fix clean-consumer CDN WASM fixture --- CHANGELOG.md | 1 + packages/necpp-wasm/test/pack/consumer.test.mjs | 5 ++--- packages/necpp-wasm/test/pack/helpers.mjs | 11 +++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c10a6f7d..1f443126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. ### Bug Fixes +* **The clean-consumer CDN test now serves WASM from the installed tarball:** the test previously read `packages/necpp-wasm/dist/nec2pp.wasm`, which leaked local build state into the fixture and failed correctly when CI supplied only the release tarball. The CDN-style origin now serves the binary installed under the fixture's own `node_modules` tree. * **Browser and clean-consumer preview tests no longer parse Vite's styled startup log:** GitHub runners can insert ANSI control sequences into the displayed URL, causing a healthy preview server to time out and leave an orphan process behind. Tests now probe the assigned HTTP origin directly and reliably terminate the preview process on success or startup failure. * **Clean-consumer tests now locate npm under GitHub `setup-node`:** direct `node --test` jobs do not set `npm_execpath`, and the old helper assumed npm lived beside the Node binary as it commonly does on Windows. npm invocation now supports the POSIX `../lib/node_modules/npm` layout used by hosted runners, the Windows layout, and a PATH fallback, with a regression test that explicitly removes `npm_execpath`. * **The pinned Emscripten CI build no longer requires an undeclared `tsc`:** removed the unused `--emit-tsd` output from the compile-only Docker job and its artifact/checksum pipeline. The package already uses a committed handwritten internal module declaration, so the generated declaration was redundant. A workflow regression test now keeps TypeScript out of the Emscripten container. diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index e9f5c78b..7b94ea30 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -15,7 +15,7 @@ import { hasWasmArtifacts, installFixture, packPackage, - readPackedWasm, + readInstalledWasm, run, runAsync, serveWasm, @@ -177,8 +177,7 @@ test("custom wasmUrl loads the binary from an HTTP CDN-style origin", { const fixture = createCleanFixture("cdn"); installFixture(fixture.root); writeFixtureFile(fixture.root, "cdn-dipole.mjs", cdnDipoleScript); - packPackage(); - const server = await serveWasm(readPackedWasm()); + const server = await serveWasm(readInstalledWasm(fixture.root)); try { const result = parseJsonLine((await runAsync("node", ["cdn-dipole.mjs"], { cwd: fixture.root, diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 689abebe..9872fc74 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -329,6 +329,13 @@ export function serveWasm(bytes) { }); } -export function readPackedWasm() { - return readFileSync(join(packageDirectory, "dist", "nec2pp.wasm")); +export function readInstalledWasm(fixtureRoot) { + return readFileSync(join( + fixtureRoot, + "node_modules", + "@necpp-engine", + "wasm", + "dist", + "nec2pp.wasm", + )); } From 9b659f186a5601280d6ed0d12937323892771093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 23:18:32 +0200 Subject: [PATCH 32/46] Fix npm release tarball publication --- .github/workflows/build.yml | 5 +++-- CHANGELOG.md | 1 + packages/necpp-wasm/test/ci-workflow.test.mjs | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0848b8be..b66a9d0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -361,10 +361,11 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org + package-manager-cache: false - uses: actions/download-artifact@v4 with: name: necpp-release-artifacts @@ -380,7 +381,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - tarball="$(find release -maxdepth 1 -name '*.tgz' -print -quit)" + tarball="$(find "$PWD/release" -maxdepth 1 -name '*.tgz' -print -quit)" test -n "$tarball" npm publish "$tarball" --access public --provenance - name: Attach tarball and checksums to the GitHub release diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f443126..ffeefe32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Comprehensive npm documentation covering numerical conventions, lifecycle, errors, loading, beamforming, performance, browser memory, and GPL-2.0-or-later distribution obligations. README TypeScript examples and the downstream Vite example are checked against the exact release tarball in CI. ### Bug Fixes +* **The tag release publishes an absolute tarball path:** npm interpreted the relative `release/.tgz` argument as GitHub shorthand and attempted an SSH clone instead of reading the tested artifact. The privileged release job now passes the absolute artifact path and uses `setup-node@v6` without package-manager caching, which also removes npm's deprecated `always-auth` warning. * **The clean-consumer CDN test now serves WASM from the installed tarball:** the test previously read `packages/necpp-wasm/dist/nec2pp.wasm`, which leaked local build state into the fixture and failed correctly when CI supplied only the release tarball. The CDN-style origin now serves the binary installed under the fixture's own `node_modules` tree. * **Browser and clean-consumer preview tests no longer parse Vite's styled startup log:** GitHub runners can insert ANSI control sequences into the displayed URL, causing a healthy preview server to time out and leave an orphan process behind. Tests now probe the assigned HTTP origin directly and reliably terminate the preview process on success or startup failure. * **Clean-consumer tests now locate npm under GitHub `setup-node`:** direct `node --test` jobs do not set `npm_execpath`, and the old helper assumed npm lived beside the Node binary as it commonly does on Windows. npm invocation now supports the POSIX `../lib/node_modules/npm` layout used by hosted runners, the Windows layout, and a PATH fallback, with a regression test that explicitly removes `npm_execpath`. diff --git a/packages/necpp-wasm/test/ci-workflow.test.mjs b/packages/necpp-wasm/test/ci-workflow.test.mjs index fc8446f0..c5fd38fc 100644 --- a/packages/necpp-wasm/test/ci-workflow.test.mjs +++ b/packages/necpp-wasm/test/ci-workflow.test.mjs @@ -71,6 +71,10 @@ test("WP8 workflow packs once and publishes the tested tarball", () => { assert.equal((workflowSource.match(/npm publish/g) ?? []).length, 1); assert.ok(workflowSource.includes('NECPP_WASM_TARBALL="$tarball"')); assert.match(workflowSource, /sha256sum --check SHA256SUMS/); + assert.match( + workflowSource, + /tarball="\$\(find "\$PWD\/release" -maxdepth 1 -name '\*\.tgz' -print -quit\)"/, + ); assert.match(workflowSource, /npm publish "\$tarball" --access public --provenance/); assert.match(workflowSource, /gh release create/); assert.match(workflowSource, /run test:browser -- example/); @@ -83,4 +87,10 @@ test("WP8 workflow packs once and publishes the tested tarball", () => { ); assert.doesNotMatch(innerBuildSource, /--emit-tsd/); assert.doesNotMatch(workflowSource, /nec2pp\.d\.ts/); + + const releaseSetupNode = workflow.jobs.release.steps.find( + (step) => step.uses?.startsWith("actions/setup-node@"), + ); + assert.equal(releaseSetupNode?.uses, "actions/setup-node@v6"); + assert.equal(releaseSetupNode?.with?.["package-manager-cache"], false); }); From fc37c6129cbc66c39289a79a9078270923789044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 28 Aug 2026 23:22:44 +0200 Subject: [PATCH 33/46] Correct default branch in release documentation --- docs/wp8-ci-release.md | 4 ++-- packages/necpp-wasm/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/wp8-ci-release.md b/docs/wp8-ci-release.md index 853ae5fa..16861a28 100644 --- a/docs/wp8-ci-release.md +++ b/docs/wp8-ci-release.md @@ -70,13 +70,13 @@ requires a package to exist before a trusted relationship can be configured. ## Initial 0.1.0 release checklist Before merging, the checked-in `package.json`, `packageVersion` export, and tag -must all identify `0.1.0`. After the WP9 branch reaches `main`: +must all identify `0.1.0`. After the WP9 branch reaches `master`: 1. Confirm the public npm organization/scope `necpp-engine` exists and the publishing account can create packages in it. 2. Protect the GitHub `npm` environment with required reviewers and add the initial granular `NPM_TOKEN` secret. -3. Wait for the complete `main` workflow to pass. +3. Wait for the complete `master` workflow to pass. 4. Create and push `wasm-v0.1.0` at that exact tested commit. Do not publish manually and do not rebuild the tarball locally. 5. Confirm the tag workflow passes every native, WASM, Node, package, diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 6c82cc70..df61246a 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -94,7 +94,7 @@ Phi increases from +X toward +Y; theta increases from +Z toward the XY plane. The full normative contract, including loads, ground, tolerances, and every state transition, is in -[`docs/wasm-api.md`](https://github.com/andrekuehne/necpp/blob/main/docs/wasm-api.md). +[`docs/wasm-api.md`](https://github.com/andrekuehne/necpp/blob/master/docs/wasm-api.md). ## Geometry and ports @@ -372,7 +372,7 @@ try { ## Complete Vite array example The repository's -[four-element array application](https://github.com/andrekuehne/necpp/tree/main/examples/wasm-array-vite) +[four-element array application](https://github.com/andrekuehne/necpp/tree/master/examples/wasm-array-vite) installs the packed package, computes Z/Y, applies progressive complex current weights, displays achieved port quantities, and plots a normalized azimuth cut. CI builds and runs that exact application in Chromium from the same From 3c46f684bbe9bd8e2c459b07157389314216b407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sat, 29 Aug 2026 09:03:57 +0200 Subject: [PATCH 34/46] Release WASM package 0.1.1 --- .gitignore | 1 + CHANGELOG.md | 19 + CMakeLists.txt | 7 + INSTALL.md | 1 + examples/wasm-array-vite/README.md | 2 +- packages/necpp-wasm/README.md | 2 +- packages/necpp-wasm/bench/README.md | 57 +++ packages/necpp-wasm/bench/RESULTS.md | 73 ++++ packages/necpp-wasm/bench/array-benchmark.mjs | 391 ++++++++++++++++++ packages/necpp-wasm/bench/array-case.mjs | 332 +++++++++++++++ packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 3 +- packages/necpp-wasm/src/versions.ts | 2 +- .../necpp-wasm/test/array-benchmark.test.mjs | 39 ++ .../necpp-wasm/test/browser-integration.mjs | 35 +- .../necpp-wasm/test/facade-runtime.test.mjs | 2 +- .../necpp-wasm/test/large-system.test.mjs | 75 ++++ .../necpp-wasm/test/pack/manifest.test.mjs | 2 +- src/CMakeLists.txt | 1 + 19 files changed, 1029 insertions(+), 19 deletions(-) create mode 100644 packages/necpp-wasm/bench/README.md create mode 100644 packages/necpp-wasm/bench/RESULTS.md create mode 100644 packages/necpp-wasm/bench/array-benchmark.mjs create mode 100644 packages/necpp-wasm/bench/array-case.mjs create mode 100644 packages/necpp-wasm/test/array-benchmark.test.mjs create mode 100644 packages/necpp-wasm/test/large-system.test.mjs diff --git a/.gitignore b/.gitignore index 2dab5e0e..3fd5238e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ /packages/necpp-wasm/dist/ /packages/necpp-wasm/COPYING /packages/necpp-wasm/.pack-work/ +/packages/necpp-wasm/bench/results/ # Legacy test binary name (pre-CMake era); kept ignored in case of stale trees. src/necpp_test src/test_manager diff --git a/CHANGELOG.md b/CHANGELOG.md index ffeefe32..9226cfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.1.1 - 2026-08-29 + +### Added +* **Process-isolated WASM array benchmark:** a configurable 2 x 2 through + 16 x 16 benchmark now compares the stateful TypeScript model API with the + legacy `runDeck()` compatibility path over equivalent NEC decks. It emits + incremental NDJSON and a summary, separates cold and retained-solve timing, + and cross-checks source currents parsed from the full legacy report. + +### Bug Fixes +* **WASM solves no longer trap above roughly 150 equations:** the release build + now reserves a 4 MiB WebAssembly stack explicitly. Emscripten heap growth + does not grow its fixed stack, and Eigen's blocked LU/triangular kernels + exhausted the SDK default while preparing or solving larger matrices. A + 4 x 4, 176-equation array now gates preparation and repeated retained-factor + solves in direct, worker, and Chromium package tests. The optimized 8 x 8, + 1,216-equation, 64-port workload also completes in Node direct and worker + modes. + ## 0.1.0 - 2026-08-28 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 64c9bfb9..742efa1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,13 @@ option(BUILD_SHARED_LIBS "Also build a shared libnecpp in addition to the sta option(NECPP_ENABLE_LTO "Enable link-time optimization (IPO) when supported" ON) option(NECPP_NATIVE_ARCH "Optimize for the build machine's CPU (-march=native). Not for distributable binaries." OFF) +# Emscripten's stack cannot grow with the heap. Eigen's optimized, blocked LU +# kernels exceed the SDK's small default stack on otherwise modest systems, so +# reserve enough stack for the 1,216-equation release workload plus headroom. +# Keep this setting in CMake so every WASM build path uses the same value. +set(NECPP_WASM_STACK_SIZE 4194304 CACHE STRING + "WebAssembly stack size in bytes (Emscripten STACK_SIZE)") + # Link-time optimization lets hot cross-TU calls (e.g. the Sommerfeld # integration kernels in c_evlcom.cpp) inline across object files. if(NECPP_ENABLE_LTO AND NOT NECPP_BUILD_WASM) diff --git a/INSTALL.md b/INSTALL.md index 8c010c4c..04c3d2d5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -26,6 +26,7 @@ Pass these on the configure step (e.g. `-DNECPP_BUILD_TESTS=OFF`): | `CMAKE_BUILD_TYPE` | `Release` | `Debug` adds `-O0 -g`; use `Release` for `-O2`. | | `NECPP_BUILD_TESTS` | `ON` | Build the Catch2 unit tests (run with `ctest --test-dir build`). | | `NECPP_BUILD_WASM` | `OFF` | Build the Emscripten/WASM target (needs `emcmake`). | +| `NECPP_WASM_STACK_SIZE` | `4194304` | Fixed Emscripten stack in bytes; 4 MiB supports the verified 1,216-equation array workload. | | `BUILD_SHARED_LIBS` | `ON` | Also build `libnecpp.so` in addition to `libnecpp.a`. | ### Building for debug diff --git a/examples/wasm-array-vite/README.md b/examples/wasm-array-vite/README.md index 3690c96e..df1c4555 100644 --- a/examples/wasm-array-vite/README.md +++ b/examples/wasm-array-vite/README.md @@ -9,7 +9,7 @@ combined azimuth far-field cut at 1 m. ## Run with the published package From this directory, run `npm install`, then -`npm install @necpp-engine/wasm@0.1.0`, followed by `npm run dev`. Open the URL +`npm install @necpp-engine/wasm@0.1.1`, followed by `npm run dev`. Open the URL printed by Vite. Use `npm run build` and `npm run preview` to inspect the production bundle. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index df61246a..4b553c36 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -307,7 +307,7 @@ appropriate CORS header. import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel({ - wasmUrl: new URL("https://cdn.example.test/necpp/0.1.0/nec2pp.wasm"), + wasmUrl: new URL("https://cdn.example.test/necpp/0.1.1/nec2pp.wasm"), }); model.dispose(); ``` diff --git a/packages/necpp-wasm/bench/README.md b/packages/necpp-wasm/bench/README.md new file mode 100644 index 00000000..07406ff5 --- /dev/null +++ b/packages/necpp-wasm/bench/README.md @@ -0,0 +1,57 @@ +# WASM array benchmark + +This benchmark compares two public `@necpp-engine/wasm` execution paths over +the same centred square array: + +- `stateful`: `createNecModel()`, wire/port construction, `prepare()`, and a + simultaneous 1 + j0 V solve at every centre-segment port; +- `deck`: `runDeck()` with equivalent `GW`, `GE`, `FR`, and `EX` cards followed + by `XQ` and `EN`, including parsing and copying the complete formatted NEC + report back to JavaScript. + +The card layout follows the authoritative +[NEC-2 Part 3 manual](https://www.nec2.org/other/nec2prt3.pdf). Each +backend/size/round runs in a fresh Node process so a trap or timeout does not +erase earlier results. The runner emits newline-delimited JSON as cases finish +and can also retain a final JSON summary. + +Build the package and run the default 2 x 2 through 16 x 16 sweep with 11 +segments per dipole: + +```powershell +npm --prefix packages/necpp-wasm run build +npm --prefix packages/necpp-wasm run bench:array -- ` + --output packages/necpp-wasm/bench/results/array-2-16-11seg.ndjson +``` + +See [RESULTS.md](RESULTS.md) for a three-round 2 x 2 through 16 x 16 comparison +captured with the 4 MiB-stack build. + +Useful options: + +```text +--sides 2-16 Inclusive ranges and comma lists are accepted +--segments 11 Must be odd +--frequency-mhz 300 +--backends stateful,deck +--rounds 1 Fresh processes per round +--retained-solves 10 Additional stateful solves after factorization +--timeout-seconds 600 Per backend/size/round +--equivalence-tolerance 1e-4 Relative L2 error after report rounding +--output PATH Optional NDJSON plus adjacent summary JSON +--overwrite Replace an existing output file +--fail-fast Stop after the first failure +``` + +The stateful `coldTotalMs` includes module instantiation, geometry/port calls, +preparation, the first solve, and result copying. Additional retained solves +are excluded from that comparable total and reported separately. The deck +`coldTotalMs` includes deck text generation, module instantiation inside +`runDeck()`, parsing, preparation, the solve, full report formatting, copying +that report, and parsing its source-current table. JavaScript module import +time is excluded from both. + +Source currents parsed from the deck's `ANTENNA INPUT PARAMETERS` table are +compared with the stateful port currents. The legacy report prints five-digit +scientific values, so comparison uses a relative tolerance rather than exact +equality. diff --git a/packages/necpp-wasm/bench/RESULTS.md b/packages/necpp-wasm/bench/RESULTS.md new file mode 100644 index 00000000..1a1199db --- /dev/null +++ b/packages/necpp-wasm/bench/RESULTS.md @@ -0,0 +1,73 @@ +# Array benchmark results - 2026-08-29 + +Three fresh-process rounds per backend on an AMD Ryzen 7 PRO 7840HS, Windows +10.0.26200, Node 24.14.1, Emscripten 4.0.7, and the 4 MiB-stack optimized WASM +artifact (`f6b681e1d94b3358ae6ddee0077b1ce40aab6b52dc9978e7a946e8c0c3c4e709`). +The worktree was dirty with the stack fix and benchmark implementation. + +Each element is a free-space, centre-fed, Z-directed lambda/4 dipole with 11 +segments. Every port is driven simultaneously at 1 + j0 V and 300 MHz. +`Stateful cold` covers module creation, geometry and port construction, +preparation, the first solve, and result copying. `Full deck cold` covers deck +generation, `runDeck()`, the complete formatted report, and source-current +parsing. Values are medians. + +| Array | Equations | Stateful cold | Full deck cold | Deck delta | Retained solve | +|---:|---:|---:|---:|---:|---:| +| 2 x 2 | 44 | 20.0 ms | 26.4 ms | 32.0% | 0.23 ms | +| 3 x 3 | 99 | 30.4 ms | 38.5 ms | 27.0% | 0.49 ms | +| 4 x 4 | 176 | 35.8 ms | 46.6 ms | 30.1% | 0.49 ms | +| 5 x 5 | 275 | 55.6 ms | 73.9 ms | 32.9% | 0.53 ms | +| 6 x 6 | 396 | 92.8 ms | 108.1 ms | 16.4% | 0.79 ms | +| 7 x 7 | 539 | 153.5 ms | 174.4 ms | 13.6% | 1.08 ms | +| 8 x 8 | 704 | 301.2 ms | 310.8 ms | 3.2% | 1.56 ms | +| 9 x 9 | 891 | 499.6 ms | 542.2 ms | 8.5% | 2.41 ms | +| 10 x 10 | 1,100 | 862.0 ms | 890.3 ms | 3.3% | 3.33 ms | +| 11 x 11 | 1,331 | 1,447.7 ms | 1,475.5 ms | 1.9% | 4.63 ms | +| 12 x 12 | 1,584 | 2,336.6 ms | 2,364.2 ms | 1.2% | 6.39 ms | +| 13 x 13 | 1,859 | 3,778.9 ms | 3,781.0 ms | 0.1% | 11.20 ms | +| 14 x 14 | 2,156 | 5,789.7 ms | 5,738.1 ms | -0.9% | 11.86 ms | +| 15 x 15 | 2,475 | 8,619.1 ms | 8,697.5 ms | 0.9% | 15.83 ms | +| 16 x 16 | 2,816 | 12,813.7 ms | 12,907.2 ms | 0.7% | 17.30 ms | + +All 90 backend cases completed. All 45 stateful/deck pairs passed the +source-current equivalence check. Relative L2 differences ranged from +7.54e-6 to 2.07e-5, consistent with the legacy report's five-digit printed +precision. + +At 16 x 16 the interaction matrix itself is 121 MiB. Median process RSS growth +was 138.0 MiB for stateful and 138.8 MiB for the deck path; the returned full +deck report was 640 KiB. Cold-path differences above roughly 700 equations are +small compared with run-to-run noise because both facades spend nearly all of +their time in the same matrix preparation. Stateful mode's material advantage +is reuse: another 256-port drive costs about 17 ms instead of repeating a +roughly 13-second deck execution and factorization. + +Command: + +```powershell +npm --prefix packages/necpp-wasm run bench:array -- ` + --sides 2-16 --segments 11 --rounds 3 --retained-solves 10 ` + --timeout-seconds 600 ` + --output packages/necpp-wasm/bench/results/array-2-16-11seg-3round-20260829.ndjson +``` + +The generated NDJSON and summary JSON remain local under `bench/results/`, +which is ignored by Git. This was a scaling comparison rather than a +laboratory-grade performance run: machine power state was not controlled and +backend order was fixed. + +## Historical 19-segment endpoint + +The earlier NEC++ performance workload used 19 rather than 11 segments per +dipole. A separate fresh-process run exercised that discretization at the +largest array size: + +| Array | Equations | Stateful cold | Full deck cold | Deck delta | Retained solve | +|---:|---:|---:|---:|---:|---:| +| 16 x 16 | 4,864 | 64,070.6 ms | 64,492.8 ms | 0.7% | 49.86 ms | + +Both paths completed with the 4 MiB stack. The interaction matrix alone is 361 +MiB; observed RSS growth was 387.0 MiB stateful and 386.4 MiB for the deck +path. The 1.25 MiB legacy report contained 15,457 lines. Its 256 source +currents agreed with the stateful result to 1.33e-5 relative L2 error. diff --git a/packages/necpp-wasm/bench/array-benchmark.mjs b/packages/necpp-wasm/bench/array-benchmark.mjs new file mode 100644 index 00000000..487f0aff --- /dev/null +++ b/packages/necpp-wasm/bench/array-benchmark.mjs @@ -0,0 +1,391 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { cpus, platform, release, totalmem } from "node:os"; +import { dirname, resolve } from "node:path"; + +const packageDirectory = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(packageDirectory, "../.."); +const caseScript = resolve(import.meta.dirname, "array-case.mjs"); +const invocationDirectory = process.env.INIT_CWD ?? process.cwd(); + +function parseInteger(value, name, minimum = 1) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum) { + throw new Error(`${name} must be an integer >= ${minimum}`); + } + return parsed; +} + +function parseSides(value) { + const sides = new Set(); + for (const part of value.split(",")) { + const range = part.match(/^(\d+)-(\d+)$/); + if (range !== null) { + const start = parseInteger(range[1], "side"); + const end = parseInteger(range[2], "side"); + if (end < start) { + throw new Error(`side range ${part} is descending`); + } + for (let side = start; side <= end; side += 1) { + sides.add(side); + } + } else { + sides.add(parseInteger(part, "side")); + } + } + return [...sides].sort((left, right) => left - right); +} + +function parseArguments(argv) { + const options = { + sides: "2-16", + segments: "11", + frequencyMHz: "300", + backends: "stateful,deck", + rounds: "1", + retainedSolves: "10", + timeoutSeconds: "600", + equivalenceTolerance: "0.0001", + output: undefined, + overwrite: false, + failFast: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--overwrite") { + options.overwrite = true; + continue; + } + if (argument === "--fail-fast") { + options.failFast = true; + continue; + } + if (!argument.startsWith("--") || argv[index + 1] === undefined) { + throw new Error(`invalid argument near ${argument}`); + } + const key = argument.slice(2); + const mappings = { + sides: "sides", + segments: "segments", + "frequency-mhz": "frequencyMHz", + backends: "backends", + rounds: "rounds", + "retained-solves": "retainedSolves", + "timeout-seconds": "timeoutSeconds", + "equivalence-tolerance": "equivalenceTolerance", + output: "output", + }; + if (mappings[key] === undefined) { + throw new Error(`unknown option ${argument}`); + } + options[mappings[key]] = argv[index + 1]; + index += 1; + } + const segments = parseInteger(options.segments, "segments"); + if (segments % 2 === 0) { + throw new Error("segments must be odd"); + } + const backends = options.backends.split(","); + if ( + backends.length === 0 + || backends.some((backend) => backend !== "stateful" && backend !== "deck") + ) { + throw new Error("backends must contain stateful and/or deck"); + } + const frequencyMHz = Number(options.frequencyMHz); + const equivalenceTolerance = Number(options.equivalenceTolerance); + if (!(frequencyMHz > 0) || !Number.isFinite(frequencyMHz)) { + throw new Error("frequency-mhz must be positive and finite"); + } + if (!(equivalenceTolerance >= 0) || !Number.isFinite(equivalenceTolerance)) { + throw new Error("equivalence-tolerance must be finite and nonnegative"); + } + return { + sides: parseSides(options.sides), + segments, + frequencyMHz, + backends, + rounds: parseInteger(options.rounds, "rounds"), + retainedSolves: parseInteger(options.retainedSolves, "retained-solves", 0), + timeoutMs: parseInteger(options.timeoutSeconds, "timeout-seconds") * 1000, + equivalenceTolerance, + output: options.output === undefined + ? undefined + : resolve(invocationDirectory, options.output), + overwrite: options.overwrite, + failFast: options.failFast, + }; +} + +function median(values) { + if (values.length === 0) { + return null; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function commandOutput(command, args) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function buildMetadata(options) { + const packageJson = JSON.parse( + readFileSync(resolve(packageDirectory, "package.json"), "utf8"), + ); + const wasmPath = resolve(packageDirectory, "dist/nec2pp.wasm"); + if (!existsSync(wasmPath)) { + throw new Error("dist/nec2pp.wasm is missing; run npm run build first"); + } + const rootCmake = readFileSync(resolve(repositoryRoot, "CMakeLists.txt"), "utf8"); + const stackMatch = rootCmake.match(/set\(NECPP_WASM_STACK_SIZE\s+(\d+)/); + const dockerScript = readFileSync( + resolve(repositoryRoot, "scripts/build_wasm_docker.ps1"), + "utf8", + ); + const emscriptenMatch = dockerScript.match(/emscripten\/emsdk:([^"\s]+)/); + return { + type: "metadata", + schemaVersion: 1, + startedAt: new Date().toISOString(), + packageVersion: packageJson.version, + nodeVersion: process.version, + operatingSystem: `${platform()} ${release()}`, + architecture: process.arch, + cpuModel: cpus()[0]?.model ?? "unknown", + logicalCpuCount: cpus().length, + physicalMemoryBytes: totalmem(), + gitCommit: commandOutput("git", ["rev-parse", "HEAD"]), + gitDirty: (commandOutput("git", ["status", "--porcelain"]) ?? "").length > 0, + emscriptenVersion: emscriptenMatch?.[1] ?? null, + wasmStackSizeBytes: stackMatch === null ? null : Number(stackMatch[1]), + wasmBytes: statSync(wasmPath).size, + wasmSha256: sha256(wasmPath), + options: { + ...options, + output: options.output, + timeoutMs: options.timeoutMs, + }, + }; +} + +function compareCurrents(stateful, deck) { + if (stateful.length !== deck.length) { + throw new Error("backend source-current counts differ"); + } + let deltaSquared = 0; + let referenceSquared = 0; + let maxAbsoluteDelta = 0; + for (let index = 0; index < stateful.length; index += 1) { + const realDelta = stateful[index].real - deck[index].real; + const imagDelta = stateful[index].imag - deck[index].imag; + const absoluteDelta = Math.hypot(realDelta, imagDelta); + deltaSquared += absoluteDelta ** 2; + referenceSquared += stateful[index].real ** 2 + stateful[index].imag ** 2; + maxAbsoluteDelta = Math.max(maxAbsoluteDelta, absoluteDelta); + } + return { + sourceCurrentCount: stateful.length, + relativeL2Error: Math.sqrt(deltaSquared) / Math.max(Math.sqrt(referenceSquared), 1e-300), + maxAbsoluteDelta, + }; +} + +function parseChildResult(result, identity) { + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + const stderr = typeof result.stderr === "string" ? result.stderr : ""; + const lines = stdout.trim().split(/\r?\n/).filter(Boolean); + let record; + try { + record = JSON.parse(lines.at(-1) ?? ""); + } catch { + record = { + type: "case", + ok: false, + ...identity, + error: { + name: result.error?.name ?? "ChildProcessError", + message: result.error?.message + ?? `benchmark child exited ${result.status}: ${stderr.trim()}`, + }, + }; + } + if (result.status !== 0 && record.ok !== false) { + record.ok = false; + record.error = { + name: result.error?.name ?? "ChildProcessError", + message: result.error?.message + ?? `benchmark child exited ${result.status}: ${stderr.trim()}`, + }; + } + return record; +} + +function caseSummary(records, backend, side) { + const matches = records.filter((record) => + record.backend === backend && record.side === side); + const successes = matches.filter((record) => record.ok); + const timingKeys = backend === "stateful" + ? [ + "instantiateMs", + "geometryMs", + "prepareMs", + "firstSolveMs", + "retainedSolveMedianMs", + "coldTotalMs", + "totalWithRetainedSolvesMs", + ] + : ["deckBuildMs", "runDeckMs", "coldTotalMs"]; + return { + backend, + side, + equations: side * side * records[0].segmentsPerDipole, + successCount: successes.length, + failureCount: matches.length - successes.length, + medianTimingsMs: Object.fromEntries(timingKeys.map((key) => [ + key, + median(successes.map((record) => record.timings[key]).filter((value) => + typeof value === "number")), + ])), + medianRssDeltaBytes: median(successes.map((record) => record.rssDeltaBytes)), + medianReportBytes: backend === "deck" + ? median(successes.map((record) => record.reportBytes)) + : undefined, + }; +} + +function summaryPath(output) { + return output.endsWith(".ndjson") + ? `${output.slice(0, -".ndjson".length)}.summary.json` + : `${output}.summary.json`; +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.output !== undefined) { + if (existsSync(options.output) && !options.overwrite) { + throw new Error(`${options.output} already exists; pass --overwrite to replace it`); + } + mkdirSync(dirname(options.output), { recursive: true }); + writeFileSync(options.output, ""); + } + const emit = (record) => { + const line = `${JSON.stringify(record)}\n`; + process.stdout.write(line); + if (options.output !== undefined) { + appendFileSync(options.output, line); + } + }; + + const metadata = buildMetadata(options); + emit(metadata); + const records = []; + const comparisons = []; + const pairs = new Map(); + let hasFailure = false; + for (const side of options.sides) { + for (let round = 1; round <= options.rounds; round += 1) { + for (const backend of options.backends) { + const identity = { + backend, + side, + round, + segmentsPerDipole: options.segments, + equations: side * side * options.segments, + ports: side * side, + frequencyMHz: options.frequencyMHz, + }; + const child = spawnSync(process.execPath, [ + caseScript, + "--backend", backend, + "--side", String(side), + "--segments", String(options.segments), + "--frequency-mhz", String(options.frequencyMHz), + "--retained-solves", String(options.retainedSolves), + "--round", String(round), + ], { + cwd: packageDirectory, + encoding: "utf8", + timeout: options.timeoutMs, + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + }); + const record = parseChildResult(child, identity); + const portCurrents = record.portCurrents; + delete record.portCurrents; + records.push(record); + emit(record); + if (!record.ok) { + hasFailure = true; + if (options.failFast) { + process.exitCode = 1; + return; + } + continue; + } + const pairKey = `${side}:${round}`; + const pair = pairs.get(pairKey) ?? {}; + pair[backend] = portCurrents; + pairs.set(pairKey, pair); + if (pair.stateful !== undefined && pair.deck !== undefined) { + const comparison = { + type: "comparison", + side, + round, + equations: identity.equations, + ...compareCurrents(pair.stateful, pair.deck), + }; + comparison.withinTolerance = comparison.relativeL2Error + <= options.equivalenceTolerance; + if (!comparison.withinTolerance) { + hasFailure = true; + } + comparisons.push(comparison); + emit(comparison); + } + } + } + } + + const summary = { + type: "summary", + schemaVersion: 1, + completedAt: new Date().toISOString(), + hasFailure, + cases: options.sides.flatMap((side) => options.backends.map((backend) => + caseSummary(records, backend, side))), + comparisons, + }; + emit(summary); + if (options.output !== undefined) { + writeFileSync(summaryPath(options.output), `${JSON.stringify({ + metadata, + ...summary, + }, null, 2)}\n`); + } + if (hasFailure) { + process.exitCode = 1; + } +} + +main(); diff --git a/packages/necpp-wasm/bench/array-case.mjs b/packages/necpp-wasm/bench/array-case.mjs new file mode 100644 index 00000000..7ccb1581 --- /dev/null +++ b/packages/necpp-wasm/bench/array-case.mjs @@ -0,0 +1,332 @@ +import { performance } from "node:perf_hooks"; +import { pathToFileURL } from "node:url"; + +const SPEED_OF_LIGHT_M_PER_S = 299_792_458; + +function requireInteger(value, name, minimum = 1) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum) { + throw new Error(`${name} must be an integer >= ${minimum}`); + } + return parsed; +} + +function requirePositiveNumber(value, name) { + const parsed = Number(value); + if (!(parsed > 0) || !Number.isFinite(parsed)) { + throw new Error(`${name} must be a positive finite number`); + } + return parsed; +} + +function parseArguments(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined) { + throw new Error(`invalid argument near ${name ?? "end of command"}`); + } + values.set(name.slice(2), value); + } + const backend = values.get("backend"); + if (backend !== "stateful" && backend !== "deck") { + throw new Error("--backend must be stateful or deck"); + } + const segments = requireInteger(values.get("segments"), "segments"); + if (segments % 2 === 0) { + throw new Error("segments must be odd so every dipole has a centre segment"); + } + return { + backend, + side: requireInteger(values.get("side"), "side"), + segments, + frequencyMHz: requirePositiveNumber( + values.get("frequency-mhz"), + "frequency-mhz", + ), + retainedSolves: requireInteger( + values.get("retained-solves"), + "retained-solves", + 0, + ), + round: requireInteger(values.get("round"), "round"), + }; +} + +export function createArrayDefinition({ side, segments, frequencyMHz }) { + const wavelengthM = SPEED_OF_LIGHT_M_PER_S / (frequencyMHz * 1e6); + const elementHalfLengthM = wavelengthM / 8; + const spacingM = wavelengthM / 2; + const radiusM = wavelengthM / 1000; + const centreSegment = (segments + 1) / 2; + const wires = []; + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const tag = y * side + x + 1; + const xM = (x - (side - 1) / 2) * spacingM; + const yM = (y - (side - 1) / 2) * spacingM; + wires.push({ + tag, + segments, + start: [xM, yM, -elementHalfLengthM], + end: [xM, yM, elementHalfLengthM], + radiusM, + }); + } + } + return { + side, + segments, + frequencyMHz, + wavelengthM, + wires, + ports: wires.map(({ tag }) => ({ tag, segment: centreSegment })), + equations: side * side * segments, + }; +} + +export function buildEquivalentDeck(definition) { + const lines = [ + `CM WASM ARRAY BENCHMARK ${definition.side} X ${definition.side}`, + "CE", + ]; + for (const wire of definition.wires) { + lines.push([ + "GW", + wire.tag, + wire.segments, + ...wire.start, + ...wire.end, + wire.radiusM, + ].join(" ")); + } + lines.push( + "GE 0", + `FR 0 1 0 0 ${definition.frequencyMHz} 0`, + ); + for (const port of definition.ports) { + lines.push(`EX 0 ${port.tag} ${port.segment} 0 1 0`); + } + lines.push("XQ", "EN"); + return `${lines.join("\n")}\n`; +} + +export function parseDeckSourceCurrents(report, expectedCount) { + const lines = report.split(/\r?\n/); + const start = lines.findIndex((line) => + line.includes("----- ANTENNA INPUT PARAMETERS -----")); + const end = lines.findIndex((line, index) => + index > start && line.includes("----- CURRENTS AND LOCATION -----")); + if (start < 0 || end < 0) { + throw new Error("legacy report does not contain source-current tables"); + } + const currents = []; + for (const line of lines.slice(start + 1, end)) { + const fields = line.trim().split(/\s+/); + if ( + fields.length >= 11 + && /^\d+$/.test(fields[0]) + && /^\d+$/.test(fields[1]) + ) { + const real = Number(fields[4]); + const imag = Number(fields[5]); + if (Number.isFinite(real) && Number.isFinite(imag)) { + currents.push({ real, imag }); + } + } + } + if (currents.length !== expectedCount) { + throw new Error( + `legacy report has ${currents.length} source currents; expected ${expectedCount}`, + ); + } + return currents; +} + +function median(values) { + if (values.length === 0) { + return null; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function currentChecksum(currents) { + let sumReal = 0; + let sumImag = 0; + let normSquared = 0; + for (const current of currents) { + sumReal += current.real; + sumImag += current.imag; + normSquared += current.real ** 2 + current.imag ** 2; + } + return { sumReal, sumImag, l2Norm: Math.sqrt(normSquared) }; +} + +async function runStateful(api, definition, retainedSolves) { + const totalStart = performance.now(); + const instantiateStart = performance.now(); + const model = await api.createNecModel(); + const instantiateMs = performance.now() - instantiateStart; + try { + const geometryStart = performance.now(); + for (const wire of definition.wires) { + model.addWire(wire); + } + model.completeGeometry(); + model.definePorts(definition.ports); + const geometryMs = performance.now() - geometryStart; + + const prepareStart = performance.now(); + model.prepare({ frequencyMHz: definition.frequencyMHz }); + const prepareMs = performance.now() - prepareStart; + + const drive = { + real: new Float64Array(definition.ports.length).fill(1), + imag: new Float64Array(definition.ports.length), + }; + const solveStart = performance.now(); + const solution = model.solveVoltages(drive); + const firstSolveMs = performance.now() - solveStart; + const coldTotalMs = performance.now() - totalStart; + const retainedSolveTimesMs = []; + for (let index = 0; index < retainedSolves; index += 1) { + const retainedStart = performance.now(); + model.solveVoltages(drive); + retainedSolveTimesMs.push(performance.now() - retainedStart); + } + const currents = Array.from(solution.currents.real, (real, index) => ({ + real, + imag: solution.currents.imag[index], + })); + if (!currents.every(({ real, imag }) => + Number.isFinite(real) && Number.isFinite(imag))) { + throw new Error("stateful solve returned a non-finite source current"); + } + return { + engineVersion: api.engineVersion, + timings: { + instantiateMs, + geometryMs, + prepareMs, + firstSolveMs, + coldTotalMs, + retainedSolveCount: retainedSolves, + retainedSolveMedianMs: median(retainedSolveTimesMs), + retainedSolveMinMs: retainedSolveTimesMs.length === 0 + ? null + : Math.min(...retainedSolveTimesMs), + retainedSolveMaxMs: retainedSolveTimesMs.length === 0 + ? null + : Math.max(...retainedSolveTimesMs), + totalWithRetainedSolvesMs: performance.now() - totalStart, + }, + currents, + }; + } finally { + model.dispose(); + } +} + +async function runDeck(api, definition) { + const totalStart = performance.now(); + const buildStart = performance.now(); + const deck = buildEquivalentDeck(definition); + const deckBuildMs = performance.now() - buildStart; + const runStart = performance.now(); + const result = await api.runDeck(deck); + const runDeckMs = performance.now() - runStart; + const currents = parseDeckSourceCurrents( + result.report, + definition.ports.length, + ); + return { + engineVersion: result.engineVersion, + timings: { + deckBuildMs, + runDeckMs, + coldTotalMs: performance.now() - totalStart, + }, + reportBytes: Buffer.byteLength(result.report), + reportLines: result.report.split(/\r?\n/).length, + deckBytes: Buffer.byteLength(deck), + currents, + }; +} + +function serializeError(error) { + return { + name: error?.name ?? "Error", + message: error?.message ?? String(error), + code: error?.code, + stack: error?.stack, + cause: error?.cause === undefined + ? undefined + : { + name: error.cause?.name, + message: error.cause?.message ?? String(error.cause), + }, + }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const api = await import("../dist/index.js"); + const rssBeforeBytes = process.memoryUsage().rss; + const definitionStart = performance.now(); + const definition = createArrayDefinition(options); + const definitionMs = performance.now() - definitionStart; + try { + const backendResult = options.backend === "stateful" + ? await runStateful(api, definition, options.retainedSolves) + : await runDeck(api, definition); + const rssAfterBytes = process.memoryUsage().rss; + const currents = backendResult.currents; + process.stdout.write(`${JSON.stringify({ + type: "case", + ok: true, + backend: options.backend, + side: options.side, + round: options.round, + segmentsPerDipole: options.segments, + equations: definition.equations, + ports: definition.ports.length, + frequencyMHz: definition.frequencyMHz, + definitionMs, + rssBeforeBytes, + rssAfterBytes, + rssDeltaBytes: rssAfterBytes - rssBeforeBytes, + currentChecksum: currentChecksum(currents), + firstCurrent: currents[0], + portCurrents: currents, + ...backendResult, + currents: undefined, + })}\n`); + } catch (error) { + process.stdout.write(`${JSON.stringify({ + type: "case", + ok: false, + backend: options.backend, + side: options.side, + round: options.round, + segmentsPerDipole: options.segments, + equations: definition.equations, + ports: definition.ports.length, + frequencyMHz: definition.frequencyMHz, + definitionMs, + error: serializeError(error), + })}\n`); + process.exitCode = 1; + } +} + +const entryPoint = process.argv[1] === undefined + ? undefined + : pathToFileURL(process.argv[1]).href; +if (entryPoint === import.meta.url) { + await main(); +} diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index c3dfc816..6bc88cad 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.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.1.0", + "version": "0.1.1", "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 6899fd78..9e12fc0d 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@necpp-engine/wasm", - "version": "0.1.0", + "version": "0.1.1", "private": false, "type": "module", "description": "Stateful NEC2++ electromagnetic solver for Node and the browser", @@ -50,6 +50,7 @@ "COPYING" ], "scripts": { + "bench:array": "node bench/array-benchmark.mjs", "build": "node scripts/build-dist.mjs", "prepack": "npm run build", "build:test": "node test/build.mjs", diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index 61c8bc9b..93c0f8e1 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.1.0"; +export const packageVersion = "0.1.1"; export const abiVersion = 1; export const engineVersion = "2.3.4"; diff --git a/packages/necpp-wasm/test/array-benchmark.test.mjs b/packages/necpp-wasm/test/array-benchmark.test.mjs new file mode 100644 index 00000000..819cae45 --- /dev/null +++ b/packages/necpp-wasm/test/array-benchmark.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildEquivalentDeck, + createArrayDefinition, + parseDeckSourceCurrents, +} from "../bench/array-case.mjs"; + +test("array benchmark emits equivalent NEC geometry and excitation cards", () => { + const definition = createArrayDefinition({ + side: 2, + segments: 11, + frequencyMHz: 300, + }); + const deck = buildEquivalentDeck(definition); + assert.equal(definition.equations, 44); + assert.equal(definition.ports.length, 4); + assert.equal(deck.match(/^GW /gm)?.length, 4); + assert.equal(deck.match(/^EX 0 /gm)?.length, 4); + assert.match(deck, /^GE 0$/m); + assert.match(deck, /^FR 0 1 0 0 300 0$/m); + assert.match(deck, /^XQ$/m); + assert.match(deck, /^EN$/m); +}); + +test("array benchmark parses legacy source currents", () => { + const report = ` + ----- ANTENNA INPUT PARAMETERS ----- + TAG SEG VOLTAGE (VOLTS) CURRENT (AMPS) IMPEDANCE (OHMS) ADMITTANCE (MHOS) POWER + 1 6 1.0000E+00 0.0000E+00 2.2352E-05 2.1015E-03 5.0610E+00 -4.7581E+02 2.2352E-05 2.1015E-03 1.1176E-05 + 2 17 1.0000E+00 0.0000E+00 2.2352E-05 2.1015E-03 5.0610E+00 -4.7581E+02 2.2352E-05 2.1015E-03 1.1176E-05 + ----- CURRENTS AND LOCATION ----- +`; + assert.deepEqual(parseDeckSourceCurrents(report, 2), [ + { real: 2.2352e-5, imag: 2.1015e-3 }, + { real: 2.2352e-5, imag: 2.1015e-3 }, + ]); +}); diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index b65c34ae..ac3a023e 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -161,20 +161,33 @@ const out = document.getElementById("out"); try { const model = await ${factory}(); try { - ${awaitPrefix}model.addWire({ - tag: 1, - segments: 11, - start: [0, 0, -0.25], - end: [0, 0, 0.25], - radiusM: 0.001, - }); + const side = 4; + const segments = 11; + const frequencyMHz = 300; + const wavelengthM = 299792458 / (frequencyMHz * 1e6); + const ports = []; + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const tag = y * side + x + 1; + const xM = (x - (side - 1) / 2) * wavelengthM / 2; + const yM = (y - (side - 1) / 2) * wavelengthM / 2; + ${awaitPrefix}model.addWire({ + tag, + segments, + start: [xM, yM, -wavelengthM / 8], + end: [xM, yM, wavelengthM / 8], + radiusM: wavelengthM / 1000, + }); + ports.push({ tag, segment: (segments + 1) / 2 }); + } + } ${awaitPrefix}model.completeGeometry(); - ${awaitPrefix}model.definePorts([{ tag: 1, segment: 6 }]); - ${awaitPrefix}model.prepare({ frequencyMHz: 300 }); + ${awaitPrefix}model.definePorts(ports); + ${awaitPrefix}model.prepare({ frequencyMHz }); const matrices = ${awaitPrefix}model.computeImpedanceMatrix(); ${awaitPrefix}model.solveVoltages({ - real: new Float64Array([1]), - imag: new Float64Array([0]), + real: new Float64Array(ports.length).fill(1), + imag: new Float64Array(ports.length), }); const field = ${awaitPrefix}model.computeFarField({ radiusM: 1, diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 65dfb08d..02be5964 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.1.0"); + assert.equal(packageVersion, "0.1.1"); assert.equal(engineVersion, "2.3.4"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/large-system.test.mjs b/packages/necpp-wasm/test/large-system.test.mjs new file mode 100644 index 00000000..92bb168a --- /dev/null +++ b/packages/necpp-wasm/test/large-system.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +import { createNecModel } from "../.test-build/src/index.js"; +import { createNecWorkerModel } from "../.test-build/src/worker.js"; + +const generatedLoader = new URL( + "../.test-build/src/nec2pp.generated.js", + import.meta.url, +); +const wasmUrl = new URL("../.test-build/src/nec2pp.wasm", import.meta.url); +const hasWasm = existsSync(generatedLoader) && existsSync(wasmUrl); + +const backends = [ + ["direct", createNecModel], + ["worker", createNecWorkerModel], +]; + +for (const [backend, createModel] of backends) { + test(`a 176-equation array solves in ${backend} mode`, { + skip: !hasWasm && "WASM artifacts have not been built", + }, async () => { + const side = 4; + const segmentsPerDipole = 11; + const frequencyMHz = 300; + const wavelengthM = 299_792_458 / (frequencyMHz * 1e6); + const elementHalfLengthM = wavelengthM / 8; + const spacingM = wavelengthM / 2; + const portCount = side * side; + const model = await createModel(); + + try { + const ports = []; + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const tag = y * side + x + 1; + const xM = (x - (side - 1) / 2) * spacingM; + const yM = (y - (side - 1) / 2) * spacingM; + await model.addWire({ + tag, + segments: segmentsPerDipole, + start: [xM, yM, -elementHalfLengthM], + end: [xM, yM, elementHalfLengthM], + radiusM: wavelengthM / 1000, + }); + ports.push({ tag, segment: (segmentsPerDipole + 1) / 2 }); + } + } + + await model.completeGeometry(); + await model.definePorts(ports); + await model.prepare({ frequencyMHz }); + + const drive = { + real: new Float64Array(portCount).fill(1), + imag: new Float64Array(portCount), + }; + const first = await model.solveVoltages(drive); + const second = await model.solveVoltages(drive); + + assert.equal(model.state, "solved"); + assert.equal(first.currents.real.length, portCount); + assert.ok(first.factorizationGeneration > 0); + assert.equal(second.factorizationGeneration, first.factorizationGeneration); + assert.equal(second.solveGeneration, first.solveGeneration + 1); + for (let index = 0; index < portCount; index += 1) { + assert.ok(Number.isFinite(second.currents.real[index])); + assert.ok(Number.isFinite(second.currents.imag[index])); + } + } finally { + await model.dispose(); + } + }); +} diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 64931bf1..100390fb 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -17,7 +17,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.1.0"); + assert.equal(packageJson.version, "0.1.1"); assert.equal(packageJson.engines.node, ">=24"); assert.deepEqual(packageJson.publishConfig, { access: "public", diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 008660ab..485c3f90 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -162,6 +162,7 @@ if(NECPP_BUILD_WASM) "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 + "-sSTACK_SIZE=${NECPP_WASM_STACK_SIZE}" --no-entry) # OUTPUT_NAME nec2pp + SUFFIX .js → Emscripten emits nec2pp.js + nec2pp.wasm, # matching the legacy Makefile output names. From 78bdafe2ee7aeb28f75dae27f898aef73f507794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 11:35:40 +0200 Subject: [PATCH 35/46] Planning for symmetry support --- docs/01_symmetry_support.md | 1613 +++++++++++++++++++++++++++++++++++ 1 file changed, 1613 insertions(+) create mode 100644 docs/01_symmetry_support.md diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md new file mode 100644 index 00000000..35e2492c --- /dev/null +++ b/docs/01_symmetry_support.md @@ -0,0 +1,1613 @@ +# Symmetry support implementation plan + +Status: **working implementation document** +Target: native NEC2++, stateful C++ model, stable C/WASM ABI, +`@necpp-engine/wasm`, worker API, and a transparent TypeScript array +symmetrizer +Primary reference: [NEC-2 Part III Program Description](https://www.nec2.org/other/nec2prt3.pdf), +especially the `GX` and `GR` geometry cards + +This document is intended to be the starting context for several agents working +sequentially. Each agent should complete one work package, record its evidence +in the status table, and leave the repository and this document in a state that +the next agent can continue without reconstructing design decisions. + +## 1. Objective + +Expose NEC-2's exact geometry symmetry machinery to the stateful TypeScript +engine and add a conservative intermediary that can recognize eligible +symmetry in a caller's full array description. + +The completed feature must support two use modes: + +1. **Explicit symmetry:** the caller builds one fundamental section and asks + the engine to generate the remaining sections by coordinate-plane + reflection or equal-angle rotation. +2. **Transparent symmetrization:** the caller supplies the complete array as + element positions in the XY plane. A pure TypeScript planning layer detects + supported symmetry within an explicit epsilon, creates an exact canonical + symmetric model, and gathers results back into the caller's original + element and port order. If it cannot prove eligibility, it builds the full + explicit model instead. + +The first release is deliberately conservative about element patterns. It +accepts only patterns that are pointwise invariant under the selected array +transforms, initially straight Z-directed wire patterns on the element-local Z +axis. Patterns whose wire geometry must itself be reflected or rotated, such as +helices, tilted wires, off-axis wire sets, arcs, or patches, must produce an +explicit fallback or a controlled error. A later extension may accept them +after handedness, endpoint direction, port polarity, and segment mapping are +defined and tested. + +## 2. Success criteria at a glance + +The feature is complete when all of the following are true: + +- one, two, and three coordinate-plane reflections and N-fold rotation about + the global Z axis are available through the stateful native, WASM, direct + TypeScript, and worker APIs; +- an even-sided square array can be built from one quadrant while preserving a + deterministic mapping to every caller element and port; +- a full explicit model and its symmetric equivalent produce the same gathered + complex Z matrix, requested/achieved port quantities, and complex far fields + within the tolerances in this document; +- the beam-steering consumer supplies the same full array description and uses + the same prepare, Z/Y, solve, combined-far-field, and embedded-far-field + methods whether the internal representation is explicit or symmetric; +- no consumer result exposes fundamental-section size, native copy-major port + order, generated tags, or a symmetry-specific result variant unless the + consumer explicitly requests optimization diagnostics; +- the npm-rendered package README describes the supported symmetry capabilities, + transparent automatic selection, unchanged consumer contract, limitations, + and diagnostics with validated examples; +- off-broadside current excitations prove that excitation weights need not be + symmetric for the geometry symmetry optimization to remain valid; +- the transparent symmetrizer either returns a fully explained accepted plan + or an explicit-model fallback with machine-readable reasons; +- epsilon-based acceptance reports every coordinate canonicalization and never + silently claims the original coordinates were exact; +- unsupported element-pattern transformations, including a helix reflected + through a plane, cannot enter the symmetry path; +- the reference 16 x 16 case retains a material preparation-time and memory + improvement over the full explicit stateful model; and +- all native, ABI, package, worker, packed-consumer, and browser tests remain + green; and +- only after those gates pass, the final release-identity work package bumps + the npm package from `0.1.1` to `0.2.0` and the native engine from `2.3.4` to + `2.4.0`, while the additive ABI remains version `1`. + +## 3. Current implementation and gap + +The numerical symmetry implementation already exists: + +- `c_geometry::reflect()` supports any combination of X, Y, and Z coordinate + sign changes and records the original segment/patch counts in `np`/`mp`; +- `c_geometry::generate_cylindrical_structure()` and the `GR` parser path + generate equally spaced rotations about the global Z axis; +- `nec_context::fblock()` builds the plane-symmetry or rotational Fourier + transform matrix; +- matrix assembly, factorization, and solve paths already use `np`, `mp`, + `m_ipsym`, and `nop` to operate on symmetry modes; and +- the deck path already accepts `GX` and `GR`. + +The missing path is: + +```text +nec_stateful_model + -> additive C/WASM ABI + -> private WASM module typing + -> direct TypeScript facade + -> worker protocol/client/runtime + -> transparent array planning and result gathering +``` + +The existing `NecModel` exposes only `addWire()` followed by +`completeGeometry()`. No solver rewrite is required for the first release, but +the public path must preserve native symmetry metadata and enforce conditions +that the deck interface historically leaves to the user. + +## 4. Normative terminology + +- **Full description:** the caller's complete list of positioned elements, + ports, loads, and environment before symmetry analysis. +- **Explicit baseline:** a stateful NEC model in which every element from the + full description is added independently and no geometry symmetry metadata is + active. +- **Fundamental section:** the wires and patches stored before NEC generates + symmetry copies. +- **Section count:** total number of identical geometry sections, including the + original. It is 2, 4, or 8 for one, two, or three reflection planes and is + the rotational order for `GR`-style symmetry. +- **Generated order:** NEC's native order after geometry expansion. Combined + plane reflections use Z, then Y, then X passes. Rotations use increasing + angles `copyIndex * 2*pi/order`. +- **Caller order:** the exact element and port order in the full description. +- **Scatter map:** caller-order port index to generated native port index. +- **Gather map:** generated native result index to caller-order result index. +- **Orbit:** all full-description elements related by the selected symmetry + group. +- **Canonicalization:** replacement of epsilon-close orbit coordinates by one + exact set of symmetry-related coordinates. This is a disclosed geometric + adjustment, not exact equality to the input. +- **Transparent fallback:** selection of the explicit baseline without changing + the requested electromagnetic model when symmetry is absent, ambiguous, or + unsupported. + +## 5. Supported symmetry contract + +### 5.1 Coordinate-plane reflection + +The public contract names planes rather than NEC's potentially confusing +"reflection along an axis" terminology: + +| Public plane | Coordinate transform | NEC `GX` digit | +|---|---|---:| +| `"x=0"` | `(x,y,z) -> (-x,y,z)` | hundreds | +| `"y=0"` | `(x,y,z) -> (x,-y,z)` | tens | +| `"z=0"` | `(x,y,z) -> (x,y,-z)` | units | + +Any nonempty combination is valid in free space when the geometry rules are +met. One, two, and three planes generate 2, 4, and 8 sections respectively. + +Required validation: + +- a segment may end on a generating plane but may not lie in it or cross it; +- a patch may not lie in a generating plane; +- all generated nonzero tags must be unique for the stateful public API; +- the symmetry operation is the final geometry-generation operation before + geometry completion; +- a ground plane at `z=0` is incompatible with use of `z=0` as a structural + reflection plane, while the vertical `x=0` and `y=0` planes remain eligible; +- loads and radiating environment must be invariant under every selected + transform; and +- sources, requested port currents, requested port voltages, and non-radiating + networks do not have to be symmetric. + +### 5.2 Rotational symmetry + +Rotational symmetry means `order` total copies at equal angles around the +global Z axis through the origin: + +```text +angle(copyIndex) = copyIndex * 2*pi/order +copyIndex = 0 .. order-1 +``` + +The public API requires an integer `order >= 2`. The implementation must reject +fixed-axis elements or other geometry that would be duplicated on itself. +Homogeneous free space and homogeneous horizontal ground are invariant under +this rotation. + +The first release does not support: + +- an arbitrary rotation axis; +- a rotation center other than the origin inside the NEC model; +- screw symmetry or rotation plus translation; +- simultaneous exploitation of a general dihedral group; or +- composing separate plane and rotational operations to claim the product of + their section counts. + +The transparent layer may recenter XY coordinates before model construction, +because free space and a homogeneous horizontal ground are translation +invariant in XY. Section 9 defines the required far-field phase restoration. + +### 5.3 Geometry mutations, loads, and ground + +No new geometry may be added after symmetry generation. The preferred public +TypeScript shape therefore makes symmetry an option of `completeGeometry()` so +the expansion and completion form one lifecycle operation. + +Loads are structural and must repeat over complete orbits. The implementation +must not reject the first of several repeated `addLoad()` calls merely because +the intermediate state is asymmetric. It must instead either: + +- offer an atomic helper that expands one caller load over all mapped copies; + or +- retain load definitions and validate complete load orbits no later than + `prepare()`. + +The transparent builder should use the atomic expansion helper. Direct users +may add every copy explicitly, but `prepare()` must fail with a controlled +geometry/configuration error if a load orbit is incomplete or contains unequal +kind, range, or values. + +Special cases that can be recognized as symmetric without expansion include a +load applied identically to every segment. Absolute segment-number loads need +an explicit orbit mapping; they must not be assumed symmetric from a numeric +range alone. + +## 6. Proposed low-level TypeScript symmetry API + +The API below is the proposed advanced contract for callers that deliberately +construct a fundamental section. It is not the representation-independent +beam-steering application API; that facade is defined in Section 8.1. WP-S0 +may refine names, but later work packages must not independently redesign it. + +```ts +export type ReflectionPlane = "x=0" | "y=0" | "z=0"; + +export interface ReflectionSymmetry { + readonly kind: "reflection"; + readonly planes: readonly ReflectionPlane[]; + /** Positive offset applied once per generated copy block. */ + readonly tagIncrement: number; +} + +export interface RotationalSymmetry { + readonly kind: "rotational"; + readonly axis: "z"; + /** Total number of sections, including the original. */ + readonly order: number; + readonly tagIncrement: number; +} + +export type GeometrySymmetry = + | ReflectionSymmetry + | RotationalSymmetry; + +export interface SymmetryCopy { + readonly index: number; + readonly tagOffset: number; + readonly transform: + | { + readonly kind: "cartesian-signs"; + readonly signs: readonly [x: 1 | -1, y: 1 | -1, z: 1 | -1]; + } + | { + readonly kind: "rotate-z"; + readonly angleDeg: number; + }; +} + +export interface SymmetryExpansion { + readonly kind: GeometrySymmetry["kind"]; + readonly sectionCount: number; + readonly fundamentalSegmentCount: number; + readonly fullSegmentCount: number; + readonly copies: readonly SymmetryCopy[]; +} + +export interface CompleteGeometryOptions { + readonly groundConnection?: GroundConnection; + readonly symmetry?: GeometrySymmetry; +} + +export interface GeometryCompletionResult { + readonly symmetry?: SymmetryExpansion; +} + +interface NecModel { + completeGeometry(options?: CompleteGeometryOptions): GeometryCompletionResult; +} + +interface NecWorkerModel { + completeGeometry( + options?: CompleteGeometryOptions, + ): Promise; +} +``` + +Returning a value from the existing `void` method is source-compatible for +callers that ignore it. The ABI should retain the current non-symmetric +completion call and add a symmetric completion entry point rather than change +an existing C signature. + +The returned copy list is data-only and structured-cloneable. Convenience +mapping helpers belong in TypeScript rather than the ABI: + +```ts +generatedTag = baseTag + copy.tagOffset; +``` + +The API must document and test the exact copy order. Consumers must not infer +physical XY row-major order from generated tags. + +## 7. Reference array family + +All correctness and benchmark implementations must use one shared fixture +generator. Do not duplicate slightly different geometry formulas across C++, +JavaScript, deck, and benchmark files. + +For frequency `f`: + +```text +lambda = c0 / f +dipole total length = lambda / 3 +dipole half length = lambda / 6 +array spacing X and Y = lambda / 2 +element center height = lambda / 4 above z=0 +wire radius = lambda / 1000 +segments per dipole = 11 +feed segment = 6 (one-based within the tag) +``` + +Each element is a straight Z-directed wire: + +```text +start = (x_i, y_j, lambda/4 - lambda/6) = (x_i, y_j, lambda/12) +end = (x_i, y_j, lambda/4 + lambda/6) = (x_i, y_j, 5*lambda/12) +``` + +For an `n x n` array centered at `(centerX, centerY)`: + +```text +x_i = centerX + (i - (n - 1)/2) * lambda/2 +y_j = centerY + (j - (n - 1)/2) * lambda/2 +``` + +Caller element and port order is row-major with X varying fastest: + +```text +callerIndex = yIndex * n + xIndex +``` + +Primary environment: + +- perfect, infinite ground at `z=0`; +- `groundConnection: "none"`, because no wire touches ground; and +- `setGround({ kind: "perfect" })` after geometry completion, following the + existing stateful lifecycle. + +Secondary coverage adds one finite reflection-coefficient ground case with +fixed documented permittivity and conductivity. Sommerfeld/Norton belongs in +the extended/native regression set but need not run in every browser or +benchmark case. + +Use 300 MHz as the default executable fixture frequency, but derive every +dimension from the engine's speed-of-light constant rather than treating one +metre as exactly one wavelength. + +### 7.1 Explicit baseline construction + +The baseline adds all `n*n` wires independently with unique tags and defines +all center-segment ports in caller order. It does not call a symmetry API and +must report one section. + +This is the numerical oracle for equivalence. It is not a deck/report oracle; +both baseline and candidate must use unformatted binary64 stateful results. + +### 7.2 Manual reflection construction + +For even `n`, add the `n/2 x n/2` positive-X/positive-Y quadrant, then request +reflection across `x=0` and `y=0`. The fundamental element count is `n*n/4` +and the section count is four. + +When fundamental tags are contiguous `1..q`, use `tagIncrement = q`. The four +tag blocks are then contiguous, but their physical order follows NEC copy +order, not caller row-major order. + +### 7.3 Odd-sided arrays + +A conventional centered odd-sided grid contains elements on both vertical +symmetry planes and one element on the rotation axis. The full point set is +mathematically symmetric, but NEC's generators would duplicate fixed elements. +The transparent symmetrizer must therefore fall back to the explicit model and +report a fixed-element reason. It must not remove the center element or split +the model into optimized and unoptimized substructures. + +## 8. Transparent symmetrizer + +### 8.1 Architectural boundary + +The symmetrizer is a pure TypeScript planning layer. Symmetry detection must +not be embedded in `NecModel`, C++, or matrix preparation. This keeps +floating-point policy and caller identity mapping outside the numerical core. + +Recommended flow: + +```text +FullArrayDescription + -> analyzeArraySymmetry(description, options) + -> ArrayBuildPlan (symmetric or explicit) + -> applyArrayBuildPlan(model, plan) + -> ResultMapping utilities +``` + +The analysis function must be deterministic and side-effect free. The build +function may have direct and worker overloads or consume a small adapter whose +methods return `void | Promise`. + +#### 8.1.1 Representation-independent consumer facade + +The beam-steering web application must never consume `ArrayBuildPlan`, +`SymmetryExpansion`, native generated tags, or scatter/gather maps. Those are +implementation and diagnostic types. Its factory input is always the complete +array description, even when the selected implementation ultimately stores +only a fundamental section. + +The factory returns one facade for both representations: + +```ts +export interface CreateArraySolverOptions { + /** `"auto"` is the production default. */ + readonly symmetry?: "auto" | "off" | "require"; + readonly symmetrizer?: SymmetrizerOptions; +} + +export interface ArraySolverDiagnostics { + readonly representation: "explicit" | "symmetric"; + readonly planner: SymmetrizerDiagnostics; + readonly symmetry?: SymmetryExpansion; +} + +export interface NecArraySolver { + readonly state: NecModelState; + + prepare(options: PrepareOptions): Promise; + computeImpedanceMatrix(): Promise; + solveVoltages(voltages: ComplexVector): Promise; + solveCurrents(currents: ComplexVector): Promise; + computeFarField(request: FarFieldRequest): Promise; + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise; + dispose(): Promise; + + /** Optional observability; numerical consumers do not need to inspect it. */ + getDiagnostics(): ArraySolverDiagnostics; +} + +export function createNecArraySolver( + description: FullArrayDescription, + options?: CreateArraySolverOptions, +): Promise; +``` + +A synchronous/direct counterpart may be supplied, but symmetry must not alter +the method names or result types within either the direct or worker family. + +Mode behavior: + +- `"auto"`: analyze the full description, use symmetry when proven eligible, + otherwise construct the explicit model; +- `"off"`: construct the explicit model, primarily for debugging, baselines, + and equivalence tests; and +- `"require"`: fail if the description cannot be represented by supported + symmetry, intended for advanced validation rather than normal application + operation. + +In `"auto"` mode, a symmetry-specific construction/preflight failure must +dispose the partial native model and retry explicit construction once when the +failure is classified as a representation-eligibility failure. Cancellation, +allocation failure, invalid full geometry, conditioning failure, and general +solver failures must not be hidden by a retry. The retry and reason are exposed +only through diagnostics. + +The facade owns all representation conversion: + +- `solveVoltages()` and `solveCurrents()` accept caller-order vectors and + scatter internally; +- `PortSolution.ports`, requested values, voltages, currents, active + impedances, and powers are returned in caller order; +- `computeImpedanceMatrix()` always returns a full `P x P` Z/Y result for all + caller ports, never a fundamental-section matrix, with rows and columns + gathered into caller order; +- `computeFarField()` returns the ordinary `FarFieldResult`, including internal + recenter phase restoration where required; +- `computeEmbeddedFarFields()` returns all `P` caller port bases in caller + order, never native copy-major order; and +- lifecycle, factorization-generation, solve-generation, ownership, error, and + disposal semantics match the existing stateful facade. + +The normal numerical result objects must not gain a required `symmetry` field +or discriminated union. `getDiagnostics()` is the sole supported way for the +application to observe whether optimization occurred. Ignoring diagnostics +must be sufficient for correct use. + +### 8.2 Proposed planning types + +```ts +export type ArrayElementId = string | number; + +export interface PositionedArrayElement { + readonly id: ArrayElementId; + readonly positionM: readonly [xM: number, yM: number]; + readonly patternId: string; + /** The first release accepts only zero/omitted rotation. */ + readonly rotationDeg?: number; +} + +export interface ElementWirePattern { + readonly id: string; + readonly kind: "straight-wire-pattern"; + readonly wires: readonly RelativeWireDefinition[]; + readonly ports: readonly RelativePortDefinition[]; + readonly loads?: readonly RelativeLoadDefinition[]; +} + +export interface FullArrayDescription { + readonly elements: readonly PositionedArrayElement[]; + readonly patterns: readonly ElementWirePattern[]; + readonly ground: GroundModel; +} + +export interface SymmetrizerOptions { + /** Required; no implicit geometry tolerance. */ + readonly positionEpsilonM: number; + readonly center?: "auto" | readonly [xM: number, yM: number]; + readonly allowReflection?: boolean; + readonly allowRotation?: boolean; + readonly preferredRotationOrders?: readonly number[]; + readonly onUnsupported?: "explicit-fallback" | "error"; +} + +export type SymmetrizationReasonCode = + | "NO_NONTRIVIAL_SYMMETRY" + | "FIXED_ELEMENT_ON_REFLECTION_PLANE" + | "FIXED_ELEMENT_ON_ROTATION_AXIS" + | "POSITION_OUTSIDE_EPSILON" + | "AMBIGUOUS_POSITION_MATCH" + | "PATTERN_MISMATCH" + | "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM" + | "UNSYMMETRIC_LOAD" + | "GROUND_BREAKS_SYMMETRY" + | "TAG_SPACE_EXHAUSTED"; + +export interface ArrayElementMapping { + readonly callerElementIndex: number; + readonly callerElementId: ArrayElementId; + readonly fundamentalElementIndex: number; + readonly copyIndex: number; + readonly generatedTag: number; + readonly generatedPortIndices: readonly number[]; + readonly positionAdjustmentM: readonly [dxM: number, dyM: number]; +} + +export type ArrayBuildPlan = + | { + readonly kind: "symmetric"; + readonly centerM: readonly [xM: number, yM: number]; + readonly symmetry: GeometrySymmetry; + readonly expansion: Omit< + SymmetryExpansion, + "fundamentalSegmentCount" | "fullSegmentCount" + >; + readonly fundamentalElements: readonly CanonicalArrayElement[]; + readonly mappings: readonly ArrayElementMapping[]; + readonly maxPositionAdjustmentM: number; + readonly diagnostics: SymmetrizerDiagnostics; + } + | { + readonly kind: "explicit"; + readonly elements: readonly CanonicalArrayElement[]; + readonly reasons: readonly SymmetrizationReason[]; + readonly diagnostics: SymmetrizerDiagnostics; + }; +``` + +Exact names may change in WP-S0, but the result must retain caller IDs, caller +indices, copy indices, generated tags, port mappings, adjustment vectors, +accepted/rejected candidates, and reason codes. + +### 8.3 Position matching algorithm + +The implementation should use the following staged algorithm: + +1. Validate finite, unique element IDs and finite XY coordinates. +2. Resolve every `patternId` and reject unsupported pattern capabilities before + spending time on geometric candidates. +3. Determine a candidate center: + - use the supplied center when present; + - for reflection candidates in `auto`, use the bounding-box midpoint; + - for rotation candidates in `auto`, use the equal-weight centroid; and + - cross-check candidate centers against the transformed point set. +4. Recenter only XY coordinates. Preserve every physical Z coordinate. +5. Build a spatial hash using cells no larger than `positionEpsilonM`. Search + adjacent cells as well, so values on a hash boundary are not missed. +6. Test candidate transforms against the complete element set. A match requires + compatible pattern identity, element parameters, per-element load schema, + and rotation metadata in addition to coordinate proximity. +7. Require a unique one-to-one permutation. Two candidates within epsilon for + one transformed point are ambiguous and must reject that candidate. +8. Build complete orbits. Every orbit must have the full section count; fixed + points are not accepted in the first release. +9. Select one deterministic representative per orbit. Use the lowest caller + index after validating that representative choice does not alter the group + mapping. +10. Canonicalize each orbit by inverse-transforming its members into the + representative frame, averaging their XY positions, and regenerating all + members by exact symmetry transforms. Record the adjustment of every + caller element. +11. Reject when any adjustment exceeds `positionEpsilonM` or when generated + canonical points collide within epsilon. +12. Allocate deterministic unique tags and build scatter/gather mappings. + +The averaging in step 10 avoids selecting one noisy quadrant as truth and makes +the canonical model invariant to input permutation. Tests must permute input +order to prove this. + +Candidate selection policy: + +- prefer the candidate with the largest section count; +- on a tie, prefer coordinate-plane reflection over rotation for a rectangular + XY array because its copy mapping is simpler and it imposes no rotational + orientation on future element patterns; +- then prefer candidates in a documented stable plane/order sequence; and +- record every tested candidate and rejection reason in diagnostics. + +For rotational auto-detection, test configured orders first. If none are +provided, enumerate divisors of the element count from largest to smallest, +subject to a documented safety cap. Do not infer an unbounded order by fitting +angles to noisy points. + +### 8.4 Epsilon policy + +`positionEpsilonM` is required, finite, and nonnegative. A zero value requests +exact coordinate matching. The layer must not choose a scale-dependent hidden +default. + +Acceptance within epsilon means that the solver uses the canonicalized exact +symmetric geometry, not the original noisy full coordinates. Therefore the +plan must expose: + +- `maxPositionAdjustmentM`; +- one adjustment vector per element; +- the effective center and every canonical coordinate; and +- whether the result is exact (`maxPositionAdjustmentM === 0`) or adjusted. + +Application code can then log, display, or reject the adjustment. The default +fallback policy remains explicit construction when a candidate is outside +epsilon. + +No universal electromagnetic error bound follows solely from coordinate +epsilon. The jitter equivalence tests in Section 11 lock behavior for the +reference array and chosen test epsilon; they are not permission to describe +all epsilon-accepted geometries as numerically identical. + +### 8.5 Recentered models and far-field phase + +NEC's supported planes and rotation axis pass through the origin. The +transparent layer may subtract `(centerX, centerY,0)` while building the native +model. For a homogeneous horizontal environment: + +- the gathered impedance/admittance matrices and port quantities are invariant + under a common XY translation; and +- the far-zone complex field requires a phase restoration if the public result + is to match a model built at the caller's original coordinates. + +With the repository's `exp(-j*k*R)` propagation convention, translating a +source by `c = (centerX, centerY,0)` multiplies the far field in direction +`u(theta,phi)` by: + +```text +phase(theta, phi) = exp(+j * k * dot(u(theta,phi), c)) +``` + +The transparent result helper must multiply both `E_theta` and `E_phi` by this +factor. It must do the same for every basis in an embedded-field result. + +This sign must be proven by an executable off-origin explicit-versus-centered +test. Do not rely on the formula alone. + +Near fields are not covered by this phase-only translation rule. If a near +field API is added later, its observation coordinates must be translated +instead. + +### 8.6 Result scattering and gathering + +The transparent layer must preserve caller order even when native generated +tags are copy-major. + +For port vectors: + +```text +native[scatter[callerIndex]] = caller[callerIndex] +caller[callerIndex] = native[scatter[callerIndex]] +``` + +For a native row-major Z matrix: + +```text +Zcaller[i,j] = Znative[scatter[i], scatter[j]] +``` + +Apply the same two-dimensional gather to Y. Copy all outputs into caller-owned +typed arrays; do not return strided views into native-order arrays. + +For embedded far fields, gather the outer port/basis dimension while preserving +theta-fast sample order within each basis. The first release has a port +orientation multiplier of `+1` only. Future transformed element patterns may +require a complex or signed port-basis multiplier, which is one reason they are +currently prohibited. + +All operations in this section occur behind `NecArraySolver`. The existence of +a gather or rephasing step must not change a consumer method signature, matrix +dimension, field layout, port metadata, or result ownership. + +## 9. Element-pattern eligibility and prohibition + +### 9.1 Accepted first-release pattern + +An element pattern is eligible only when the implementation can prove that the +selected transform leaves its ordered wire and port definition pointwise +unchanged. Initially this means: + +- straight round-wire primitives only; +- every wire endpoint has element-local `x=0` and `y=0` within the pattern + verifier's exact policy; +- no per-element rotation; +- no arc, helix, patch, surface basis, or opaque/custom primitive; +- identical segmentation, radius, relative Z endpoints, port segments, and + scalar load schema for all elements in an orbit; and +- only `x=0`/`y=0` reflections or Z-axis rotations for the reference over-ground + array. + +This accepts a Z-directed dipole and multi-wire collinear Z-axis patterns while +avoiding endpoint reversal, tangent-frame, or handedness ambiguity. + +### 9.2 Required rejection behavior + +The following must result in `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM` and an +explicit fallback by default: + +- a helix of either handedness; +- a tilted or horizontal dipole; +- an off-axis wire, even if the full position set contains a plausible partner; +- an element with a nonzero local rotation; +- an arc or patch; +- a pattern whose transformed endpoints match only after swapping endpoint + order; or +- any pattern lacking enough semantic information to determine transform + behavior. + +The diagnostics must name the pattern and the first unsupported primitive or +rule. It is not sufficient to return only "no symmetry found." + +### 9.3 Path to later acceptance + +A future pattern-transform workstream may relax the prohibition only after it +implements all of the following: + +1. A transform algebra for every exposed primitive, including positions, + ordered endpoints, tangent frames, patch normals, and local rotations. +2. A semantic orientation descriptor for ports and directed network elements. +3. Segment and port polarity mapping under endpoint reversal. +4. Handedness behavior: + - proper rotations preserve helix handedness; + - reflections have determinant `-1` and reverse chirality; and + - a reflected right-handed helix must match an explicitly described + left-handed counterpart, not the original pattern by name alone. +5. Canonical transformed-pattern fingerprints and a one-to-one match against + the full caller description. +6. Full-versus-generated current, Z, power, and complex-field tests for both + right- and left-handed helices. +7. Explicit review of the existing native reflection code for wire endpoint + order and patch `psalp`/tangent handling. + +Until those conditions have their own DoD, no `unsafeAssumeInvariant` escape +hatch should be added to the public API. + +## 10. Native and ABI design + +### 10.1 Native stateful descriptor + +Add a native descriptor equivalent to: + +```cpp +enum class nec_geometry_symmetry_kind { + none, + reflection, + rotational, +}; + +struct nec_geometry_symmetry { + nec_geometry_symmetry_kind kind; + unsigned reflection_plane_mask; // X=1, Y=2, Z=4 + int rotational_order; + int tag_increment; +}; + +struct nec_geometry_completion_result { + nec_geometry_symmetry symmetry; + int section_count; + int64_t fundamental_segment_count; + int64_t full_segment_count; +}; +``` + +`nec_stateful_model::complete_geometry()` should accept an optional descriptor, +validate it while still in geometry-building state, call the existing geometry +generator, complete geometry, store immutable completion metadata, and return +that metadata. + +Keep the existing overload for native source compatibility. + +### 10.2 Preflight and failure safety + +The public path must validate before resizing and partially writing generated +arrays wherever practical: + +- plane mask/order/tag increment; +- segment/patch fixed-plane and crossing rules; +- rotational fixed-axis collisions; +- integer multiplication and allocation sizes; +- generated tag uniqueness and `int32_t` range; and +- ground-connection incompatibility known at completion time. + +Refactor expected validation out of the mutation loop in +`c_geometry::reflect_plane()`. An expected invalid input must not leave half of +the copies written. Geometry-completion failures unrelated to symmetry should +retain the current model's documented failure semantics unless transactional +completion is implemented for all geometry. + +### 10.3 C/WASM ABI + +Retain `necpp_wasm_v1_complete_geometry(model, ground_connection)` and add an +entry point such as: + +```c +int32_t necpp_wasm_v1_complete_geometry_symmetric( + necpp_wasm_v1_model* model, + int32_t ground_connection, + int32_t symmetry_kind, + int32_t parameter, /* plane mask or rotational order */ + int32_t tag_increment); +``` + +Add read-only metadata getters for kind, section count, fundamental segment +count, and full segment count. Copy transforms are deterministic from the +accepted descriptor and need not be stored as ABI arrays. + +This is additive to the v1 symbol set. Update: + +- the public C header and C++ implementation; +- native C and C++ ABI contract tests; +- Emscripten `EXPORTED_FUNCTIONS`; +- the handwritten private `NecWasmModule` interface; +- smoke tests and generated/module declaration expectations; and +- package manifest/packed artifact tests that enumerate exports. + +Do not expose C++ containers, pointers to mutable geometry, or a JSON boundary. + +## 11. Equivalence test matrix + +Correctness gates precede performance measurements. Every comparison must +first reject NaN/infinity and assert dimensions, order, port metadata, +frequency, and state generations exactly. + +### 11.1 Numerical comparison metrics + +For a complex vector or matrix `a` and baseline `b`, calculate both: + +```text +relativeL2 = ||a-b||2 / max(||b||2, absoluteFloor) +scaledMax = max_i |a_i-b_i| / max(max_i |b_i|, absoluteFloor) +``` + +Use the existing repository tolerance policy as the starting point: + +- exact-coordinate full-versus-symmetric Z/Y: `relativeL2 <= 1e-8` and + `scaledMax <= 1e-8`; +- port voltages, currents, active impedance, and powers: `1e-8` scaled; +- exact-coordinate full-versus-symmetric complex far fields: `1e-8` scaled; +- native-to-WASM and direct-to-worker bulk copies: `1e-12`; and +- symmetry metadata, gather maps, axes, array lengths, state, and generation + counters: exact. + +If a fixture requires a looser bound, the test must document the measured +cause and may not exceed the independent NEC golden-value ceiling without a +design review. Never compare only magnitudes when complex values are available. + +### 11.2 Required geometry/environment cases + +| Case | Size/order | Environment | Purpose | +|---|---:|---|---| +| R1 | 2 x 2, X/Y reflection | perfect ground | smallest four-section smoke case | +| R2 | 4 x 4, X/Y reflection | perfect ground | full Z and beam-steering canonical case | +| R3 | 8 x 8, X/Y reflection | perfect ground | larger gathered-Z scheduled/CI case | +| R4 | 4 x 4, X reflection only | perfect ground | two-section mapping | +| R5 | 2 x 2 fundamental with X/Y/Z reflection | free space only | eight-section native coverage using geometry that does not cross planes | +| T1 | order 2 | perfect ground | rotational transform and mapping smoke case | +| T2 | order 4 square/ring-compatible array | perfect ground | C4 rotational equivalence | +| T3 | order 6 ring | free space and one homogeneous ground case | non-power-of-two Fourier modes | +| G1 | 4 x 4, X/Y reflection | finite reflection-coefficient ground | non-perfect ground equivalence | +| N1 | 3 x 3 | perfect ground | fixed-plane/axis explicit fallback | +| O1 | 4 x 4, nonzero XY center | perfect ground | recenter plus complex far-field rephasing | +| E1 | 4 x 4 with epsilon jitter | perfect ground | disclosed canonicalization | +| P1 | 4 x 4 helix pattern | perfect ground | required unsupported-pattern fallback | + +The Z-directed reference dipole is used for R1-R4, G1, N1, O1, and E1. R5 +needs a separate octant fixture whose individual segments do not cross any +generating plane. T1-T3 need geometry with complete free orbits and no element +on the rotation axis. + +### 11.3 Gathered Z/Y equivalence + +For R1, R2, R3, R4, T1-T3, G1, and O1: + +1. Build the full explicit baseline with ports in caller order. +2. Build the manual or transparent symmetric candidate. +3. Define candidate native ports in a deliberately non-caller order at least + once, so the gather test cannot pass trivially. +4. Compute complete complex Z and Y matrices for both models. +5. Gather candidate rows and columns into caller order. +6. Compare every complex entry using both metrics above. +7. Assert `Z*Y` is identity within the existing conditioning tolerance. +8. Assert reciprocity independently where the fixture is reciprocal. +9. Verify a few named mutual-impedance entries whose physical element pairs + are related by reflection/rotation. +10. Confirm the baseline has section count one and the candidate reports the + expected fundamental/full segment counts. + +R3 may run in a scheduled or performance job if complete 64-port extraction is +too expensive for every pull request. R1 and R2 are mandatory in ordinary CI. + +### 11.4 Canonical beam-steering cases + +Use `solveCurrents()` so both models realize the same requested port currents. +For a target direction `(theta0, phi0)` and element position `r_n`, use the +repository's `exp(+j*omega*t)` / `exp(-j*k*R)` convention: + +```text +I_n = amplitude_n * exp(-j * k * dot(u(theta0,phi0), r_n)) +``` + +Required current-weight cases for the 4 x 4 reference array: + +1. uniform amplitude and phase; +2. steer on a `theta=60 deg` azimuth cut toward `phi=0 deg` (+X); +3. steer on the same cut toward `phi=90 deg` (+Y); +4. steer toward `theta=50 deg, phi=45 deg` (diagonal); and +5. one deterministic asymmetric amplitude taper plus progressive phase to + prove that the excitation itself need not share structural symmetry. + +For each case: + +- compare required voltages, achieved currents, active impedances, and powers; +- compute a combined far-field grid with theta and phi coverage above ground; +- compare all complex `E_theta` and `E_phi` samples after center rephasing; +- compare total field magnitude derived from the complex components; +- compare normalized cuts, peak sample index, peak magnitude, and phase at the + intended steering sample; +- require the full and symmetric peak directions to agree to the exact grid + sample; and +- separately sanity-check that the relevant baseline cut places its main lobe + at or near the requested azimuth. Lock the allowable grid-cell offset only + after measuring the baseline, because the element/ground pattern can shift + an elevation maximum. + +Run the same weights through unit-current embedded far fields and JavaScript +superposition. The directly solved field and the superposed basis field must +agree for each model, and the gathered symmetric bases must agree with the +explicit bases in caller port order. + +### 11.5 Epsilon and rejection cases + +Add pure planner and end-to-end cases for: + +- exact coordinates with `epsilon=0`; +- caller input randomly permuted while producing identical canonical geometry + and mappings by caller ID; +- deterministic XY jitter bounded by `1e-10*lambda`, accepted with every + adjustment reported; +- original jittered explicit model versus canonical symmetric model, with + fixture-specific Z/field bounds measured and locked no looser than `1e-7` + unless evidence justifies otherwise; +- one partner displaced just beyond epsilon, causing explicit fallback; +- two points both within epsilon of one target, causing ambiguous-match + fallback; +- an even grid with one mismatched pattern ID; +- an odd grid with fixed elements; +- a non-homogeneous or otherwise incompatible environment once such an + environment exists in the public description; +- an asymmetric load orbit rejected no later than `prepare()`; +- a complete symmetric load orbit accepted; and +- helix, tilted-wire, off-axis-wire, endpoint-swap-only, and rotated-pattern + prohibitions. + +## 12. Benchmark plan + +### 12.1 Benchmark representations + +Extend the process-isolated WASM array benchmark with three stateful +representations of the same reference array: + +- `explicit`: all `n*n` dipoles, no symmetry; +- `manual-reflection`: one quadrant plus explicit X/Y symmetry options; and +- `auto-reflection`: full caller description analyzed by the transparent + symmetrizer, then built from the accepted plan. + +The benchmark must compare numerical checksums or selected full results before +reporting a speedup. A fast wrong model is a failed case. + +Keep the existing deck benchmark as historical coverage, but do not use +formatted report values as the oracle for the new binary64 equivalence gate. + +### 12.2 Sizes and workloads + +Use even sides: + +```text +2, 4, 8, 12, 16 +``` + +At 11 segments per dipole the full equation counts are: + +```text +44, 176, 704, 1,584, 2,816 +``` + +Measure these phases independently: + +- module creation; +- transparent analysis/canonicalization; +- geometry and port construction; +- geometry completion/symmetry expansion; +- `prepare()` allocation, fill, symmetry combination, and factorization when + phase metrics become available; +- one `solveCurrents()` beam-steering solve; +- retained solves with changed weights; +- combined far-field calculation on a fixed grid; +- complete Z gathering for 2 x 2 and 4 x 4 in the normal benchmark; +- optional/scheduled complete Z gathering for 8 x 8; and +- peak WASM memory or allocated matrix bytes. + +Do not make 16 x 16 full-Z extraction a prerequisite for the ordinary +preparation benchmark. A 256-port basis extraction measures a different +workload and can hide the matrix-factorization gain that symmetry is intended +to expose. + +### 12.3 Measurement discipline + +- Run every representation/size/round in a fresh process. +- Use at least three measured rounds; report median, min, max, and failures. +- Compare representations built from the same package artifact and process + settings. +- Record OS, CPU, Node, Emscripten, engine/package versions, commit, worktree + status, frequency, geometry, ground, segment count, grid, and tolerance. +- Emit NDJSON incrementally and an adjacent summary JSON, preserving completed + cases after a later trap or timeout. +- Separate correctness failure, timeout, WASM trap, allocation failure, and + numerical-conditioning failure. +- Keep import time outside comparable cold totals, matching the existing + benchmark convention. +- Report transparent planner time separately and as a percentage of total cold + time. + +### 12.4 Performance gates + +Correctness gates are mandatory. Performance gates are same-host ratios, not +absolute wall-clock promises: + +- the 16 x 16 manual-reflection median `prepare()` time should be at least 8x + faster than the explicit median on the reference single-thread WASM artifact; +- `auto-reflection` preparation after planning should be within 5% of + `manual-reflection` at 8 x 8 and larger; +- transparent planning should remain below 5% of `auto-reflection` cold time at + 8 x 8 and larger and should be reported in milliseconds for small arrays; +- symmetric matrix storage should be close to the theoretical fourfold + reduction for two reflection planes; +- explicit no-symmetry `prepare()` must not regress by more than 5% versus the + pre-feature artifact on the same benchmark protocol; and +- if the 8x target is missed while correctness passes, retain the feature but + do not claim the target: record phase metrics and open a bounded follow-up + before changing the gate. + +The previous free-space 16 x 16 experiment achieved about 11.5x cold-deck +speedup. It is evidence for priority, not a substitute for the new over-ground +stateful benchmark. + +## 13. Sequential work packages + +### Work package status table + +Every agent updates this table and the detailed WP section before handing off. + +| WP | State | Owner/agent | Evidence/commit | Notes for next agent | +|---|---|---|---|---| +| WP-S0 Contract and shared fixtures | not started | — | — | — | +| WP-S1 Native geometry safety and metadata | not started | — | — | — | +| WP-S2 Stateful symmetry and validation | not started | — | — | — | +| WP-S3 Additive C/WASM ABI | not started | — | — | — | +| WP-S4 Direct and worker TypeScript API | not started | — | — | — | +| WP-S5 Transparent symmetrizer | not started | — | — | — | +| WP-S6 End-to-end equivalence suite | not started | — | — | — | +| WP-S7 Benchmarks and performance gates | not started | — | — | — | +| WP-S8 Public documentation, examples, and release hardening | not started | — | — | — | +| WP-S9 Final version bump and release identity | not started | — | — | — | + +Allowed states are `not started`, `in progress`, `blocked`, and `complete`. +`complete` requires the WP's DoD, not merely code that compiles. + +### WP-S0 — Contract and shared fixtures + +Dependencies: none. + +Deliverables: + +- finalize public names and lifecycle behavior in this document; +- add native and TypeScript symmetry contract types without implementation, if + the repository's normal interface-first workflow requires it; +- define error/status classification for invalid symmetry, incompatible ground, + incomplete load orbit, and unsupported pattern transform; +- implement or specify one shared reference-array fixture generator with the + exact formulas in Section 7; +- define caller order, generated order, scatter/gather conventions, and copy + transforms as executable data fixtures; +- decide whether shared cross-language fixture data is generated JSON, a small + language-neutral table, or parallel helpers checked against common golden + coordinates; and +- update `docs/wasm-api.md` lifecycle and operation tables in draft form. + +DoD: + +- TypeScript compile-valid and compile-invalid contract tests cover every new + public discriminant and reject arbitrary axes/empty planes/order < 2; +- a 4 x 4 fixture has exact golden caller coordinates, quadrant coordinates, + generated transforms, tags, and gather maps; +- the fixture proves lower and upper wire Z coordinates are `lambda/12` and + `5*lambda/12`; +- no unresolved API naming, units, ownership, or failure-state TODO remains; +- existing public consumers still typecheck when ignoring the new completion + return value; and +- this WP records exact commands and results in its status row/notes. + +Handoff focus: WP-S1 should be able to implement native behavior without +inventing a second metadata format. + +### WP-S1 — Native geometry safety and metadata + +Dependencies: WP-S0. + +Deliverables: + +- add native symmetry descriptor/completion metadata structures; +- refactor plane and rotational preflight validation ahead of mutation; +- expose deterministic copy count/order and fundamental/full segment counts; +- validate size arithmetic, tags, plane crossings/fixed geometry, and rotational + collisions; +- retain legacy `GX`/`GR` deck semantics and regression outputs; +- add focused `c_geometry` tests for one/two/three planes and rotational orders + 2, 4, and 6; and +- document any native behavior found to differ from the NEC manual before + changing it. + +DoD: + +- valid legacy GX/GR decks and the full existing testharness still pass; +- expected invalid reflection/rotation inputs fail before generated array + mutation is observable; +- one/two/three-plane copy order, coordinates, tags, `np`/`mp`, and `m_ipsym` + have exact assertions; +- rotational coordinates/tags and Fourier section count have exact assertions + for a non-power-of-two order; +- integer overflow and allocation-size checks have controlled tests; +- sanitizer/bounds-check builds report no generated-geometry errors; and +- native comments use plane terminology consistently with the public contract. + +Handoff focus: WP-S2 consumes native descriptors and must not call private +geometry arrays directly. + +### WP-S2 — Stateful symmetry and structural validation + +Dependencies: WP-S1. + +Deliverables: + +- extend `nec_stateful_model::complete_geometry()` with optional symmetry while + retaining the existing overload; +- store immutable completion metadata and expose read-only accessors; +- enforce geometry-building lifecycle and prohibit post-symmetry additions; +- connect symmetric completion to the existing retained factorization path; +- implement or stage load-orbit storage/validation at `prepare()`; +- reject ground configurations that invalidate the selected structural + symmetry; +- preserve arbitrary port order and arbitrary simultaneous source weights; and +- add native full-versus-symmetric Z, solve, and far-field tests for the small + reference cases. + +DoD: + +- R1, R2, R4, T1, T2, and one finite-ground case pass native equivalence gates; +- an intentionally asymmetric excitation passes equivalence, proving source + weights are not incorrectly validated as structure; +- incomplete and unequal load orbits fail before matrix results are published; +- complete load orbits and all-segment scalar loads pass; +- `z=0` reflection plus ground fails clearly while X/Y reflection and Z-axis + rotation over ground remain valid; +- factorization generation and consumer-solution restoration retain existing + behavior; and +- no formatted report parsing appears in a stateful correctness test. + +Handoff focus: WP-S3 gets a complete native API with stable result ownership. + +### WP-S3 — Additive C/WASM ABI + +Dependencies: WP-S2. + +Deliverables: + +- add the symmetric-completion C function and metadata getters; +- map lifecycle, input, geometry, and solver failures to the existing stable + status taxonomy; +- update native C and C++ ABI tests; +- update Emscripten exported symbols and the private module declaration; +- update WASM smoke tests and packed-artifact export assertions; and +- verify old consumers calling only the original v1 functions remain valid. + +DoD: + +- pure C compilation and execution cover valid reflection, valid rotation, and + each primary error class; +- native C++ ABI tests compare returned metadata with the stateful object; +- WASM smoke tests build a symmetric 2 x 2 reference case and return finite + results; +- missing/incorrect exports fail an automated manifest or smoke assertion; +- no ABI getter returns a pointer whose lifetime is ambiguous; and +- ABI version policy is documented: additive v1 symbols do not silently change + existing signatures or enum values. + +Handoff focus: WP-S4 should only translate/validate data and must not reproduce +native electromagnetic logic. + +### WP-S4 — Direct and worker TypeScript API + +Dependencies: WP-S3. + +Deliverables: + +- implement `GeometrySymmetry`, completion result, copy metadata, and validation + in the direct facade; +- add corresponding worker operation typing, runtime dispatch, client method, + progress event, structured-clone handling, and result revival; +- update lifecycle transitions and errors; +- snapshot/freeze returned metadata consistently with existing port metadata; +- update root and worker exports plus generated declaration tests; and +- document explicit fundamental-section construction with the 4 x 4 reference + array. + +DoD: + +- facade-mapping tests assert every native argument and returned copy transform; +- direct and worker results agree to `1e-12` on R1 including gathered Z and one + far-field grid; +- invalid plane lists, duplicate planes, unsafe integers, tag overflow, and + rotational order fail as typed `NecInputError`/`NecGeometryError` cases; +- worker progress and cancellation behavior remain deterministic; +- packed Node consumer and browser integration exercise at least one symmetry + operation; and +- old direct/worker examples still compile and run while ignoring completion + metadata. + +Handoff focus: WP-S5 may depend only on public TypeScript types/methods, not the +private WASM module. + +### WP-S5 — Transparent symmetrizer + +Dependencies: WP-S4. + +Deliverables: + +- implement pure description validation, center selection, spatial matching, + candidate enumeration, orbit formation, canonicalization, and diagnostics; +- implement deterministic fundamental representative and tag allocation; +- implement explicit fallback plans; +- implement model/worker plan application adapters; +- implement the representation-independent `NecArraySolver` facade and factory + so application code never branches on the plan kind; +- implement caller/native port scatter, vector gather, matrix row/column gather, + embedded-basis gather, and far-field center rephasing; +- implement the conservative pattern capability verifier; and +- add unit/property-style tests for input permutations, epsilon boundaries, + ambiguity, odd arrays, pattern mismatch, and all required prohibition cases. + +DoD: + +- exact even 2 x 2, 4 x 4, and 8 x 8 grids select two-plane reflection with the + expected four-section plan; +- exact odd 3 x 3 and 5 x 5 grids select explicit fallback with fixed-element + reasons; +- input permutation does not change canonical coordinates or ID-based mapping; +- epsilon jitter acceptance reports every adjustment and rejects the first + beyond-epsilon/ambiguous case deterministically; +- off-origin field rephasing passes an executable complex-field sign test; +- gathered synthetic vectors/matrices/bases pass exact index tests before any + solver is involved; +- one compile-time and runtime consumer test executes the same unbranched + prepare/Z/solve/far-field code with `symmetry: "off"` and `symmetry: "auto"`; +- ordinary Z, solution, combined-field, and embedded-field return objects have + identical public shapes for explicit and symmetric representations; +- no generated tag, copy index, or fundamental-section port order leaks through + an ordinary numerical result; +- a helix and every other prohibited pattern can never produce a symmetric + plan; and +- planner diagnostics are structured-cloneable and contain no functions or + cyclic objects. + +Handoff focus: WP-S6 combines public planner and engine paths and should not +patch around mapping failures in test code. + +### WP-S6 — End-to-end equivalence suite + +Dependencies: WP-S5. + +Deliverables: + +- implement the complete cases and metrics in Section 11; +- build explicit, manual, and transparent models from the same full + description; +- compare gathered Z/Y and all port quantities; +- implement canonical current-weight generation and far-field comparisons; +- compare direct combined fields with gathered unit-current embedded-field + superposition; +- run the beam-steering consumer contract suite against explicit, accepted + symmetric, and automatic-fallback models without representation branches; +- split fast CI, slower native/WASM CI, and scheduled large-matrix cases without + weakening core coverage; and +- save concise failure diagnostics identifying caller IDs, native indices, + matrix entries, or field samples. + +DoD: + +- R1, R2, R4, T1, T2, G1, N1, O1, E1, and P1 pass in their designated test + tiers; +- R3 and T3 run in at least one automated scheduled/native tier; +- every exact-coordinate matrix and field comparison meets Section 11 + tolerances; +- all five current-weight cases compare direct and embedded fields; +- the deliberately non-caller native port order proves the two-dimensional + matrix gather; +- explicit 4 x 4, symmetric 4 x 4, and fallback 3 x 3 models expose the same + consumer method and result types, with full caller port counts throughout; +- off-origin comparison proves the complex translation phase, not just + magnitude; and +- failure output is sufficient to distinguish geometry mismatch, gather error, + phase-sign error, and numerical solve error. + +Handoff focus: WP-S7 must reuse these fixtures/checks as benchmark correctness +guards. + +### WP-S7 — Benchmarks and performance gates + +Dependencies: WP-S6. + +Deliverables: + +- extend the process-isolated benchmark with explicit, manual, and auto paths; +- use the Section 7 over-ground lambda-scaled geometry; +- emit all Section 12 phase, memory, correctness, and environment metadata; +- add CLI help, README instructions, NDJSON schema/version, and summary ratios; +- capture a clean reference run with at least three rounds; and +- compare explicit pre-feature/no-symmetry behavior to detect regression. + +DoD: + +- sizes 2, 4, 8, 12, and 16 complete or report classified controlled failures; +- numerical checks pass before speedups are printed; +- 16 x 16 meets the same-host 8x manual-reflection preparation target or the + documented miss procedure is followed without altering correctness gates; +- auto and manual paths meet the 5% parity target at 8 x 8 and larger; +- planner overhead and matrix allocation reduction are separately visible; +- raw NDJSON and summary JSON are retained outside source control unless the + repository explicitly tracks a curated result summary; and +- `packages/necpp-wasm/bench/RESULTS.md` or a symmetry-specific results document + records artifact hash, worktree state, commands, tables, and interpretation. + +Handoff focus: WP-S8 uses measured facts, not theoretical estimates, in public +documentation. + +### WP-S8 — Public documentation, examples, and release hardening + +Dependencies: WP-S7. + +Deliverables: + +- update `docs/wasm-api.md` as the detailed API and behavior reference; +- add a self-contained **Symmetric arrays and automatic optimization** section + to `packages/necpp-wasm/README.md`. This file is explicitly included in the + npm `files` list and is the documentation rendered on the npm package page; +- add a concise symmetry capability summary and link to the detailed reference + from the repository root README, if the root README contains the WASM package + overview at implementation time; +- update benchmark documentation and prepare a draft unreleased changelog entry; + WP-S9 assigns the final versions and release date; +- add one direct and one worker example using manual symmetry; +- add one transparent full-description example that displays accepted/fallback + diagnostics and max coordinate adjustment; +- validate every public README code example against the built declarations and + runtime, rather than maintaining illustrative snippets that can silently rot; +- document even/odd square behavior, tag/copy order, loads, ground, arbitrary + excitations, recenter phase, and all first-release pattern prohibitions; +- document the deferred helix/handedness acceptance path without implying it is + implemented; +- run complete native, WASM, package, packed-consumer, and browser validation; + and +- review release/package contents for every new source and declaration. + +The npm README section must be understandable without opening repository-only +documentation. At minimum it contains: + +| Topic | Required npm README content | +|---|---| +| Consumer contract | The same full-array description and the same prepare, Z/Y, solve, combined-far-field, and embedded-far-field calls work for explicit and optimized execution. | +| Default/opt-in policy | Exact documented behavior of `symmetry: "auto"`, `"off"`, and `"require"`, including which mode the public factory defaults to. | +| Supported operations | Coordinate-plane reflections, valid combinations, N-fold rotation about global Z, and the first-release restrictions imposed by ground and element patterns. | +| Full NxN input | A runnable square-array example using caller-provided XY positions, lambda-scaled geometry, epsilon, and automatic selection. | +| Result semantics | Z/Y matrices, port quantities, embedded fields, and combined fields remain in full caller element/port order regardless of internal reduction. | +| Diagnostics | How to inspect accepted symmetry, reduction factor, canonicalization distance, warnings, and fallback reason without branching ordinary solver use. | +| Numerical meaning | Epsilon acceptance canonicalizes near-symmetric coordinates; it does not prove the supplied geometry was exactly symmetric. | +| Unsupported patterns | Helices, tilted/off-axis wires, arcs, patches, rotated patterns, and transformations with unresolved handedness, endpoint, or port-polarity semantics fall back or error. | +| Performance | Only measured benchmark results and their hardware/model context; no unconditional speedup claim. | +| Manual escape hatch | Link and minimal example for explicitly constructing a fundamental section when the caller wants direct control. | + +Detailed `docs/wasm-api.md` documentation additionally includes the complete +support matrix, TypeScript declarations, lifecycle and ownership, copy/tag/port +mapping rules, mathematical transforms, structured fallback reason catalogue, +tolerances, errors, worker parity, and the future helix-orientation acceptance +requirements. The npm README should summarize these accurately and link with an +absolute repository URL that still resolves when rendered from the packed npm +artifact. + +DoD: + +- a new consumer can build the 4 x 4 reference array from both a quadrant and a + full position list without reading native source; +- the packed npm README independently explains how to supply the full NxN XY + position list and let automatic detection select or reject symmetry; +- the README's auto, off, require, direct, and worker examples compile against + the emitted declarations, and executable examples pass against the built WASM; +- documentation states that epsilon acceptance canonicalizes coordinates and + reports the adjustment; +- documentation clearly distinguishes structural symmetry from excitation + symmetry and from explicit `GM`-style copies; +- helix/transformed-pattern examples fail or fall back exactly as documented; +- `npm pack --dry-run` lists `README.md`, and inspection of the actual tarball + confirms it contains the updated capability section and working links; +- package description/keywords and documentation do not claim support beyond + the tested operation/pattern matrix; +- all commands in the release checklist pass from a clean build; and +- the global DoD below is checked and the status table contains complete + evidence for every WP. + +Handoff focus: WP-S9 is a deliberately mechanical finalization step. Do not +bump a version in WP-S8 merely to make documentation examples appear final. + +### WP-S9 — Final version bump and release identity + +Dependencies: WP-S8 and completion of every preceding work package. + +This is the final implementation work package. The package, native engine, and +ABI versions are independent release identities and must be changed according +to their own compatibility rules: + +| Identity | Before | After | Reason | +|---|---:|---:|---| +| `@necpp-engine/wasm` package | `0.1.1` | `0.2.0` | substantial additive public TypeScript/WASM capability in a pre-1.0 package | +| NEC2++ native engine | `2.3.4` | `2.4.0` | additive stateful symmetry and C entry points | +| WASM ABI | `1` | `1` | entry points are additive; no existing v1 signature or buffer contract changes | + +The ABI version must not be incremented, and the `necpp_wasm_v1_*` prefix must +not be renamed, unless implementation work discovers an unavoidable breaking +change. Such a discovery blocks WP-S9 and requires an explicit contract review; +it must not be hidden inside the version-bump commit. + +Deliverables: + +- change the root `CMakeLists.txt` engine version from `2.3.4` to `2.4.0`; +- change the package version from `0.1.1` to `0.2.0` in + `packages/necpp-wasm/package.json` and every root/package entry in + `packages/necpp-wasm/package-lock.json`; +- update `packages/necpp-wasm/src/versions.ts` so `packageVersion` is `0.2.0`, + `engineVersion` is `2.4.0`, and `abiVersion` remains `1`; +- finalize the `0.2.0` changelog section and release date, documenting automatic + detection/fallback, the representation-independent consumer facade, supported + symmetries, equivalence coverage, and first-release pattern prohibitions; +- update pinned version assertions and user-facing versioned examples, including + package README/CDN examples and browser/runtime integration expectations; +- rebuild the native engine and WASM artifact after the bump so embedded runtime + metadata reports engine `2.4.0`, then rebuild declarations and package output; +- create the final npm tarball, inspect its manifest and contents, and record its + filename and digest with the release evidence; and +- prepare, but do not create or publish, the release/tag identity unless a user + separately authorizes the external release action. + +DoD: + +- repository search finds no stale expected `0.1.1` package or `2.3.4` engine + identity in active source, tests, generated package metadata, or versioned + documentation examples; historical changelog text may retain old versions; +- build-time version verification proves `package.json`, lockfile, + `src/versions.ts`, root CMake metadata, and the embedded WASM engine agree; +- native and WASM runtime version queries report `2.4.0`, package metadata and + imports report `0.2.0`, and ABI queries still report `1`; +- the complete clean-build release checklist from WP-S8 passes again after the + bump, including packed-consumer and browser integration tests; +- `npm pack --dry-run` and final tarball inspection contain the intended symmetry + API declarations, implementation, WASM artifact, documentation, and no + unintended benchmark output; and +- the version bump is the last planned source change. Any correction caused by + final validation is followed by rebuilding, retesting, and rechecking every + release identity before WP-S9 is marked complete. + +## 14. Global Definition of Done + +Symmetry support is ready to merge/release only when: + +1. **Contract:** public inputs, outputs, units, ownership, lifecycle, copy order, + errors, and fallback behavior are documented and type-tested. +2. **Native correctness:** GX/GR legacy coverage remains green and stateful + full-versus-symmetric native tests pass. +3. **ABI stability:** all new functionality crosses additive C/WASM symbols; + no existing v1 signature, enum value, or result buffer meaning changed. +4. **Direct/worker parity:** direct and worker APIs return the same metadata and + numerical results, with cancellation and structured-clone behavior tested. +5. **Consumer transparency:** the beam-steering application runs identical, + unbranched prepare/Z/solve/far-field code for explicit, symmetric, and + automatic-fallback arrays; all ordinary results use full caller port order + and existing result types. +6. **Transparent safety:** symmetry is accepted only after one-to-one orbit, + pattern, load, environment, and epsilon validation; otherwise explicit + fallback is deterministic and explained. +7. **Pattern prohibition:** helix, tilted/off-axis wire, rotated pattern, arc, + patch, endpoint-reversal-only, and opaque patterns cannot enter the first + release symmetry path. +8. **Gathered matrices:** complete complex Z and Y for mandatory small cases + match the full explicit model in caller order within `1e-8` metrics. +9. **Beam steering:** all canonical current cases match full explicit complex + fields, port quantities, field cuts, and peak sample; embedded-field + superposition agrees with direct fields. +10. **Ground:** perfect and finite homogeneous ground coverage passes for valid + vertical planes/rotations, and incompatible `z=0` structural reflection is + rejected. +11. **Epsilon disclosure:** exact and jittered cases are distinct; every + canonical adjustment and the maximum adjustment are observable. +12. **Translation phase:** an off-origin explicit array and a centered symmetric + array agree in complex far field after the tested `exp(+j*k*u dot c)` + correction. +13. **Performance:** the process-isolated benchmark records correctness, + preparation speed, auto-planner overhead, retained solves, fields, and + memory across 2 through 16 sides, and handles the performance gates as + specified. +14. **Regression:** native unit/regression tests, stateful WP partitions, C ABI + tests, WASM smoke, TypeScript tests/typecheck, worker integration, + packed-consumer tests, and browser integration are green. +15. **Published documentation:** detailed API docs and the README included in + the npm tarball accurately explain supported symmetries, automatic + detection/fallback, the unchanged full-array contract, diagnostics, + canonicalization, limitations, and measured performance; public examples + are validated against the shipped declarations and runtime. +16. **Version identity:** the final work package sets package `0.2.0`, engine + `2.4.0`, and ABI `1`; source metadata, lockfile, tests, documentation, + embedded WASM, and inspected tarball all agree. +17. **Handoff evidence:** every WP status row names its evidence and leaves no + silent skipped test or unresolved blocker. + +## 15. Agent handoff protocol + +At the start of each sequential agent's work: + +1. Read the repository `AGENTS.md`, this document, and the preceding WP's status + notes. +2. Run `git status --short` and preserve all unrelated user/agent changes. +3. Inspect the actual current interfaces; do not assume line numbers or the + initial proposal are unchanged. +4. Mark only the selected WP `in progress`. +5. Run the narrowest existing tests before editing to establish a baseline. + +Before handing off: + +1. Complete the WP's tests and proportional regression suite. +2. Record exact commands, platform limitations, failures, and results in this + document or a linked tracked results note. +3. Update the status row to `complete` only if every DoD item is satisfied; + otherwise use `blocked` and name the concrete blocker. +4. List public/API decisions the next agent must preserve. +5. Do not weaken a numerical or performance gate merely to make a WP green. +6. Leave generated benchmark data out of source control unless explicitly + curated and documented. + +Suggested validation commands should be updated by WP-S0/WP-S8 to match the +active build directories, but the final set must cover at least: + +```powershell +cmake --build --config Release +ctest --test-dir -C Release --output-on-failure +npm --prefix packages/necpp-wasm test +npm --prefix packages/necpp-wasm run test:wasm +npm --prefix packages/necpp-wasm run test:browser +npm --prefix packages/necpp-wasm run bench:array -- +``` + +The authoritative completion evidence is the executed command and artifact, +not the placeholder build-directory text above. From f1d85e89b2ec59f39479d3ec7135201f93efe9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 12:07:34 +0200 Subject: [PATCH 36/46] feat: define symmetry contracts and fixtures --- docs/01_symmetry_support.md | 215 ++++++++++++++++-- docs/wasm-api.md | 78 ++++++- packages/necpp-wasm/bench/array-case.mjs | 34 +-- packages/necpp-wasm/src/index.ts | 14 ++ packages/necpp-wasm/src/model.ts | 12 +- packages/necpp-wasm/src/symmetry.ts | 18 ++ packages/necpp-wasm/src/types.ts | 97 +++++++- packages/necpp-wasm/src/worker-client.ts | 13 +- packages/necpp-wasm/src/worker-runtime.ts | 5 +- packages/necpp-wasm/src/worker.ts | 14 ++ packages/necpp-wasm/test-d/public-api.test.ts | 131 ++++++++++- packages/necpp-wasm/test-d/worker-api.test.ts | 14 +- .../necpp-wasm/test/array-benchmark.test.mjs | 1 + .../necpp-wasm/test/facade-mapping.test.mjs | 6 +- .../test/fixtures/reference-array.mjs | 212 +++++++++++++++++ .../test/symmetry-contract.test.mjs | 112 +++++++++ .../necpp-wasm/test/worker-client.test.mjs | 5 +- src/nec_stateful_model.h | 43 ++++ src/nec_stateful_model_tb.cpp | 25 ++ tests/data/symmetry_reference_array_4x4.json | 29 +++ 20 files changed, 1012 insertions(+), 66 deletions(-) create mode 100644 packages/necpp-wasm/src/symmetry.ts create mode 100644 packages/necpp-wasm/test/fixtures/reference-array.mjs create mode 100644 packages/necpp-wasm/test/symmetry-contract.test.mjs create mode 100644 tests/data/symmetry_reference_array_4x4.json diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 35e2492c..607a78ab 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -216,19 +216,43 @@ load applied identically to every segment. Absolute segment-number loads need an explicit orbit mapping; they must not be assumed symmetric from a numeric range alone. -## 6. Proposed low-level TypeScript symmetry API +### 5.4 Failure classification -The API below is the proposed advanced contract for callers that deliberately +Symmetry failures carry one of the following stable `SymmetryFailureReason` +values. The ordinary package error code remains the primary error taxonomy; +the reason refines it and identifies whether the automatic full-model retry is +permitted. + +| Reason | Package error | Representation-eligibility failure | Required behavior | +|---|---|---:|---| +| `INVALID_SYMMETRY` | `NEC_INPUT` | no | Reject malformed planes, orders, axes, tag increments, or mutually inconsistent descriptor fields before mutation. | +| `INCOMPATIBLE_GROUND` | `NEC_GEOMETRY` | yes, only when the unchanged full model is valid | Reject structural `z=0` reflection with ground and other environment/symmetry conflicts; `"auto"` may retry explicit once. | +| `INCOMPLETE_LOAD_ORBIT` | `NEC_GEOMETRY` | yes, only when the unchanged full loads are valid | Detect at `prepare()` after all loads have been supplied; `"auto"` may retry the full explicit load set once. | +| `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM` | `NEC_GEOMETRY` when configured as an error; otherwise planner fallback | yes | Default to an explained explicit plan; `"require"` or `onUnsupported: "error"` throws without entering native symmetry generation. | + +Allocation, cancellation, conditioning, solver, and invalid full-geometry +failures are never representation-eligibility failures and must not be hidden +by an explicit retry. Every throwing implementation attaches the reason as +`details.symmetryFailure`; planner fallback diagnostics use the corresponding +`SymmetrizationReasonCode`. + +## 6. Finalized low-level TypeScript symmetry API + +The API below is the advanced contract for callers that deliberately construct a fundamental section. It is not the representation-independent beam-steering application API; that facade is defined in Section 8.1. WP-S0 -may refine names, but later work packages must not independently redesign it. +finalized these names; later work packages must preserve them. ```ts export type ReflectionPlane = "x=0" | "y=0" | "z=0"; +/** Branded integer constructed by rotationalOrder(); range is 2..INT32_MAX. */ +export type RotationalOrder = number & { /* private brand */ }; +export function rotationalOrder(order: number): RotationalOrder; + export interface ReflectionSymmetry { readonly kind: "reflection"; - readonly planes: readonly ReflectionPlane[]; + readonly planes: readonly [ReflectionPlane, ...ReflectionPlane[]]; /** Positive offset applied once per generated copy block. */ readonly tagIncrement: number; } @@ -237,7 +261,7 @@ export interface RotationalSymmetry { readonly kind: "rotational"; readonly axis: "z"; /** Total number of sections, including the original. */ - readonly order: number; + readonly order: RotationalOrder; readonly tagIncrement: number; } @@ -292,6 +316,13 @@ callers that ignore it. The ABI should retain the current non-symmetric completion call and add a symmetric completion entry point rather than change an existing C signature. +The brand is intentional: TypeScript cannot express "any integer at least two" +as a structural `number` subtype. `rotationalOrder()` performs the runtime +integer/range check and makes `order: 1` or an unchecked arbitrary number a +compile-time error. Reflection planes use a nonempty tuple, so `planes: []` is +also rejected during type checking. Runtime validation must additionally reject +duplicate planes and invalid or overflowing tag increments. + The returned copy list is data-only and structured-cloneable. Convenience mapping helpers belong in TypeScript rather than the ABI: @@ -357,6 +388,17 @@ Use 300 MHz as the default executable fixture frequency, but derive every dimension from the engine's speed-of-light constant rather than treating one metre as exactly one wavelength. +WP-S0 selected a small language-neutral golden table plus one JavaScript +generator. The executable generator is +`packages/necpp-wasm/test/fixtures/reference-array.mjs`; both array benchmarks +and subsequent TypeScript symmetry tests import it. The cross-language golden +table is `tests/data/symmetry_reference_array_4x4.json`. Native helpers may use +`em::speed_of_light()` and the formulas above, but must check their 4 x 4 +coordinates and maps against that table rather than introduce independent +geometry constants. The JavaScript generator derives the same constant from +the engine's vacuum permittivity and permeability values; it does not use the +SI exact value `299792458`. + ### 7.1 Explicit baseline construction The baseline adds all `n*n` wires independently with unique tags and defines @@ -376,6 +418,23 @@ When fundamental tags are contiguous `1..q`, use `tagIncrement = q`. The four tag blocks are then contiguous, but their physical order follows NEC copy order, not caller row-major order. +For the golden 4 x 4 case, the positive-quadrant fundamental order is increasing +Y with X varying fastest: `(lambda/4,lambda/4)`, +`(3lambda/4,lambda/4)`, `(lambda/4,3lambda/4)`, and +`(3lambda/4,3lambda/4)`. Native copies are fundamental, Y-reflected, +X-reflected, then XY-reflected, with tag offsets `0,4,8,12`. The executable +caller-to-native scatter map is: + +```text +[15,14,6,7, 13,12,4,5, 9,8,0,1, 11,10,2,3] +``` + +Its inverse generated-to-caller gather map is: + +```text +[10,11,14,15, 6,7,2,3, 9,8,13,12, 5,4,1,0] +``` + ### 7.3 Odd-sided arrays A conventional centered odd-sided grid contains elements on both vertical @@ -495,11 +554,38 @@ or discriminated union. `getDiagnostics()` is the sole supported way for the application to observe whether optimization occurred. Ignoring diagnostics must be sufficient for correct use. -### 8.2 Proposed planning types +### 8.2 Finalized planning types ```ts export type ArrayElementId = string | number; +export interface RelativeWireDefinition { + readonly id: string; + readonly segments: number; + readonly startM: CartesianPointM; + readonly endM: CartesianPointM; + readonly radiusM: number; +} + +export interface RelativePortDefinition { + readonly wireId: string; + readonly segment: number; + readonly name?: string; +} + +export interface RelativeSegmentSelection { + readonly wireId: string; + readonly firstSegment?: number; + readonly lastSegment?: number; +} + +type RetargetLoad = + T extends LoadDefinition + ? Omit & { readonly target: RelativeSegmentSelection } + : never; + +export type RelativeLoadDefinition = RetargetLoad; + export interface PositionedArrayElement { readonly id: ArrayElementId; readonly positionM: readonly [xM: number, yM: number]; @@ -522,13 +608,20 @@ export interface FullArrayDescription { readonly ground: GroundModel; } +export interface CanonicalArrayElement { + readonly id: ArrayElementId; + readonly positionM: readonly [xM: number, yM: number]; + readonly patternId: string; + readonly rotationDeg: 0; +} + export interface SymmetrizerOptions { /** Required; no implicit geometry tolerance. */ readonly positionEpsilonM: number; readonly center?: "auto" | readonly [xM: number, yM: number]; readonly allowReflection?: boolean; readonly allowRotation?: boolean; - readonly preferredRotationOrders?: readonly number[]; + readonly preferredRotationOrders?: readonly RotationalOrder[]; readonly onUnsupported?: "explicit-fallback" | "error"; } @@ -544,12 +637,45 @@ export type SymmetrizationReasonCode = | "GROUND_BREAKS_SYMMETRY" | "TAG_SPACE_EXHAUSTED"; +export interface SymmetrizationReason { + readonly code: SymmetrizationReasonCode; + readonly message: string; + readonly callerElementIndex?: number; + readonly patternId?: string; +} + +export interface PositionCanonicalization { + readonly callerElementIndex: number; + readonly originalPositionM: readonly [xM: number, yM: number]; + readonly canonicalPositionM: readonly [xM: number, yM: number]; + readonly adjustmentM: readonly [dxM: number, dyM: number]; + readonly distanceM: number; +} + +export interface SymmetryCandidateDiagnostics { + readonly symmetry: GeometrySymmetry; + readonly accepted: boolean; + readonly reasons: readonly SymmetrizationReason[]; +} + +export interface SymmetrizerDiagnostics { + readonly representation: "explicit" | "symmetric"; + readonly exact: boolean; + readonly effectiveCenterM: readonly [xM: number, yM: number]; + readonly maxPositionAdjustmentM: number; + readonly canonicalizations: readonly PositionCanonicalization[]; + readonly candidates: readonly SymmetryCandidateDiagnostics[]; + readonly reasons: readonly SymmetrizationReason[]; +} + export interface ArrayElementMapping { readonly callerElementIndex: number; readonly callerElementId: ArrayElementId; readonly fundamentalElementIndex: number; readonly copyIndex: number; readonly generatedTag: number; + /** Parallel to generatedPortIndices. */ + readonly callerPortIndices: readonly number[]; readonly generatedPortIndices: readonly number[]; readonly positionAdjustmentM: readonly [dxM: number, dyM: number]; } @@ -576,9 +702,13 @@ export type ArrayBuildPlan = }; ``` -Exact names may change in WP-S0, but the result must retain caller IDs, caller -indices, copy indices, generated tags, port mappings, adjustment vectors, -accepted/rejected candidates, and reason codes. +These names and units are final. Caller ports are ordered by caller element, +then by the referenced pattern's `ports` order. Every returned collection and +coordinate tuple is caller-owned immutable data; no plan exposes a native +pointer or borrowed WASM view. Later work may add optional diagnostics but must +not rename these fields or remove caller IDs, indices, copy indices, generated +tags, paired port mappings, adjustment vectors, accepted/rejected candidates, +or reason codes. ### 8.3 Position matching algorithm @@ -786,26 +916,30 @@ Add a native descriptor equivalent to: ```cpp enum class nec_geometry_symmetry_kind { - none, - reflection, - rotational, + none = 0, + reflection = 1, + rotational = 2, }; struct nec_geometry_symmetry { - nec_geometry_symmetry_kind kind; - unsigned reflection_plane_mask; // X=1, Y=2, Z=4 - int rotational_order; - int tag_increment; + nec_geometry_symmetry_kind kind = nec_geometry_symmetry_kind::none; + uint32_t reflection_plane_mask = 0; // X=1, Y=2, Z=4 + int rotational_order = 1; + int tag_increment = 0; }; struct nec_geometry_completion_result { nec_geometry_symmetry symmetry; - int section_count; - int64_t fundamental_segment_count; - int64_t full_segment_count; + int section_count = 1; + int64_t fundamental_segment_count = 0; + int64_t full_segment_count = 0; }; ``` +WP-S0 added these definitions to `nec_stateful_model.h`, including the stable +`nec_reflection_plane_x/y/z` bit values. The behavior overload remains WP-S1 +and WP-S2 work; the data format is no longer provisional. + `nec_stateful_model::complete_geometry()` should accept an optional descriptor, validate it while still in geometry-building state, call the existing geometry generator, complete geometry, store immutable completion metadata, and return @@ -1096,7 +1230,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP | State | Owner/agent | Evidence/commit | Notes for next agent | |---|---|---|---|---| -| WP-S0 Contract and shared fixtures | not started | — | — | — | +| WP-S0 Contract and shared fixtures | complete | Codex | Native `[wp_s0]`: 15 assertions; `necpp_unit`: 1/1; npm: 38/38 + typecheck | Preserve the finalized descriptor values, branded rotational order, Z/Y/X copy order, and golden scatter/gather maps. | | WP-S1 Native geometry safety and metadata | not started | — | — | — | | WP-S2 Stateful symmetry and validation | not started | — | — | — | | WP-S3 Additive C/WASM ABI | not started | — | — | — | @@ -1146,6 +1280,45 @@ DoD: Handoff focus: WP-S1 should be able to implement native behavior without inventing a second metadata format. +Completion evidence (2026-08-30, Windows/MSVC): + +- Baseline before edits: `npm --prefix packages/necpp-wasm run typecheck` — + passed. +- `npm --prefix packages/necpp-wasm test` — passed 38/38 Node tests and the + strict TypeScript compile-valid/compile-invalid contract suite. +- `npm --prefix packages/necpp-wasm run build` — emitted declarations and + assembled `dist` successfully (WASM 698667 bytes, loader 77177 bytes). +- The shell did not have `cmake` on `PATH`; the existing build cache identified + `C:\Users\andre\AppData\Local\Temp\codex-necpp-cmake\cmake\data\bin\cmake.exe`. + Running that executable with `--build build-wp0 --config Release` completed. + Existing MSVC conversion/unknown-pragma warnings remained; no new build error + occurred. +- The paired cached `ctest.exe --test-dir build-wp0 -C Release -R necpp_unit + --output-on-failure` passed 1/1 tests. +- `build-wp0\tests\Release\nec2++_tests.exe "[wp_s0]"` passed 15 assertions in + one native contract test. +- `git diff --check` passed; line-ending notices are repository checkout-policy + warnings, not whitespace errors. + +Contract decisions for WP-S1 and later: + +- Native kind values are none/reflection/rotational = 0/1/2; reflection plane + mask bits are X/Y/Z = 1/2/4. The descriptor and completion metadata in + `nec_stateful_model.h` are the implementation format. +- TypeScript uses `rotationalOrder()` because structural TypeScript cannot + express an arbitrary integer `>= 2`; raw order numbers are deliberately not + accepted. Reflection planes are a nonempty tuple. +- Ordinary direct and worker completion now returns `{}` as a + `GeometryCompletionResult`, so callers that ignore the former `void` result + remain source-compatible. Passing `symmetry` is intentionally rejected by + the WP-S0 runtime stub until the native/ABI/facade work packages implement it. +- The 4 x 4 JSON table is the cross-language golden. The JavaScript fixture is + the single correctness/benchmark generator and uses the engine-derived + speed of light, positive-Z over-ground wires, and exact Section 7 dimensions. +- Reflection copy order is fixed by NEC passes, not caller plane-list order: + fundamental, Y, X, XY for the 4 x 4 quadrant. Port/vector scatter is + caller-to-native; gather is its native-to-caller inverse. + ### WP-S1 — Native geometry safety and metadata Dependencies: WP-S0. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 77ce346f..2231acd8 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -5,6 +5,11 @@ 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). +The WP-S0 symmetry names, metadata, lifecycle shape, and fixture mappings below +are finalized as an interface-first contract. Non-symmetric completion already +returns an empty `GeometryCompletionResult`; native symmetry execution and its +additive ABI entry point are staged for WP-S1 through WP-S4. + ## Package and runtime boundary The final npm package name is **`@necpp-engine/wasm`**. The unscoped name @@ -46,7 +51,8 @@ These conventions apply to every public method and returned value. - Phasors use \(e^{+j\omega t}\). An outgoing spherical wave therefore has propagation factor \(e^{-jkR}/R\), with \(k=2\pi f/c_0\) and the NEC-2 value - \(c_0=299{,}800{,}000\ \mathrm{m/s}\). + \(c_0=1/\sqrt{(4\pi\,10^{-7})(8.854\,10^{-12})} + \approx299{,}795{,}637.69321626\ \mathrm{m/s}\). - Geometry, radii, ranges, and all other public distances are in metres. Frequency is in MHz at the API boundary. - Voltages and currents are complex peak-amplitude phasors, not RMS phasors, @@ -154,6 +160,10 @@ Additional lifecycle rules: - Geometry can change only before `completeGeometry()` and completion occurs exactly once. At least one valid geometry element is required. +- `completeGeometry({ symmetry })` makes symmetry generation the final geometry + mutation and returns immutable copy/count metadata. No wire, patch, or other + geometry primitive may be added after generation. Plane-list order never + changes NEC's fixed Z-then-Y-then-X copy order. - `definePorts()` replaces the complete port list. It requires a nonempty list of unique, valid tag/segment pairs and is deliberately frozen before preparation so all later results have stable ordering. @@ -187,7 +197,7 @@ enumerates every operation/state pair plus both `prepare()` branches. | `createNecModel(options?)` | Optional `wasmUrl` or caller-owned WASM bytes | Promise of an `empty` model | `NecRuntimeError` for load/instantiate/version failure; `NecInputError` if both overrides are supplied | | `createNecWorkerModel(options?)` | Same loading overrides plus optional `onProgress` | Promise of an `empty` worker model | Same loading failures; `NecRuntimeError` if the worker cannot start or is terminated | | `addWire(wire)` | Positive integer tag/count; distinct finite endpoints and positive finite radius, all in m | `void`; copies the definition | `NecInputError` for shape/range errors; `NecGeometryError` for engine geometry limits | -| `completeGeometry(options?)` | Ground connection: `none` (default), `interpolate`, or `zero-current` | `void` | `NecGeometryError` for intersections, invalid junctions, or a ground-incompatible structure | +| `completeGeometry(options?)` | Ground connection plus optional finalized reflection/rotation descriptor | `GeometryCompletionResult`; `symmetry` is absent for ordinary completion | `NecInputError` for an invalid descriptor; `NecGeometryError` for intersections, invalid junctions, symmetry/ground conflicts, or a ground-incompatible structure | | `definePorts(ports)` | Nonempty ordered tag and one-based segment pairs | `void`; copies and freezes order | `NecPortError` for missing/duplicate ports or non-source-capable segments; `NecInputError` for malformed integers | | `addLoad(load)` | Segment target and impedance, RLC, or conductivity values in the units above | `void`; invalidates prepared data | `NecInputError` for invalid values/ranges; `NecGeometryError` when no segment matches | | `clearLoads()` | None | `void`; removes every load and invalidates prepared data | No non-state failure | @@ -219,12 +229,72 @@ message as `message` or `cause`; a raw number or string is never thrown. | `NecRuntimeError` | `NEC_RUNTIME` | WASM loading, ABI mismatch, allocation, or other runtime-boundary failure | Every class derives from `NecError`, whose `code` is stable for programmatic -handling. Messages and `details` aid diagnostics but are not a compatibility -surface. Validation failures do not mutate model state. A failed calculation +handling. Messages and undocumented `details` aid diagnostics but are not a +compatibility surface; documented discriminants such as +`details.symmetryFailure` are stable. Validation failures do not mutate model state. A failed calculation keeps the last successfully prepared factorization and consumer solution when the native layer can prove they are intact; otherwise it rolls back to `geometry-complete` and discards prepared data. +### Symmetry failure refinement + +Symmetry-specific failures refine the ordinary error code through +`details.symmetryFailure` and the exported `SymmetryFailureReason` type: + +| Reason | Error code | Automatic explicit retry | +|---|---|---| +| `INVALID_SYMMETRY` | `NEC_INPUT` | Never | +| `INCOMPATIBLE_GROUND` | `NEC_GEOMETRY` | Only when the unchanged full model is valid | +| `INCOMPLETE_LOAD_ORBIT` | `NEC_GEOMETRY` | Only when the unchanged full load set is valid | +| `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM` | `NEC_GEOMETRY` when configured to throw | Default behavior is an explained planner fallback; `"require"` may throw | + +Allocation, cancellation, conditioning, solver, and invalid full-geometry +failures are not representation-eligibility failures and are never hidden by a +retry. + +## Geometry symmetry contract (WP-S0) + +`CompleteGeometryOptions.symmetry` accepts exactly one of: + +- `{ kind: "reflection", planes, tagIncrement }`, where `planes` is a nonempty + tuple containing only `"x=0"`, `"y=0"`, and/or `"z=0"`; or +- `{ kind: "rotational", axis: "z", order, tagIncrement }`, where `order` is + produced by `rotationalOrder(number)` and represents the total section count. + +`rotationalOrder()` accepts signed-32-bit integers from 2 through 2147483647. +The brand makes raw unchecked numbers—including literal `1`—compile-invalid. +The runtime additionally rejects duplicate reflection planes, nonpositive or +overflowing tag increments, fixed/crossing geometry, duplicate generated tags, +and incompatible ground. + +`GeometryCompletionResult.symmetry`, when present, contains the symmetry kind, +section count, fundamental/full segment counts, and a structured-cloneable +copy list. Reflection copies use `cartesian-signs`; rotational copies use +`rotate-z` with degrees. Copy index zero is always the fundamental section and +`generatedTag = baseTag + copy.tagOffset`. + +For reflections, NEC appends copies in fixed Z, Y, X pass order, independent of +the order of `planes`. For the 4 x 4 positive-X/positive-Y fixture this yields +fundamental, Y-reflected, X-reflected, then XY-reflected blocks with offsets +`0,4,8,12`. Caller row-major order uses X fastest. Its executable maps are: + +```text +scatter caller -> native: +[15,14,6,7, 13,12,4,5, 9,8,0,1, 11,10,2,3] + +gather native -> caller: +[10,11,14,15, 6,7,2,3, 9,8,13,12, 5,4,1,0] +``` + +The shared generator is +[`reference-array.mjs`](../packages/necpp-wasm/test/fixtures/reference-array.mjs) +and its language-neutral 4 x 4 golden table is +[`symmetry_reference_array_4x4.json`](../tests/data/symmetry_reference_array_4x4.json). +At frequency `f`, it uses the NEC engine's speed-of-light constant, length +`lambda/3`, Z endpoints `lambda/12` and `5*lambda/12`, spacing `lambda/2`, +height `lambda/4`, radius `lambda/1000`, 11 segments, and feed segment 6. The +primary environment is perfect ground with geometry `groundConnection: "none"`. + ## Worker facade `createNecWorkerModel()` is imported from `@necpp-engine/wasm/worker`. The package diff --git a/packages/necpp-wasm/bench/array-case.mjs b/packages/necpp-wasm/bench/array-case.mjs index 7ccb1581..594b88c1 100644 --- a/packages/necpp-wasm/bench/array-case.mjs +++ b/packages/necpp-wasm/bench/array-case.mjs @@ -1,7 +1,7 @@ import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; -const SPEED_OF_LIGHT_M_PER_S = 299_792_458; +import { createReferenceArrayFixture } from "../test/fixtures/reference-array.mjs"; function requireInteger(value, name, minimum = 1) { const parsed = Number(value); @@ -55,35 +55,7 @@ function parseArguments(argv) { } export function createArrayDefinition({ side, segments, frequencyMHz }) { - const wavelengthM = SPEED_OF_LIGHT_M_PER_S / (frequencyMHz * 1e6); - const elementHalfLengthM = wavelengthM / 8; - const spacingM = wavelengthM / 2; - const radiusM = wavelengthM / 1000; - const centreSegment = (segments + 1) / 2; - const wires = []; - for (let y = 0; y < side; y += 1) { - for (let x = 0; x < side; x += 1) { - const tag = y * side + x + 1; - const xM = (x - (side - 1) / 2) * spacingM; - const yM = (y - (side - 1) / 2) * spacingM; - wires.push({ - tag, - segments, - start: [xM, yM, -elementHalfLengthM], - end: [xM, yM, elementHalfLengthM], - radiusM, - }); - } - } - return { - side, - segments, - frequencyMHz, - wavelengthM, - wires, - ports: wires.map(({ tag }) => ({ tag, segment: centreSegment })), - equations: side * side * segments, - }; + return createReferenceArrayFixture({ side, segments, frequencyMHz }); } export function buildEquivalentDeck(definition) { @@ -104,6 +76,7 @@ export function buildEquivalentDeck(definition) { lines.push( "GE 0", `FR 0 1 0 0 ${definition.frequencyMHz} 0`, + "GN 1", ); for (const port of definition.ports) { lines.push(`EX 0 ${port.tag} ${port.segment} 0 1 0`); @@ -179,6 +152,7 @@ async function runStateful(api, definition, retainedSolves) { } model.completeGeometry(); model.definePorts(definition.ports); + model.setGround(definition.ground); const geometryMs = performance.now() - geometryStart; const prepareStart = performance.now(); diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 8da6f350..4462f1c5 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -10,11 +10,13 @@ export { } from "./errors.js"; export { abiVersion, engineVersion, packageVersion } from "./versions.js"; +export { rotationalOrder } from "./symmetry.js"; export type { NecErrorCode, NecErrorOptions } from "./errors.js"; export type { AngleSweep, + CartesianSignsTransform, CartesianPointM, CompleteGeometryOptions, ComplexMatrix, @@ -31,6 +33,8 @@ export type { FarFieldResult, FiniteGround, FreeSpaceGround, + GeometryCompletionResult, + GeometrySymmetry, GroundConnection, GroundModel, ImpedanceLoad, @@ -47,9 +51,19 @@ export type { PortDefinition, PortSolution, PrepareOptions, + ReflectionPlane, + ReflectionSymmetry, + RotationalOrder, + RotationalSymmetry, + RotateZTransform, RunDeckOptions, SegmentSelection, SeriesRlcLoad, + SymmetryCopy, + SymmetryCopyTransform, + SymmetryExpansion, + SymmetryFailureClassification, + SymmetryFailureReason, WireDefinition, } from "./types.js"; diff --git a/packages/necpp-wasm/src/model.ts b/packages/necpp-wasm/src/model.ts index 5fda5fdd..2efcb078 100644 --- a/packages/necpp-wasm/src/model.ts +++ b/packages/necpp-wasm/src/model.ts @@ -16,6 +16,7 @@ import type { EmbeddedFieldNormalization, FarFieldRequest, FarFieldResult, + GeometryCompletionResult, GroundModel, ImpedanceResult, LoadDefinition, @@ -549,9 +550,17 @@ export class WasmNecModel implements NecModel { )); } - completeGeometry(options: CompleteGeometryOptions = {}): void { + completeGeometry( + options: CompleteGeometryOptions = {}, + ): GeometryCompletionResult { this.#assertOperation("completeGeometry"); const record = requireRecord(options, "options"); + if (record.symmetry !== undefined) { + throw new NecRuntimeError( + "Symmetric geometry completion is reserved by the WP-S0 contract but is not implemented by this runtime yet", + { details: { operation: "completeGeometry", symmetry: record.symmetry } }, + ); + } const connection = record.groundConnection ?? "none"; const nativeConnection = connection === "none" ? 0 @@ -567,6 +576,7 @@ export class WasmNecModel implements NecModel { nativeConnection, ), ); + return Object.freeze({}); } definePorts(ports: readonly PortDefinition[]): void { diff --git a/packages/necpp-wasm/src/symmetry.ts b/packages/necpp-wasm/src/symmetry.ts new file mode 100644 index 00000000..38084ce3 --- /dev/null +++ b/packages/necpp-wasm/src/symmetry.ts @@ -0,0 +1,18 @@ +import { NecInputError } from "./errors.js"; +import type { RotationalOrder } from "./types.js"; + +const INT32_MAX = 2_147_483_647; + +/** + * Validate and brand an N-fold rotational section count for geometry symmetry. + * The native ABI represents the value as a signed 32-bit integer. + */ +export function rotationalOrder(order: number): RotationalOrder { + if (!Number.isSafeInteger(order) || order < 2 || order > INT32_MAX) { + throw new NecInputError( + "Rotational symmetry order must be an integer from 2 through 2147483647", + { details: { symmetryFailure: "INVALID_SYMMETRY", order } }, + ); + } + return order as RotationalOrder; +} diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts index d4e57f96..ead15a68 100644 --- a/packages/necpp-wasm/src/types.ts +++ b/packages/necpp-wasm/src/types.ts @@ -37,9 +37,100 @@ export interface WireDefinition { /** How wire ends on z=0 are treated when geometry is completed. */ export type GroundConnection = "none" | "interpolate" | "zero-current"; +/** A coordinate plane through the global NEC model origin. */ +export type ReflectionPlane = "x=0" | "y=0" | "z=0"; + +declare const rotationalOrderBrand: unique symbol; + +/** + * An integer rotational section count greater than or equal to two. + * Construct values with {@link rotationalOrder} so the range check is explicit. + */ +export type RotationalOrder = number & { + readonly [rotationalOrderBrand]: "RotationalOrder"; +}; + +export interface ReflectionSymmetry { + readonly kind: "reflection"; + /** Nonempty set of generating planes. Order does not affect copy order. */ + readonly planes: readonly [ReflectionPlane, ...ReflectionPlane[]]; + /** Positive integer offset applied once per generated copy block. */ + readonly tagIncrement: number; +} + +export interface RotationalSymmetry { + readonly kind: "rotational"; + /** The first public contract supports only the global Z axis. */ + readonly axis: "z"; + /** Total number of sections, including the fundamental section. */ + readonly order: RotationalOrder; + /** Positive integer offset applied once per generated copy block. */ + readonly tagIncrement: number; +} + +export type GeometrySymmetry = ReflectionSymmetry | RotationalSymmetry; + +export interface CartesianSignsTransform { + readonly kind: "cartesian-signs"; + readonly signs: readonly [x: 1 | -1, y: 1 | -1, z: 1 | -1]; +} + +export interface RotateZTransform { + readonly kind: "rotate-z"; + readonly angleDeg: number; +} + +export type SymmetryCopyTransform = CartesianSignsTransform | RotateZTransform; + +export interface SymmetryCopy { + /** Zero-based native copy-block index; zero is the fundamental section. */ + readonly index: number; + readonly tagOffset: number; + readonly transform: SymmetryCopyTransform; +} + +export interface SymmetryExpansion { + readonly kind: GeometrySymmetry["kind"]; + readonly sectionCount: number; + readonly fundamentalSegmentCount: number; + readonly fullSegmentCount: number; + /** Native copy-major order. This is not caller spatial order. */ + readonly copies: readonly SymmetryCopy[]; +} + +/** Stable machine-readable classifications attached to symmetry failures. */ +export type SymmetryFailureReason = + | "INVALID_SYMMETRY" + | "INCOMPATIBLE_GROUND" + | "INCOMPLETE_LOAD_ORBIT" + | "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM"; + +export type SymmetryFailureClassification = + | { + readonly reason: "INVALID_SYMMETRY"; + readonly errorCode: "NEC_INPUT"; + readonly representationEligibilityFailure: false; + } + | { + readonly reason: + | "INCOMPATIBLE_GROUND" + | "INCOMPLETE_LOAD_ORBIT" + | "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM"; + readonly errorCode: "NEC_GEOMETRY"; + /** Automatic builders still verify that the unchanged full model is valid. */ + readonly representationEligibilityFailure: true; + }; + export interface CompleteGeometryOptions { /** Defaults to `"none"`. A non-none value declares a ground plane at z=0. */ readonly groundConnection?: GroundConnection; + /** Final geometry-generation operation before connection/completion. */ + readonly symmetry?: GeometrySymmetry; +} + +export interface GeometryCompletionResult { + /** Absent for ordinary, non-symmetric geometry completion. */ + readonly symmetry?: SymmetryExpansion; } /** One-based segment position among all segments carrying `tag`. */ @@ -232,7 +323,7 @@ export interface NecModel { readonly state: NecModelState; addWire(wire: WireDefinition): void; - completeGeometry(options?: CompleteGeometryOptions): void; + completeGeometry(options?: CompleteGeometryOptions): GeometryCompletionResult; definePorts(ports: readonly PortDefinition[]): void; addLoad(load: LoadDefinition): void; clearLoads(): void; @@ -287,7 +378,9 @@ export interface NecWorkerModel { readonly state: NecModelState; addWire(wire: WireDefinition): Promise; - completeGeometry(options?: CompleteGeometryOptions): Promise; + completeGeometry( + options?: CompleteGeometryOptions, + ): Promise; definePorts(ports: readonly PortDefinition[]): Promise; addLoad(load: LoadDefinition): Promise; clearLoads(): Promise; diff --git a/packages/necpp-wasm/src/worker-client.ts b/packages/necpp-wasm/src/worker-client.ts index 850bf854..c19a11e0 100644 --- a/packages/necpp-wasm/src/worker-client.ts +++ b/packages/necpp-wasm/src/worker-client.ts @@ -7,6 +7,7 @@ import type { EmbeddedFieldNormalization, FarFieldRequest, FarFieldResult, + GeometryCompletionResult, GroundModel, ImpedanceResult, LoadDefinition, @@ -198,11 +199,19 @@ class WorkerNecModel implements NecWorkerModel { return this.#invokeVoid("addWire", [wire]); } - completeGeometry(options?: CompleteGeometryOptions): Promise { - return this.#invokeVoid( + async completeGeometry( + options?: CompleteGeometryOptions, + ): Promise { + const result = await this.#invoke( "completeGeometry", options === undefined ? [] : [options], ); + if (typeof result !== "object" || result === null) { + throw new NecRuntimeError( + "Worker geometry completion result is not an object", + ); + } + return result as GeometryCompletionResult; } definePorts(ports: readonly PortDefinition[]): Promise { diff --git a/packages/necpp-wasm/src/worker-runtime.ts b/packages/necpp-wasm/src/worker-runtime.ts index 01e80faf..18dbd80d 100644 --- a/packages/necpp-wasm/src/worker-runtime.ts +++ b/packages/necpp-wasm/src/worker-runtime.ts @@ -46,8 +46,9 @@ function invokeModel( model.addWire(args[0] as WireDefinition); return undefined; case "completeGeometry": - model.completeGeometry(args[0] as CompleteGeometryOptions | undefined); - return undefined; + return model.completeGeometry( + args[0] as CompleteGeometryOptions | undefined, + ); case "definePorts": model.definePorts(args[0] as readonly PortDefinition[]); return undefined; diff --git a/packages/necpp-wasm/src/worker.ts b/packages/necpp-wasm/src/worker.ts index a141ec8d..305f60ce 100644 --- a/packages/necpp-wasm/src/worker.ts +++ b/packages/necpp-wasm/src/worker.ts @@ -10,6 +10,7 @@ export { } from "./errors.js"; export { abiVersion, engineVersion, packageVersion } from "./versions.js"; +export { rotationalOrder } from "./symmetry.js"; export type { NecErrorCode, NecErrorOptions } from "./errors.js"; @@ -17,6 +18,7 @@ export { createNecWorkerModel } from "./worker-client.js"; export type { AngleSweep, + CartesianSignsTransform, CartesianPointM, CompleteGeometryOptions, ComplexMatrix, @@ -32,6 +34,8 @@ export type { FarFieldResult, FiniteGround, FreeSpaceGround, + GeometryCompletionResult, + GeometrySymmetry, GroundConnection, GroundModel, ImpedanceLoad, @@ -47,7 +51,17 @@ export type { PortDefinition, PortSolution, PrepareOptions, + ReflectionPlane, + ReflectionSymmetry, + RotationalOrder, + RotationalSymmetry, + RotateZTransform, SegmentSelection, SeriesRlcLoad, + SymmetryCopy, + SymmetryCopyTransform, + SymmetryExpansion, + SymmetryFailureClassification, + SymmetryFailureReason, WireDefinition, } from "./types.js"; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index 9e0419a7..382aaa4c 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -4,7 +4,12 @@ import { createNecModel, engineVersion, packageVersion, + rotationalOrder, runDeck, + type GeometryCompletionResult, + type GeometrySymmetry, + type SymmetryCopy, + type SymmetryFailureClassification, type ComplexMatrix, type FarFieldResult, type PortSolution, @@ -19,7 +24,8 @@ async function validConsumer(): Promise { end: [0, 0, 0.25], radiusM: 0.001, }); - model.completeGeometry(); + const completion: GeometryCompletionResult = model.completeGeometry(); + completion.symmetry?.copies[0]?.transform.kind; model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); model.addLoad({ kind: "impedance", @@ -56,6 +62,78 @@ async function validConsumer(): Promise { void validConsumer; +const validSymmetries: readonly GeometrySymmetry[] = [ + { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 4, + }, + { + kind: "rotational", + axis: "z", + order: rotationalOrder(4), + tagIncrement: 4, + }, +]; + +const validCopies: readonly SymmetryCopy[] = [ + { + index: 0, + tagOffset: 0, + transform: { kind: "cartesian-signs", signs: [1, 1, 1] }, + }, + { + index: 1, + tagOffset: 4, + transform: { kind: "rotate-z", angleDeg: 90 }, + }, +]; + +void validSymmetries; +void validCopies; + +const validFailureClassifications: readonly SymmetryFailureClassification[] = [ + { + reason: "INVALID_SYMMETRY", + errorCode: "NEC_INPUT", + representationEligibilityFailure: false, + }, + { + reason: "INCOMPATIBLE_GROUND", + errorCode: "NEC_GEOMETRY", + representationEligibilityFailure: true, + }, + { + reason: "INCOMPLETE_LOAD_ORBIT", + errorCode: "NEC_GEOMETRY", + representationEligibilityFailure: true, + }, + { + reason: "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM", + errorCode: "NEC_GEOMETRY", + representationEligibilityFailure: true, + }, +]; + +void validFailureClassifications; + +async function validSymmetricCompletionConsumer(): Promise { + const model = await createNecModel(); + model.addWire({ + tag: 1, + segments: 11, + start: [0.25, 0.25, 0.1], + end: [0.25, 0.25, 0.4], + radiusM: 0.001, + }); + const completion = model.completeGeometry({ + symmetry: validSymmetries[0]!, + }); + completion.symmetry?.copies[0]?.tagOffset; +} + +void validSymmetricCompletionConsumer; + async function intentionallyInvalidConsumer(): Promise { const model = await createNecModel(); @@ -70,6 +148,57 @@ async function intentionallyInvalidConsumer(): Promise { // @ts-expect-error the public model has no raw WASM pointer. model.handle; + + const emptyReflection: GeometrySymmetry = { + kind: "reflection", + // @ts-expect-error reflection symmetry requires at least one plane. + planes: [], + tagIncrement: 1, + }; + + const arbitraryAxis: GeometrySymmetry = { + kind: "rotational", + // @ts-expect-error the first contract supports only rotation about global Z. + axis: "x", + order: rotationalOrder(4), + tagIncrement: 1, + }; + + const tooSmallOrder: GeometrySymmetry = { + kind: "rotational", + axis: "z", + // @ts-expect-error raw values, including order 1, must pass rotationalOrder(). + order: 1, + tagIncrement: 1, + }; + + const arbitraryPlane: GeometrySymmetry = { + kind: "reflection", + // @ts-expect-error only the three global coordinate planes are supported. + planes: ["x=y"], + tagIncrement: 1, + }; + + const unknownCopyTransform: SymmetryCopy = { + index: 0, + tagOffset: 0, + // @ts-expect-error copy transform discriminants are closed. + transform: { kind: "translate", offsetM: [1, 0, 0] }, + }; + + // @ts-expect-error invalid descriptors are input errors, not geometry errors. + const invalidFailureClassification: SymmetryFailureClassification = { + reason: "INVALID_SYMMETRY", + errorCode: "NEC_GEOMETRY", + representationEligibilityFailure: false, + }; + + void emptyReflection; + void arbitraryAxis; + void tooSmallOrder; + void arbitraryPlane; + void unknownCopyTransform; + void invalidFailureClassification; } void intentionallyInvalidConsumer; diff --git a/packages/necpp-wasm/test-d/worker-api.test.ts b/packages/necpp-wasm/test-d/worker-api.test.ts index 68c0a7ba..be2a67fa 100644 --- a/packages/necpp-wasm/test-d/worker-api.test.ts +++ b/packages/necpp-wasm/test-d/worker-api.test.ts @@ -4,6 +4,8 @@ import { createNecWorkerModel, engineVersion, packageVersion, + rotationalOrder, + type GeometryCompletionResult, type ComplexMatrix, type FarFieldResult, type NecWorkerProgressEvent, @@ -25,7 +27,8 @@ async function validWorkerConsumer(): Promise { end: [0, 0, 0.25], radiusM: 0.001, }); - await model.completeGeometry(); + const completion: GeometryCompletionResult = await model.completeGeometry(); + completion.symmetry?.sectionCount; await model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); await model.prepare({ frequencyMHz: 300 }); @@ -74,6 +77,15 @@ async function intentionallyInvalidWorkerConsumer(): Promise { // @ts-expect-error progress callbacks are not a createNecModel option mix-in on the model. model.onProgress; + + await model.completeGeometry({ + symmetry: { + kind: "rotational", + axis: "z", + order: rotationalOrder(4), + tagIncrement: 1, + }, + }); } void intentionallyInvalidWorkerConsumer; diff --git a/packages/necpp-wasm/test/array-benchmark.test.mjs b/packages/necpp-wasm/test/array-benchmark.test.mjs index 819cae45..9009adc6 100644 --- a/packages/necpp-wasm/test/array-benchmark.test.mjs +++ b/packages/necpp-wasm/test/array-benchmark.test.mjs @@ -20,6 +20,7 @@ test("array benchmark emits equivalent NEC geometry and excitation cards", () => assert.equal(deck.match(/^EX 0 /gm)?.length, 4); assert.match(deck, /^GE 0$/m); assert.match(deck, /^FR 0 1 0 0 300 0$/m); + assert.match(deck, /^GN 1$/m); assert.match(deck, /^XQ$/m); assert.match(deck, /^EN$/m); }); diff --git a/packages/necpp-wasm/test/facade-mapping.test.mjs b/packages/necpp-wasm/test/facade-mapping.test.mjs index 0bd4ed5f..9a1699f0 100644 --- a/packages/necpp-wasm/test/facade-mapping.test.mjs +++ b/packages/necpp-wasm/test/facade-mapping.test.mjs @@ -90,7 +90,11 @@ function createConfigurableModel(recording) { end: [0, 0, 1], radiusM: 0.001, }); - model.completeGeometry({ groundConnection: "zero-current" }); + const completion = model.completeGeometry({ + groundConnection: "zero-current", + }); + assert.deepEqual(completion, {}); + assert.ok(Object.isFrozen(completion)); model.definePorts([{ tag: 1, segment: 2 }]); return model; } diff --git a/packages/necpp-wasm/test/fixtures/reference-array.mjs b/packages/necpp-wasm/test/fixtures/reference-array.mjs new file mode 100644 index 00000000..6cab1d66 --- /dev/null +++ b/packages/necpp-wasm/test/fixtures/reference-array.mjs @@ -0,0 +1,212 @@ +/** + * Shared executable fixture for symmetry correctness tests and array + * benchmarks. Coordinates follow docs/01_symmetry_support.md section 7. + */ + +const NEC_VACUUM_PERMITTIVITY_F_PER_M = 8.854e-12; +const NEC_VACUUM_PERMEABILITY_H_PER_M = 4 * Math.PI * 1e-7; + +export const NEC_ENGINE_SPEED_OF_LIGHT_M_PER_S = 1 / Math.sqrt( + NEC_VACUUM_PERMITTIVITY_F_PER_M * NEC_VACUUM_PERMEABILITY_H_PER_M, +); + +export const XY_REFLECTION_COPY_SIGNS = Object.freeze([ + Object.freeze([1, 1, 1]), + Object.freeze([1, -1, 1]), + Object.freeze([-1, 1, 1]), + Object.freeze([-1, -1, 1]), +]); + +function requirePositiveInteger(value, name) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer`); + } +} + +function requirePositiveNumber(value, name) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive finite number`); + } +} + +function requireCenter(centerM) { + if ( + !Array.isArray(centerM) + || centerM.length !== 2 + || !centerM.every(Number.isFinite) + ) { + throw new Error("centerM must contain two finite coordinates"); + } +} + +function wireAt(tag, segments, xM, yM, lowerZM, upperZM, radiusM) { + return { + tag, + segments, + start: [xM, yM, lowerZM], + end: [xM, yM, upperZM], + radiusM, + }; +} + +function createXyReflectionFixture({ + side, + segments, + wavelengthM, + lowerZM, + upperZM, + radiusM, +}) { + if (side % 2 !== 0) { + return undefined; + } + + const half = side / 2; + const fundamentalElementCount = half * half; + const fundamentalWires = []; + const fundamentalGridIndices = []; + for (let yIndex = half; yIndex < side; yIndex += 1) { + for (let xIndex = half; xIndex < side; xIndex += 1) { + const fundamentalElementIndex = fundamentalWires.length; + const xQuarterWavelengths = 2 * xIndex - (side - 1); + const yQuarterWavelengths = 2 * yIndex - (side - 1); + fundamentalGridIndices.push({ + fundamentalElementIndex, + xIndex, + yIndex, + xQuarterWavelengths, + yQuarterWavelengths, + }); + fundamentalWires.push(wireAt( + fundamentalElementIndex + 1, + segments, + xQuarterWavelengths * wavelengthM / 4, + yQuarterWavelengths * wavelengthM / 4, + lowerZM, + upperZM, + radiusM, + )); + } + } + + const scatterCallerToGenerated = new Array(side * side); + const gatherGeneratedToCaller = new Array(side * side); + const generatedTagsByCaller = new Array(side * side); + const mappingsByCaller = new Array(side * side); + const copies = XY_REFLECTION_COPY_SIGNS.map((signs, copyIndex) => ({ + index: copyIndex, + tagOffset: copyIndex * fundamentalElementCount, + transform: { kind: "cartesian-signs", signs }, + })); + + for (const copy of copies) { + const [signX, signY] = copy.transform.signs; + for (const fundamental of fundamentalGridIndices) { + const xNumerator = signX * fundamental.xQuarterWavelengths; + const yNumerator = signY * fundamental.yQuarterWavelengths; + const xIndex = (xNumerator + side - 1) / 2; + const yIndex = (yNumerator + side - 1) / 2; + const callerElementIndex = yIndex * side + xIndex; + const generatedIndex = copy.index * fundamentalElementCount + + fundamental.fundamentalElementIndex; + const generatedTag = fundamental.fundamentalElementIndex + 1 + + copy.tagOffset; + scatterCallerToGenerated[callerElementIndex] = generatedIndex; + gatherGeneratedToCaller[generatedIndex] = callerElementIndex; + generatedTagsByCaller[callerElementIndex] = generatedTag; + mappingsByCaller[callerElementIndex] = { + callerElementIndex, + fundamentalElementIndex: fundamental.fundamentalElementIndex, + copyIndex: copy.index, + generatedTag, + generatedPortIndices: [generatedIndex], + }; + } + } + + return { + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: fundamentalElementCount, + }, + sectionCount: copies.length, + fundamentalWires, + fundamentalPorts: fundamentalWires.map(({ tag }) => ({ + tag, + segment: (segments + 1) / 2, + })), + copies, + scatterCallerToGenerated, + gatherGeneratedToCaller, + generatedTagsByCaller, + mappingsByCaller, + }; +} + +export function createReferenceArrayFixture({ + side = 4, + segments = 11, + frequencyMHz = 300, + centerM = [0, 0], +} = {}) { + requirePositiveInteger(side, "side"); + requirePositiveInteger(segments, "segments"); + if (segments % 2 === 0) { + throw new Error("segments must be odd so the feed segment is centered"); + } + requirePositiveNumber(frequencyMHz, "frequencyMHz"); + requireCenter(centerM); + + const wavelengthM = NEC_ENGINE_SPEED_OF_LIGHT_M_PER_S + / (frequencyMHz * 1e6); + const spacingM = wavelengthM / 2; + const lowerZM = wavelengthM / 12; + const upperZM = 5 * wavelengthM / 12; + const radiusM = wavelengthM / 1000; + const feedSegment = (segments + 1) / 2; + const wires = []; + for (let yIndex = 0; yIndex < side; yIndex += 1) { + for (let xIndex = 0; xIndex < side; xIndex += 1) { + const tag = yIndex * side + xIndex + 1; + const xM = centerM[0] + (xIndex - (side - 1) / 2) * spacingM; + const yM = centerM[1] + (yIndex - (side - 1) / 2) * spacingM; + wires.push(wireAt( + tag, + segments, + xM, + yM, + lowerZM, + upperZM, + radiusM, + )); + } + } + + return { + speedOfLightMPerS: NEC_ENGINE_SPEED_OF_LIGHT_M_PER_S, + frequencyMHz, + wavelengthM, + side, + centerM: [...centerM], + segments, + feedSegment, + spacingM, + lowerZM, + upperZM, + radiusM, + wires, + ports: wires.map(({ tag }) => ({ tag, segment: feedSegment })), + groundConnection: "none", + ground: { kind: "perfect" }, + equations: side * side * segments, + reflection: createXyReflectionFixture({ + side, + segments, + wavelengthM, + lowerZM, + upperZM, + radiusM, + }), + }; +} diff --git a/packages/necpp-wasm/test/symmetry-contract.test.mjs b/packages/necpp-wasm/test/symmetry-contract.test.mjs new file mode 100644 index 00000000..dce4366e --- /dev/null +++ b/packages/necpp-wasm/test/symmetry-contract.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + NecInputError, + rotationalOrder, +} from "../.test-build/src/index.js"; +import { + createReferenceArrayFixture, + NEC_ENGINE_SPEED_OF_LIGHT_M_PER_S, +} from "./fixtures/reference-array.mjs"; + +const golden = JSON.parse(readFileSync( + new URL("../../../tests/data/symmetry_reference_array_4x4.json", import.meta.url), + "utf8", +)); + +test("rotationalOrder validates the signed 32-bit native contract", () => { + assert.equal(rotationalOrder(2), 2); + assert.equal(rotationalOrder(2_147_483_647), 2_147_483_647); + for (const invalid of [1, 0, -2, 2.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws( + () => rotationalOrder(invalid), + (error) => error instanceof NecInputError + && error.code === "NEC_INPUT" + && error.details?.symmetryFailure === "INVALID_SYMMETRY", + ); + } +}); + +test("the shared 4x4 reference fixture matches the language-neutral golden data", () => { + const fixture = createReferenceArrayFixture(); + const reflection = fixture.reflection; + assert.ok(reflection); + assert.equal(fixture.speedOfLightMPerS, NEC_ENGINE_SPEED_OF_LIGHT_M_PER_S); + assert.equal(fixture.speedOfLightMPerS, golden.speedOfLightMPerS); + assert.equal(fixture.frequencyMHz, golden.frequencyMHz); + assert.equal(fixture.side, golden.side); + assert.equal(fixture.segments, golden.segments); + assert.equal(fixture.feedSegment, golden.feedSegment); + assert.equal(fixture.groundConnection, "none"); + assert.deepEqual(fixture.ground, { kind: "perfect" }); + + const expectedCallerCoordinates = golden.callerCoordinateQuarterWavelengths + .map(([x, y]) => [x * fixture.wavelengthM / 4, y * fixture.wavelengthM / 4]); + assert.deepEqual( + fixture.wires.map((wire) => [wire.start[0], wire.start[1]]), + expectedCallerCoordinates, + ); + assert.deepEqual( + fixture.wires.map(({ tag }) => tag), + Array.from({ length: 16 }, (_, index) => index + 1), + ); + assert.ok(fixture.wires.every((wire) => + wire.start[2] === golden.wireZTwelfths[0] * fixture.wavelengthM / 12 + && wire.end[2] === golden.wireZTwelfths[1] * fixture.wavelengthM / 12 + && wire.radiusM === fixture.wavelengthM / golden.radiusWavelengthDenominator)); + assert.equal(fixture.lowerZM, fixture.wavelengthM / 12); + assert.equal(fixture.upperZM, 5 * fixture.wavelengthM / 12); + + const expectedFundamentalCoordinates = golden + .fundamentalCoordinateQuarterWavelengths + .map(([x, y]) => [x * fixture.wavelengthM / 4, y * fixture.wavelengthM / 4]); + assert.deepEqual( + reflection.fundamentalWires.map((wire) => [wire.start[0], wire.start[1]]), + expectedFundamentalCoordinates, + ); + assert.deepEqual( + reflection.copies.map((copy) => copy.transform.signs), + golden.copySigns, + ); + assert.deepEqual( + reflection.copies.map((copy) => copy.tagOffset), + golden.copyTagOffsets, + ); + assert.deepEqual( + reflection.scatterCallerToGenerated, + golden.scatterCallerToGenerated, + ); + assert.deepEqual( + reflection.gatherGeneratedToCaller, + golden.gatherGeneratedToCaller, + ); + assert.deepEqual( + reflection.generatedTagsByCaller, + golden.generatedTagsByCaller, + ); + + for (const mapping of reflection.mappingsByCaller) { + const fundamental = reflection.fundamentalWires[ + mapping.fundamentalElementIndex + ]; + const copy = reflection.copies[mapping.copyIndex]; + const caller = fixture.wires[mapping.callerElementIndex]; + const [signX, signY] = copy.transform.signs; + assert.deepEqual( + [signX * fundamental.start[0], signY * fundamental.start[1]], + [caller.start[0], caller.start[1]], + ); + assert.equal( + reflection.gatherGeneratedToCaller[ + reflection.scatterCallerToGenerated[mapping.callerElementIndex] + ], + mapping.callerElementIndex, + ); + } +}); + +test("odd-sided reference arrays deliberately expose no reflection fixture", () => { + assert.equal(createReferenceArrayFixture({ side: 3 }).reflection, undefined); +}); diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs index 96836fb3..0b7530a4 100644 --- a/packages/necpp-wasm/test/worker-client.test.mjs +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -30,6 +30,7 @@ function createFakeModel(overrides = {}) { }, completeGeometry() { state = "geometry-complete"; + return {}; }, definePorts(nextPorts) { ports.splice(0, ports.length, ...snapshotPorts(nextPorts)); @@ -213,13 +214,15 @@ test("the worker runtime preserves state across serialized requests", async () = }, deps); assert.equal(fake.state, "geometry-building"); - await handleWorkerRequest(session, { + const completed = await handleWorkerRequest(session, { id: 3, kind: "invoke", method: "completeGeometry", args: [], }, deps); assert.equal(fake.state, "geometry-complete"); + assert.equal(completed.response.kind, "ok"); + assert.deepEqual(completed.response.result, {}); assert.ok(progress.includes("create:start")); assert.ok(progress.includes("addWire:complete")); }); diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index a31d3e90..309f7a9e 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -43,6 +43,49 @@ enum class nec_ground_connection { zero_current = 2, }; +/*! Symmetry generator selected while completing stateful geometry. + * + * The numeric values are part of the planned additive C/WASM boundary. Keep + * them stable when the stateful implementation is added in WP-S1/WP-S2. + */ +enum class nec_geometry_symmetry_kind { + none = 0, + reflection = 1, + rotational = 2, +}; + +/*! Coordinate-plane bits used by NEC's geometry reflection generator. */ +enum nec_reflection_plane_mask : uint32_t { + nec_reflection_plane_x = 1u, + nec_reflection_plane_y = 2u, + nec_reflection_plane_z = 4u, +}; + +/*! Immutable input contract for one final geometry symmetry operation. + * + * Reflection uses reflection_plane_mask and requires rotational_order == 1. + * Rotation uses rotational_order >= 2 about global Z and requires a zero + * reflection_plane_mask. tag_increment is a positive offset per copy block. + */ +struct nec_geometry_symmetry { + nec_geometry_symmetry_kind kind = nec_geometry_symmetry_kind::none; + uint32_t reflection_plane_mask = 0u; + int rotational_order = 1; + int tag_increment = 0; +}; + +/*! Geometry-completion metadata; copy transforms derive from the descriptor. + * + * A non-symmetric completion reports one section. Segment counts use 64-bit + * storage so metadata does not narrow the stateful geometry's native counts. + */ +struct nec_geometry_completion_result { + nec_geometry_symmetry symmetry; + int section_count = 1; + int64_t fundamental_segment_count = 0; + int64_t full_segment_count = 0; +}; + struct nec_port_definition { int tag = 0; int segment = 0; diff --git a/src/nec_stateful_model_tb.cpp b/src/nec_stateful_model_tb.cpp index ecb3ed88..6956061d 100644 --- a/src/nec_stateful_model_tb.cpp +++ b/src/nec_stateful_model_tb.cpp @@ -59,6 +59,31 @@ class scoped_cout_sink { } // namespace +TEST_CASE("WP-S0 native symmetry descriptor has stable defaults and masks", + "[symmetry][wp_s0]") +{ + const nec_geometry_symmetry symmetry; + REQUIRE(symmetry.kind == nec_geometry_symmetry_kind::none); + REQUIRE(symmetry.reflection_plane_mask == 0u); + REQUIRE(symmetry.rotational_order == 1); + REQUIRE(symmetry.tag_increment == 0); + + REQUIRE(static_cast(nec_geometry_symmetry_kind::none) == 0); + REQUIRE(static_cast(nec_geometry_symmetry_kind::reflection) == 1); + REQUIRE(static_cast(nec_geometry_symmetry_kind::rotational) == 2); + REQUIRE(nec_reflection_plane_x == 1u); + REQUIRE(nec_reflection_plane_y == 2u); + REQUIRE(nec_reflection_plane_z == 4u); + REQUIRE((nec_reflection_plane_x | nec_reflection_plane_y + | nec_reflection_plane_z) == 7u); + + const nec_geometry_completion_result completion; + REQUIRE(completion.section_count == 1); + REQUIRE(completion.fundamental_segment_count == 0); + REQUIRE(completion.full_segment_count == 0); + REQUIRE(completion.symmetry.kind == nec_geometry_symmetry_kind::none); +} + TEST_CASE("WP1 stateful model constructs and solves without a deck", "[wasm_api][wp1][stateful]") { diff --git a/tests/data/symmetry_reference_array_4x4.json b/tests/data/symmetry_reference_array_4x4.json new file mode 100644 index 00000000..c2ec55d4 --- /dev/null +++ b/tests/data/symmetry_reference_array_4x4.json @@ -0,0 +1,29 @@ +{ + "formatVersion": 1, + "speedOfLightMPerS": 299795637.69321626, + "frequencyMHz": 300, + "side": 4, + "segments": 11, + "feedSegment": 6, + "callerCoordinateQuarterWavelengths": [ + [-3, -3], [-1, -3], [1, -3], [3, -3], + [-3, -1], [-1, -1], [1, -1], [3, -1], + [-3, 1], [-1, 1], [1, 1], [3, 1], + [-3, 3], [-1, 3], [1, 3], [3, 3] + ], + "fundamentalCoordinateQuarterWavelengths": [ + [1, 1], [3, 1], [1, 3], [3, 3] + ], + "wireZTwelfths": [1, 5], + "radiusWavelengthDenominator": 1000, + "copySigns": [ + [1, 1, 1], + [1, -1, 1], + [-1, 1, 1], + [-1, -1, 1] + ], + "copyTagOffsets": [0, 4, 8, 12], + "scatterCallerToGenerated": [15, 14, 6, 7, 13, 12, 4, 5, 9, 8, 0, 1, 11, 10, 2, 3], + "gatherGeneratedToCaller": [10, 11, 14, 15, 6, 7, 2, 3, 9, 8, 13, 12, 5, 4, 1, 0], + "generatedTagsByCaller": [16, 15, 7, 8, 14, 13, 5, 6, 10, 9, 1, 2, 12, 11, 3, 4] +} From 4de39c08735cb297d6e023b6e498d300adf7f828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 12:39:47 +0200 Subject: [PATCH 37/46] feat: harden native symmetry generation --- docs/01_symmetry_support.md | 47 +++- src/CMakeLists.txt | 1 + src/c_geometry.cpp | 469 +++++++++++++++++++++++++++++------- src/c_geometry.h | 19 +- src/c_geometry_tb.cpp | 244 +++++++++++++++++++ src/nec_geometry_symmetry.h | 54 +++++ src/nec_stateful_model.h | 44 +--- src/safe_array.h | 21 +- 8 files changed, 764 insertions(+), 135 deletions(-) create mode 100644 src/nec_geometry_symmetry.h diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 607a78ab..0f4444c8 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1231,7 +1231,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP | State | Owner/agent | Evidence/commit | Notes for next agent | |---|---|---|---|---| | WP-S0 Contract and shared fixtures | complete | Codex | Native `[wp_s0]`: 15 assertions; `necpp_unit`: 1/1; npm: 38/38 + typecheck | Preserve the finalized descriptor values, branded rotational order, Z/Y/X copy order, and golden scatter/gather maps. | -| WP-S1 Native geometry safety and metadata | not started | — | — | — | +| WP-S1 Native geometry safety and metadata | complete | Codex | Native `[wp_s1]`: 291 assertions; aggregate: 1,044; 52/52 legacy decks matched; npm: 38/38 | WP-S2 must call `c_geometry::generate_symmetry()` and consume its immutable result instead of reading geometry arrays. | | WP-S2 Stateful symmetry and validation | not started | — | — | — | | WP-S3 Additive C/WASM ABI | not started | — | — | — | | WP-S4 Direct and worker TypeScript API | not started | — | — | — | @@ -1352,6 +1352,51 @@ DoD: Handoff focus: WP-S2 consumes native descriptors and must not call private geometry arrays directly. +Completion evidence (2026-08-30, Windows/MSVC): + +- Baseline before edits: + `build-wp0\tests\Release\nec2++_tests.exe "[symmetry]" --reporter compact` + passed 47 assertions in four cases. +- A fresh bounds-checked test build was configured with the available CMake + 3.31.6 executable and the already-fetched Catch2 source, then + `cmake --build build-wps1 --config Release --target nec2++_tests` passed. +- `build-wps1\tests\Release\nec2++_tests.exe "[wp_s1]" --reporter compact` + passed 291 assertions in four cases. Coverage includes exact one-, two-, and + three-plane order and metadata; rotational orders 2, 4, and 6; expected + preflight failures; malformed descriptors; tag collision/overflow; count + overflow; and allocation-size rejection. +- The bounds-checked aggregate passed 1,044 assertions in 82 cases. Direct + WP1/WP2/WP3/WP4 runs passed 54/66/206/59 assertions respectively, and the + executable smoke script found `TOTAL RUN TIME` in a real dipole report. +- The temporary CTest launcher hung on WP1 despite the same Catch binary + passing all seven WP1 cases in 0.05 seconds when invoked directly. The direct + per-group commands above are the authoritative results; this was a launcher + issue rather than a test failure. +- All 52 `testharness/data/*.nec` decks were run through both the committed + pre-WP-S1 Release binary and the WP-S1 Release binary. Exit statuses matched, + and `nec2diff` reported zero antenna-input, power-budget, and radiation-input + differences for every deck, including all GX/GR cases. +- `cmake --build build-wps1 --config Release` passed. Existing MSVC narrowing + and unknown-GCC-pragma warnings remain; WP-S1 introduced no compiler error. +- `npm --prefix packages/necpp-wasm test` passed 38/38 Node tests and strict + TypeScript typechecking. `git diff --check` passed. +- No legacy GX/GR field semantics or NEC-manual behavior was deliberately + changed. Legacy zero tag increments remain accepted; the strict native + descriptor requires a positive increment and unique generated nonzero tags. + +Contract decisions for WP-S2 and later: + +- The stable descriptor/result types live in the installed + `nec_geometry_symmetry.h`; `nec_stateful_model.h` re-exports them by include. +- `c_geometry::generate_symmetry()` is the strict native handoff. It validates + descriptors, sizes, tags, coordinate-plane conflicts, and rotational + duplicate elements before changing geometry counts or generated arrays. +- Legacy `reflect()`/GX/GR uses the same geometry and size preflight while + retaining legacy tag-group semantics and exact numerical output. +- Reflection copies remain ordered by Z, then Y, then X generation passes. + Rotation copies remain increasing `2*pi/order` about global Z; `np`, `mp`, + and `m_ipsym` continue to be the solver's Fourier metadata. + ### WP-S2 — Stateful symmetry and structural validation Dependencies: WP-S1. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 485c3f90..28591a40 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,7 @@ set(NECPP_PUBLIC_HEADERS nec_context.h c_geometry.h nec_ground.h + nec_geometry_symmetry.h nec_radiation_pattern.h nec_results.h nec_stateful_model.h diff --git a/src/c_geometry.cpp b/src/c_geometry.cpp index 2a8de153..46cb86e4 100644 --- a/src/c_geometry.cpp +++ b/src/c_geometry.cpp @@ -24,6 +24,8 @@ #include #include +#include +#include #include /** @@ -988,15 +990,299 @@ void c_geometry::move( nec_float rox, nec_float roy, nec_float roz, nec_float xs /*-----------------------------------------------------------------------*/ -/* reflect geometry across one symmetry plane (planar) or rotate */ -/* to complete a cylindrical structure. */ -/* sym_plane > 0: bitmask of axes to negate (1=X, 2=Y, 4=Z) */ +namespace { + +constexpr nec_float symmetry_segment_plane_sum_tolerance = 1.0e-5; +constexpr nec_float symmetry_segment_crossing_tolerance = 1.0e-6; +constexpr nec_float symmetry_patch_plane_tolerance = 1.0e-10; +constexpr nec_float symmetry_collision_tolerance = 1.0e-10; + +int reflection_section_count(uint32_t plane_mask) +{ + int plane_count = 0; + for (uint32_t bit = nec_reflection_plane_x; + bit <= nec_reflection_plane_z; bit <<= 1u) + if ((plane_mask & bit) != 0u) + ++plane_count; + return 1 << plane_count; +} + +int64_t checked_symmetry_product( + int64_t fundamental_count, int section_count, const char* element_name) +{ + if (fundamental_count < 0 || section_count < 1 || + fundamental_count > + (std::numeric_limits::max)() / section_count) { + nec_exception error("GEOMETRY SYMMETRY SIZE ERROR--"); + error.append(element_name); + error.append(" COUNT OVERFLOW"); + throw error; + } + + const int64_t full_count = fundamental_count * section_count; + if (full_count > real_array::maximum_size()) { + nec_exception error("GEOMETRY SYMMETRY SIZE ERROR--"); + error.append(element_name); + error.append(" ALLOCATION IS TOO LARGE"); + throw error; + } + return full_count; +} + +int64_t checked_symmetry_sum(int64_t first, int64_t second) +{ + if (first < 0 || second < 0 || + first > (std::numeric_limits::max)() - second) + throw nec_exception("GEOMETRY SYMMETRY SIZE ERROR--TAG ARRAY COUNT OVERFLOW"); + const int64_t result = first + second; + if (result > int_array::maximum_size()) + throw nec_exception("GEOMETRY SYMMETRY SIZE ERROR--TAG ARRAY ALLOCATION IS TOO LARGE"); + return result; +} + +void validate_generated_tags( + const c_geometry& geometry, int section_count, int tag_increment, + bool require_unique_tags) +{ + std::set fundamental_tags; + for (int64_t index = 0; index < geometry.n_segments; ++index) { + const int tag = geometry.segment_tags[index]; + if (require_unique_tags && tag < 0) + throw nec_exception("GEOMETRY SYMMETRY TAG ERROR--TAGS MUST NOT BE NEGATIVE"); + if (tag != 0) + fundamental_tags.insert(tag); + } + + std::vector tags(fundamental_tags.begin(), fundamental_tags.end()); + for (const int tag : fundamental_tags) { + const int64_t first = tag; + const int64_t last = first + + static_cast(section_count - 1) * tag_increment; + const int64_t minimum = std::min(first, last); + const int64_t maximum = std::max(first, last); + if (minimum < (std::numeric_limits::min)() || + maximum > (std::numeric_limits::max)()) + throw nec_exception("GEOMETRY SYMMETRY TAG ERROR--GENERATED TAG OVERFLOW"); + if (require_unique_tags && minimum <= 0) + throw nec_exception("GEOMETRY SYMMETRY TAG ERROR--GENERATED TAGS MUST BE POSITIVE"); + } + + if (!require_unique_tags) + return; + + const int64_t increment = tag_increment; + for (size_t first_index = 0; first_index < tags.size(); ++first_index) { + for (size_t second_index = first_index + 1; + second_index < tags.size(); ++second_index) { + const int64_t difference = static_cast(tags[second_index]) - + static_cast(tags[first_index]); + if (difference % increment == 0 && + difference / increment < section_count) + throw nec_exception( + "GEOMETRY SYMMETRY TAG ERROR--GENERATED TAGS ARE NOT UNIQUE"); + } + } +} + +bool approximately_equal(nec_float lhs, nec_float rhs) +{ + const nec_float scale = 1.0 + std::max(fabs(lhs), fabs(rhs)); + return fabs(lhs - rhs) <= symmetry_collision_tolerance * scale; +} + +bool vector_rotation_angle( + nec_float source_x, nec_float source_y, + nec_float target_x, nec_float target_y, + nec_float& angle, bool& has_angle) +{ + const nec_float source_radius = hypot(source_x, source_y); + const nec_float target_radius = hypot(target_x, target_y); + if (!approximately_equal(source_radius, target_radius)) + return false; + if (source_radius <= symmetry_collision_tolerance) { + if (target_radius > symmetry_collision_tolerance) + return false; + return true; + } + + const nec_float candidate = atan2( + source_x * target_y - source_y * target_x, + source_x * target_x + source_y * target_y); + if (!has_angle) { + angle = candidate; + has_angle = true; + } else if (!approximately_equal(sin(angle), sin(candidate)) || + !approximately_equal(cos(angle), cos(candidate))) { + return false; + } + return true; +} + +bool angle_is_nonidentity_copy(nec_float angle, int order) +{ + nec_float normalized = fmod(angle, two_pi()); + if (normalized < 0.0) + normalized += two_pi(); + const nec_float copy_value = normalized * order / two_pi(); + const int64_t copy = static_cast(llround(copy_value)); + if (fabs(copy_value - static_cast(copy)) > 1.0e-8) + return false; + return copy % order != 0; +} + +bool segment_matches_rotation( + const c_geometry& geometry, int64_t source, int64_t target, bool reverse, + int order) +{ + nec_float target_x1 = reverse ? geometry.x2[target] : geometry.x[target]; + nec_float target_y1 = reverse ? geometry.y2[target] : geometry.y[target]; + nec_float target_z1 = reverse ? geometry.z2[target] : geometry.z[target]; + nec_float target_x2 = reverse ? geometry.x[target] : geometry.x2[target]; + nec_float target_y2 = reverse ? geometry.y[target] : geometry.y2[target]; + nec_float target_z2 = reverse ? geometry.z[target] : geometry.z2[target]; + + if (!approximately_equal(geometry.z[source], target_z1) || + !approximately_equal(geometry.z2[source], target_z2)) + return false; + + nec_float angle = 0.0; + bool has_angle = false; + if (!vector_rotation_angle( + geometry.x[source], geometry.y[source], target_x1, target_y1, + angle, has_angle) || + !vector_rotation_angle( + geometry.x2[source], geometry.y2[source], target_x2, target_y2, + angle, has_angle)) + return false; + return !has_angle || angle_is_nonidentity_copy(angle, order); +} + +bool patch_matches_rotation( + const c_geometry& geometry, int64_t source, int64_t target, int order) +{ + if (!approximately_equal(geometry.pz[source], geometry.pz[target]) || + !approximately_equal(geometry.t1z[source], geometry.t1z[target]) || + !approximately_equal(geometry.t2z[source], geometry.t2z[target]) || + !approximately_equal(geometry.pbi[source], geometry.pbi[target]) || + !approximately_equal(geometry.psalp[source], geometry.psalp[target])) + return false; + + nec_float angle = 0.0; + bool has_angle = false; + if (!vector_rotation_angle( + geometry.px[source], geometry.py[source], + geometry.px[target], geometry.py[target], angle, has_angle) || + !vector_rotation_angle( + geometry.t1x[source], geometry.t1y[source], + geometry.t1x[target], geometry.t1y[target], angle, has_angle) || + !vector_rotation_angle( + geometry.t2x[source], geometry.t2y[source], + geometry.t2x[target], geometry.t2y[target], angle, has_angle)) + return false; + return !has_angle || angle_is_nonidentity_copy(angle, order); +} + +void preflight_reflection( + const c_geometry& geometry, uint32_t plane_mask, int tag_increment, + bool require_unique_tags) +{ + const int section_count = reflection_section_count(plane_mask); + const int64_t full_segments = checked_symmetry_product( + geometry.n_segments, section_count, "SEGMENT"); + checked_symmetry_product(geometry.m, section_count, "PATCH"); + if (geometry.n_segments > 0) { + const int64_t patch_tag_slots = checked_symmetry_product( + geometry.m, section_count / 2, "PATCH TAG SLOT"); + checked_symmetry_sum(full_segments, patch_tag_slots); + } + validate_generated_tags( + geometry, section_count, tag_increment, require_unique_tags); + + for (uint32_t plane = nec_reflection_plane_x; + plane <= nec_reflection_plane_z; plane <<= 1u) { + if ((plane_mask & plane) == 0u) + continue; + for (int64_t index = 0; index < geometry.n_segments; ++index) { + nec_float first = 0.0; + nec_float second = 0.0; + if (plane == nec_reflection_plane_x) { + first = geometry.x[index]; second = geometry.x2[index]; + } else if (plane == nec_reflection_plane_y) { + first = geometry.y[index]; second = geometry.y2[index]; + } else { + first = geometry.z[index]; second = geometry.z2[index]; + } + if ((fabs(first) + fabs(second) <= + symmetry_segment_plane_sum_tolerance) || + (first * second < -symmetry_segment_crossing_tolerance)) { + nec_exception error("GEOMETRY DATA ERROR--SEGMENT "); + error.append(index + 1); + error.append(" LIES IN OR CROSSES A PLANE OF SYMMETRY"); + throw error; + } + } + + for (int64_t index = 0; index < geometry.m; ++index) { + const nec_float center = plane == nec_reflection_plane_x + ? geometry.px[index] + : (plane == nec_reflection_plane_y + ? geometry.py[index] : geometry.pz[index]); + if (fabs(center) <= symmetry_patch_plane_tolerance) { + nec_exception error("GEOMETRY DATA ERROR--PATCH "); + error.append(index + 1); + error.append(" LIES IN A PLANE OF SYMMETRY"); + throw error; + } + } + } +} + +void preflight_rotation( + const c_geometry& geometry, int order, int tag_increment, + bool require_unique_tags) +{ + const int64_t full_segments = checked_symmetry_product( + geometry.n_segments, order, "SEGMENT"); + checked_symmetry_product(geometry.m, order, "PATCH"); + if (geometry.n_segments > 0) + checked_symmetry_sum(full_segments, geometry.m); + validate_generated_tags(geometry, order, tag_increment, require_unique_tags); + + for (int64_t source = 0; source < geometry.n_segments; ++source) { + for (int64_t target = 0; target < geometry.n_segments; ++target) { + if (segment_matches_rotation( + geometry, source, target, false, order) || + segment_matches_rotation( + geometry, source, target, true, order)) + throw nec_exception( + "GEOMETRY DATA ERROR--ROTATIONAL SYMMETRY DUPLICATES A SEGMENT"); + } + } + + for (int64_t source = 0; source < geometry.m; ++source) { + if (hypot(geometry.px[source], geometry.py[source]) <= + symmetry_collision_tolerance) + throw nec_exception( + "GEOMETRY DATA ERROR--ROTATIONAL SYMMETRY DUPLICATES AN AXIS PATCH"); + for (int64_t target = 0; target < geometry.m; ++target) { + if (patch_matches_rotation( + geometry, source, target, order)) + throw nec_exception( + "GEOMETRY DATA ERROR--ROTATIONAL SYMMETRY DUPLICATES A PATCH"); + } + } +} + +} // namespace + +/* Reflect geometry across coordinate planes or rotate it about the Z axis. */ +/* sym_plane > 0: bitmask of planes (1=X, 2=Y, 4=Z) */ /* sym_plane < 0: rotational symmetry with nop = -sym_plane */ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) { int itagi; int64_t k; - nec_float e1, e2, xk, yk; + nec_float xk, yk; if ( sym_plane < 0 ) { @@ -1107,19 +1393,17 @@ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) int copy_mask[8] = { 0 }; int num_copies = 1; - for ( int axis_mask = 4; axis_mask > 0; axis_mask >>= 1 ) + for ( int plane_bit = 4; plane_bit > 0; plane_bit >>= 1 ) { - if ( 0 == (sym_plane & axis_mask) ) + if ( 0 == (sym_plane & plane_bit) ) continue; for ( int copy = 0; copy < num_copies; copy++ ) - copy_mask[num_copies + copy] = copy_mask[copy] | axis_mask; + copy_mask[num_copies + copy] = copy_mask[copy] | plane_bit; num_copies *= 2; } - tag_increment = itx * num_copies; - /* --- SEGMENTS --- */ if ( orig_n > 0 ) { @@ -1150,44 +1434,6 @@ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) { int64_t nx = base + i; - /* Validate: segment must not lie in the symmetry plane */ - if ( flip_z ) - { - e1 = z[i]; - e2 = z2[i]; - if ( (fabs(e1)+fabs(e2) <= 1.0e-5) || (e1*e2 < -1.0e-6) ) - { - nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - } - if ( flip_y ) - { - e1 = y[i]; - e2 = y2[i]; - if ( (fabs(e1)+fabs(e2) <= 1.0e-5) || (e1*e2 < -1.0e-6) ) - { - nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - } - if ( flip_x ) - { - e1 = x[i]; - e2 = x2[i]; - if ( (fabs(e1)+fabs(e2) <= 1.0e-5) || (e1*e2 < -1.0e-6) ) - { - nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - } - x[nx] = flip_x ? -x[i] : x[i]; y[nx] = flip_y ? -y[i] : y[i]; z[nx] = flip_z ? -z[i] : z[i]; @@ -1199,7 +1445,8 @@ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) if ( itagi == 0 ) segment_tags[nx] = 0; if ( itagi != 0 ) - segment_tags[nx] = itagi + copy * itx; + segment_tags[nx] = static_cast( + static_cast(itagi) + static_cast(copy) * itx); segment_radius[nx] = segment_radius[i]; } @@ -1241,28 +1488,6 @@ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) { int64_t nx = base + i; - if ( flip_z && fabs(pz[i]) <= 1.0e-10 ) - { - nec_exception nex("GEOMETRY DATA ERROR--PATCH "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - if ( flip_y && fabs(py[i]) <= 1.0e-10 ) - { - nec_exception nex("GEOMETRY DATA ERROR--PATCH "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - if ( flip_x && fabs(px[i]) <= 1.0e-10 ) - { - nec_exception nex("GEOMETRY DATA ERROR--PATCH "); - nex.append(i+1); - nex.append("LIES IN PLANE OF SYMMETRY"); - throw nex; - } - px[nx] = flip_x ? -px[i] : px[i]; py[nx] = flip_y ? -py[i] : py[i]; pz[nx] = flip_z ? -pz[i] : pz[i]; @@ -1283,38 +1508,112 @@ void c_geometry::reflect_plane( int sym_plane, int& tag_increment ) /*-----------------------------------------------------------------------*/ -/* reflects partial structure along x,y, or z axes or rotates */ -/* structure to complete a symmetric structure. */ +/* Reflect a fundamental structure across coordinate planes or rotate it. */ +/* This legacy entry point preserves GX/GR tag semantics. */ void c_geometry::reflect( int ix, int iy, int iz, int itx, int nop ) { int iti; - - np= n_segments; - mp= m; - m_ipsym=0; iti= itx; if ( ix >= 0) { - if ( nop == 0) - return; - - m_ipsym=1; + if ( nop == 0) { + np= n_segments; + mp= m; + m_ipsym=0; + return; + } - /* Build axis mask: bit 0=X, bit 1=Y, bit 2=Z */ - int sym_plane = (iz ? 4 : 0) | (iy ? 2 : 0) | (ix ? 1 : 0); + /* Build plane mask: bit 0=X, bit 1=Y, bit 2=Z. */ + const uint32_t plane_mask = + (iz ? nec_reflection_plane_z : 0u) | + (iy ? nec_reflection_plane_y : 0u) | + (ix ? nec_reflection_plane_x : 0u); + if (plane_mask != 0u) + preflight_reflection(*this, plane_mask, itx, false); + np= n_segments; + mp= m; + m_ipsym=1; if ( iz != 0 ) m_ipsym=2; - if ( sym_plane != 0 ) - reflect_plane( sym_plane, iti ); + if ( plane_mask != 0u ) + reflect_plane( static_cast(plane_mask), iti ); return; } /* if ( ix >= 0) */ - /* reproduce structure with rotation to form cylindrical structure */ + if (nop < 2) + throw nec_exception("GEOMETRY SYMMETRY INPUT ERROR--ROTATIONAL ORDER MUST BE AT LEAST TWO"); + preflight_rotation(*this, nop, itx, false); + + np= n_segments; + mp= m; + /* Reproduce the fundamental structure about Z to form a cylinder. */ m_ipsym = -1; reflect_plane( -nop, iti ); } + +nec_geometry_completion_result c_geometry::generate_symmetry( + const nec_geometry_symmetry& symmetry) +{ + nec_geometry_completion_result result; + result.symmetry = symmetry; + result.fundamental_segment_count = n_segments; + + switch (symmetry.kind) { + case nec_geometry_symmetry_kind::none: + if (symmetry.reflection_plane_mask != 0u || + symmetry.rotational_order != 1 || symmetry.tag_increment != 0) + throw nec_exception( + "GEOMETRY SYMMETRY INPUT ERROR--NONE DESCRIPTOR HAS EXTRA FIELDS"); + result.full_segment_count = n_segments; + return result; + + case nec_geometry_symmetry_kind::reflection: { + constexpr uint32_t valid_planes = nec_reflection_plane_x | + nec_reflection_plane_y | nec_reflection_plane_z; + if (symmetry.reflection_plane_mask == 0u || + (symmetry.reflection_plane_mask & ~valid_planes) != 0u || + symmetry.rotational_order != 1 || symmetry.tag_increment <= 0) + throw nec_exception( + "GEOMETRY SYMMETRY INPUT ERROR--INVALID REFLECTION DESCRIPTOR"); + + const int sections = reflection_section_count( + symmetry.reflection_plane_mask); + preflight_reflection( + *this, symmetry.reflection_plane_mask, symmetry.tag_increment, true); + np = n_segments; + mp = m; + m_ipsym = (symmetry.reflection_plane_mask & nec_reflection_plane_z) + ? 2 : 1; + int tag_increment = symmetry.tag_increment; + reflect_plane(static_cast(symmetry.reflection_plane_mask), tag_increment); + result.section_count = sections; + result.full_segment_count = n_segments; + return result; + } + + case nec_geometry_symmetry_kind::rotational: + if (symmetry.reflection_plane_mask != 0u || + symmetry.rotational_order < 2 || symmetry.tag_increment <= 0) + throw nec_exception( + "GEOMETRY SYMMETRY INPUT ERROR--INVALID ROTATIONAL DESCRIPTOR"); + preflight_rotation( + *this, symmetry.rotational_order, symmetry.tag_increment, true); + np = n_segments; + mp = m; + m_ipsym = -1; + { + int tag_increment = symmetry.tag_increment; + reflect_plane(-symmetry.rotational_order, tag_increment); + } + result.section_count = symmetry.rotational_order; + result.full_segment_count = n_segments; + return result; + } + + throw nec_exception("GEOMETRY SYMMETRY INPUT ERROR--UNKNOWN SYMMETRY KIND"); +} /*-----------------------------------------------------------------------*/ diff --git a/src/c_geometry.h b/src/c_geometry.h index 214ae0c1..f9c8534d 100644 --- a/src/c_geometry.h +++ b/src/c_geometry.h @@ -17,6 +17,7 @@ */ #pragma once #include "math_util.h" +#include "nec_geometry_symmetry.h" #include #include #include @@ -92,11 +93,11 @@ class c_geometry void move( nec_float rox, nec_float roy, nec_float roz, nec_float xs, nec_float ys, nec_float zs, int its, int nrpt, int itgi ); - /*! \brief Reflects partial structure along x,y, or z axes. + /*! \brief Reflects a fundamental structure across coordinate planes. - \param ix If ix = 1 then the structure is reflected along X axis. - \param iy If iy = 1 then the structure is reflected along Y axis. - \param iz If iz = 1 then the structure is reflected along Z axis. + \param ix If ix = 1 then reflect across the x=0 plane. + \param iy If iy = 1 then reflect across the y=0 plane. + \param iz If iz = 1 then reflect across the z=0 plane. \param itx The tag number increment. */ void reflect(int ix, int iy, int iz, int itx) { @@ -116,6 +117,16 @@ class c_geometry void reflect( int ix, int iy, int iz, int itx, int nop ); + /*! \brief Validate and generate symmetry for a stateful native caller. + * + * Unlike the legacy GX/GR helpers, this entry point requires the strict + * descriptor contract, unique generated nonzero tags, and collision-free + * fundamental geometry. All expected validation happens before the + * geometry counts or generated arrays are changed. + */ + nec_geometry_completion_result generate_symmetry( + const nec_geometry_symmetry& symmetry); + /*! \brief Scale all dimensions of a structure by a constant.*/ void scale( nec_float xw1); diff --git a/src/c_geometry_tb.cpp b/src/c_geometry_tb.cpp index 9116e9ed..0cb0f612 100644 --- a/src/c_geometry_tb.cpp +++ b/src/c_geometry_tb.cpp @@ -6,6 +6,7 @@ #include "libnecpp.h" #include #include +#include void HANDLE_NEC(long x) { int __tmp = (x); @@ -273,3 +274,246 @@ TEST_CASE( "GX three-plane symmetry produces correct impedance", "[symmetry]") { nec_delete(nec); } + +namespace { + +nec_geometry_symmetry reflection_symmetry(uint32_t planes, int tag_increment = 100) +{ + nec_geometry_symmetry symmetry; + symmetry.kind = nec_geometry_symmetry_kind::reflection; + symmetry.reflection_plane_mask = planes; + symmetry.tag_increment = tag_increment; + return symmetry; +} + +nec_geometry_symmetry rotational_symmetry(int order, int tag_increment = 100) +{ + nec_geometry_symmetry symmetry; + symmetry.kind = nec_geometry_symmetry_kind::rotational; + symmetry.rotational_order = order; + symmetry.tag_increment = tag_increment; + return symmetry; +} + +void add_symmetry_fixture(c_geometry& geometry) +{ + geometry.wire(10, 1, 1.0, 2.0, 3.0, 1.5, 2.5, 3.5, + 0.001, 1.0, 1.0); + geometry.patch(0, 0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.25, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); +} + +} // namespace + +TEST_CASE("WP-S1 coordinate-plane symmetry has exact native order and metadata", + "[symmetry][wp_s1]") +{ + struct reflection_case { + uint32_t planes; + int expected_ipsym; + int expected_masks[8]; + int section_count; + }; + const reflection_case cases[] = { + {nec_reflection_plane_x, 1, {0, 1}, 2}, + {nec_reflection_plane_x | nec_reflection_plane_y, + 1, {0, 2, 1, 3}, 4}, + {nec_reflection_plane_x | nec_reflection_plane_y | + nec_reflection_plane_z, + 2, {0, 4, 2, 6, 1, 5, 3, 7}, 8}, + }; + + for (const reflection_case& test : cases) { + CAPTURE(test.planes); + c_geometry geometry; + add_symmetry_fixture(geometry); + + const nec_geometry_completion_result result = geometry.generate_symmetry( + reflection_symmetry(test.planes)); + + REQUIRE(result.symmetry.kind == nec_geometry_symmetry_kind::reflection); + REQUIRE(result.section_count == test.section_count); + REQUIRE(result.fundamental_segment_count == 1); + REQUIRE(result.full_segment_count == test.section_count); + REQUIRE(geometry.n_segments == test.section_count); + REQUIRE(geometry.m == test.section_count); + REQUIRE(geometry.np == 1); + REQUIRE(geometry.mp == 1); + REQUIRE(geometry.m_ipsym == test.expected_ipsym); + + for (int copy = 0; copy < test.section_count; ++copy) { + const int mask = test.expected_masks[copy]; + CAPTURE(copy, mask); + REQUIRE(geometry.x[copy] == ((mask & 1) ? -1.0 : 1.0)); + REQUIRE(geometry.y[copy] == ((mask & 2) ? -2.0 : 2.0)); + REQUIRE(geometry.z[copy] == ((mask & 4) ? -3.0 : 3.0)); + REQUIRE(geometry.x2[copy] == ((mask & 1) ? -1.5 : 1.5)); + REQUIRE(geometry.y2[copy] == ((mask & 2) ? -2.5 : 2.5)); + REQUIRE(geometry.z2[copy] == ((mask & 4) ? -3.5 : 3.5)); + REQUIRE(geometry.segment_tags[copy] == 10 + copy * 100); + REQUIRE(geometry.px[copy] == ((mask & 1) ? -4.0 : 4.0)); + REQUIRE(geometry.py[copy] == ((mask & 2) ? -5.0 : 5.0)); + REQUIRE(geometry.pz[copy] == ((mask & 4) ? -6.0 : 6.0)); + } + } +} + +TEST_CASE("WP-S1 rotational symmetry has exact order, tags, and Fourier count", + "[symmetry][wp_s1]") +{ + for (const int order : {2, 4, 6}) { + CAPTURE(order); + c_geometry geometry; + geometry.wire(10, 1, 1.0, 0.0, 0.1, 1.0, 0.0, 0.2, + 0.001, 1.0, 1.0); + + const nec_geometry_completion_result result = geometry.generate_symmetry( + rotational_symmetry(order)); + + REQUIRE(result.section_count == order); + REQUIRE(result.fundamental_segment_count == 1); + REQUIRE(result.full_segment_count == order); + REQUIRE(geometry.n_segments == order); + REQUIRE(geometry.np == 1); + REQUIRE(geometry.mp == 0); + REQUIRE(geometry.m_ipsym == -1); + REQUIRE((geometry.n_segments + geometry.m) / + (geometry.np + geometry.mp) == order); + for (int copy = 0; copy < order; ++copy) { + const double angle = 2.0 * pi() * copy / order; + CAPTURE(copy); + REQUIRE(geometry.x[copy] == Catch::Approx(std::cos(angle)).margin(1e-12)); + REQUIRE(geometry.y[copy] == Catch::Approx(std::sin(angle)).margin(1e-12)); + REQUIRE(geometry.z[copy] == 0.1); + REQUIRE(geometry.z2[copy] == 0.2); + REQUIRE(geometry.segment_tags[copy] == 10 + copy * 100); + } + } +} + +TEST_CASE("WP-S1 expected symmetry failures do not mutate generated geometry", + "[symmetry][wp_s1]") +{ + SECTION("segment crossing a reflection plane") { + c_geometry geometry; + geometry.wire(1, 1, -1.0, 1.0, 1.0, 1.0, 1.0, 2.0, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(reflection_symmetry(nec_reflection_plane_x)), + nec_exception); + REQUIRE(geometry.n_segments == 1); + REQUIRE(geometry.x.size() == 1); + REQUIRE(geometry.segment_tags.size() == 1); + REQUIRE(geometry.np == 1); + REQUIRE(geometry.m_ipsym == 0); + } + + SECTION("patch centered in a reflection plane") { + c_geometry geometry; + geometry.patch(0, 0, 0.0, 2.0, 3.0, 0.0, 0.0, 0.25, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(reflection_symmetry(nec_reflection_plane_x)), + nec_exception); + REQUIRE(geometry.m == 1); + REQUIRE(geometry.px.size() == 1); + REQUIRE(geometry.mp == 1); + REQUIRE(geometry.m_ipsym == 0); + } + + SECTION("fixed rotational-axis segment") { + c_geometry geometry; + geometry.wire(1, 1, 0.0, 0.0, 0.1, 0.0, 0.0, 0.2, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(rotational_symmetry(6)), nec_exception); + REQUIRE(geometry.n_segments == 1); + REQUIRE(geometry.x.size() == 1); + REQUIRE(geometry.np == 1); + REQUIRE(geometry.m_ipsym == 0); + } + + SECTION("already duplicated rotational segment") { + c_geometry geometry; + geometry.wire(1, 1, 1.0, 0.0, 0.1, 1.0, 0.0, 0.2, + 0.001, 1.0, 1.0); + geometry.wire(2, 1, -1.0, 0.0, 0.1, -1.0, 0.0, 0.2, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(rotational_symmetry(2, 10)), nec_exception); + REQUIRE(geometry.n_segments == 2); + REQUIRE(geometry.x.size() == 2); + REQUIRE(geometry.np == 2); + REQUIRE(geometry.m_ipsym == 0); + } +} + +TEST_CASE("WP-S1 symmetry validates tag and size arithmetic before allocation", + "[symmetry][wp_s1]") +{ + SECTION("malformed descriptors") { + c_geometry geometry; + geometry.wire(1, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(reflection_symmetry(8u)), nec_exception); + REQUIRE_THROWS_AS( + geometry.generate_symmetry(rotational_symmetry(1)), nec_exception); + REQUIRE_THROWS_AS( + geometry.generate_symmetry( + reflection_symmetry(nec_reflection_plane_x, 0)), + nec_exception); + REQUIRE(geometry.n_segments == 1); + REQUIRE(geometry.x.size() == 1); + REQUIRE(geometry.np == 1); + REQUIRE(geometry.m_ipsym == 0); + } + + SECTION("generated tag collision") { + c_geometry geometry; + geometry.wire(1, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, + 0.001, 1.0, 1.0); + geometry.wire(2, 1, 2.0, 1.0, 1.0, 2.0, 1.0, 2.0, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry( + reflection_symmetry(nec_reflection_plane_x, 1)), + nec_exception); + REQUIRE(geometry.n_segments == 2); + REQUIRE(geometry.segment_tags.size() == 2); + } + + SECTION("generated tag overflow") { + c_geometry geometry; + geometry.wire((std::numeric_limits::max)(), 1, + 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, + 0.001, 1.0, 1.0); + REQUIRE_THROWS_AS( + geometry.generate_symmetry( + reflection_symmetry(nec_reflection_plane_x, 1)), + nec_exception); + REQUIRE(geometry.n_segments == 1); + REQUIRE(geometry.segment_tags[0] == (std::numeric_limits::max)()); + } + + SECTION("count multiplication overflow") { + c_geometry geometry; + geometry.n_segments = (std::numeric_limits::max)() / 2 + 1; + REQUIRE_THROWS_AS( + geometry.generate_symmetry(reflection_symmetry(nec_reflection_plane_x)), + nec_exception); + REQUIRE(geometry.n_segments == + (std::numeric_limits::max)() / 2 + 1); + REQUIRE(geometry.np == 0); + } + + SECTION("safe-array allocation size overflow") { + c_geometry geometry; + geometry.n_segments = real_array::maximum_size() / 2 + 1; + REQUIRE_THROWS_AS( + geometry.generate_symmetry(reflection_symmetry(nec_reflection_plane_x)), + nec_exception); + REQUIRE(geometry.n_segments == real_array::maximum_size() / 2 + 1); + REQUIRE(geometry.np == 0); + } +} diff --git a/src/nec_geometry_symmetry.h b/src/nec_geometry_symmetry.h new file mode 100644 index 00000000..cedfc5a5 --- /dev/null +++ b/src/nec_geometry_symmetry.h @@ -0,0 +1,54 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#pragma once + +#include + +/*! Symmetry generator selected before completing geometry. + * + * The numeric values are part of the additive C/WASM boundary. Keep them + * stable across the native, ABI, and TypeScript layers. + */ +enum class nec_geometry_symmetry_kind { + none = 0, + reflection = 1, + rotational = 2, +}; + +/*! Coordinate-plane bits used by NEC's geometry reflection generator. */ +enum nec_reflection_plane_mask : uint32_t { + nec_reflection_plane_x = 1u, + nec_reflection_plane_y = 2u, + nec_reflection_plane_z = 4u, +}; + +/*! Immutable input contract for one final geometry symmetry operation. + * + * Reflection uses reflection_plane_mask and requires rotational_order == 1. + * Rotation uses rotational_order >= 2 about global Z and requires a zero + * reflection_plane_mask. tag_increment is a positive offset per copy block. + */ +struct nec_geometry_symmetry { + nec_geometry_symmetry_kind kind = nec_geometry_symmetry_kind::none; + uint32_t reflection_plane_mask = 0u; + int rotational_order = 1; + int tag_increment = 0; +}; + +/*! Geometry-generation metadata; copy transforms derive from the descriptor. + * + * A non-symmetric result reports one section. Segment counts use 64-bit + * storage so metadata does not narrow the geometry's native counts. + */ +struct nec_geometry_completion_result { + nec_geometry_symmetry symmetry; + int section_count = 1; + int64_t fundamental_segment_count = 0; + int64_t full_segment_count = 0; +}; diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index 309f7a9e..5fd7f2e5 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -9,6 +9,7 @@ #pragma once #include "common.h" +#include "nec_geometry_symmetry.h" #include #include @@ -43,49 +44,6 @@ enum class nec_ground_connection { zero_current = 2, }; -/*! Symmetry generator selected while completing stateful geometry. - * - * The numeric values are part of the planned additive C/WASM boundary. Keep - * them stable when the stateful implementation is added in WP-S1/WP-S2. - */ -enum class nec_geometry_symmetry_kind { - none = 0, - reflection = 1, - rotational = 2, -}; - -/*! Coordinate-plane bits used by NEC's geometry reflection generator. */ -enum nec_reflection_plane_mask : uint32_t { - nec_reflection_plane_x = 1u, - nec_reflection_plane_y = 2u, - nec_reflection_plane_z = 4u, -}; - -/*! Immutable input contract for one final geometry symmetry operation. - * - * Reflection uses reflection_plane_mask and requires rotational_order == 1. - * Rotation uses rotational_order >= 2 about global Z and requires a zero - * reflection_plane_mask. tag_increment is a positive offset per copy block. - */ -struct nec_geometry_symmetry { - nec_geometry_symmetry_kind kind = nec_geometry_symmetry_kind::none; - uint32_t reflection_plane_mask = 0u; - int rotational_order = 1; - int tag_increment = 0; -}; - -/*! Geometry-completion metadata; copy transforms derive from the descriptor. - * - * A non-symmetric completion reports one section. Segment counts use 64-bit - * storage so metadata does not narrow the stateful geometry's native counts. - */ -struct nec_geometry_completion_result { - nec_geometry_symmetry symmetry; - int section_count = 1; - int64_t fundamental_segment_count = 0; - int64_t full_segment_count = 0; -}; - struct nec_port_definition { int tag = 0; int segment = 0; diff --git a/src/safe_array.h b/src/safe_array.h index 156a542c..fdad0ad8 100644 --- a/src/safe_array.h +++ b/src/safe_array.h @@ -17,8 +17,10 @@ */ #pragma once +#include #include #include +#include #include #include #include @@ -74,6 +76,16 @@ class safe_array { int64_t cols() const { return _cols; } int64_t capacity() const { return _own_data ? _capacity : _len; } + /*! Largest logical length whose 1.5x growth allocation is representable. */ + static int64_t maximum_size() { + const uint64_t index_limit = + static_cast((std::numeric_limits::max)()); + const uint64_t byte_limit = + static_cast((std::numeric_limits::max)()) / sizeof(T); + const uint64_t capacity_limit = std::min(index_limit, byte_limit); + return static_cast((capacity_limit / 3u) * 2u); + } + void resize(int64_t n_rows, int64_t n_cols) { _rows = n_rows; _cols = n_cols; @@ -93,13 +105,18 @@ class safe_array { if (!_own_data) throw nec_exception("attempt to resize data we do not own"); #endif + if (new_length < 0) + throw nec_exception("safe_array: negative resize requested"); + if (new_length > maximum_size()) + throw nec_exception("safe_array: requested size is too large"); if (new_length > _capacity) { - _capacity = new_length + new_length / 2; // 1.5x growth + const int64_t new_capacity = new_length + new_length / 2; try { - Vector new_storage(_capacity); + Vector new_storage(new_capacity); if (_len > 0) new_storage.head(_len) = _storage.head(_len); _storage.swap(new_storage); + _capacity = new_capacity; } catch (const std::bad_alloc&) { throw nec_exception("Error: Out of Memory "); } From 2596c35e1924d7f27952ff7a0b620dd8025f67be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 13:26:29 +0200 Subject: [PATCH 38/46] feat: add stateful symmetry support --- docs/01_symmetry_support.md | 52 ++- src/nec_stateful_model.cpp | 125 ++++- src/nec_stateful_model.h | 16 + src/nec_stateful_model_symmetry_tb.cpp | 607 +++++++++++++++++++++++++ tests/CMakeLists.txt | 6 +- 5 files changed, 800 insertions(+), 6 deletions(-) create mode 100644 src/nec_stateful_model_symmetry_tb.cpp diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 0f4444c8..ec4f0dea 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1232,7 +1232,7 @@ Every agent updates this table and the detailed WP section before handing off. |---|---|---|---|---| | WP-S0 Contract and shared fixtures | complete | Codex | Native `[wp_s0]`: 15 assertions; `necpp_unit`: 1/1; npm: 38/38 + typecheck | Preserve the finalized descriptor values, branded rotational order, Z/Y/X copy order, and golden scatter/gather maps. | | WP-S1 Native geometry safety and metadata | complete | Codex | Native `[wp_s1]`: 291 assertions; aggregate: 1,044; 52/52 legacy decks matched; npm: 38/38 | WP-S2 must call `c_geometry::generate_symmetry()` and consume its immutable result instead of reading geometry arrays. | -| WP-S2 Stateful symmetry and validation | not started | — | — | — | +| WP-S2 Stateful symmetry and validation | complete | Codex | Native `[wp_s2]`: 10,599 assertions; direct WP1-WP4 regressions and npm 38/38 | WP-S3 should call the symmetric overload, then expose only `geometry_completion()` metadata through additive ABI getters. | | WP-S3 Additive C/WASM ABI | not started | — | — | — | | WP-S4 Direct and worker TypeScript API | not started | — | — | — | | WP-S5 Transparent symmetrizer | not started | — | — | — | @@ -1428,6 +1428,56 @@ DoD: behavior; and - no formatted report parsing appears in a stateful correctness test. +Completion evidence (2026-08-30, Windows/MSVC): + +- A clean bounds-checked test target was rebuilt with + `cmake --build build-wps1 --config Release --target nec2++_tests + --clean-first`; the build passed with only the repository's existing MSVC + conversion and unknown-pragma warnings. +- `build-wps1\tests\Release\nec2++_tests.exe "[wp_s2]" --reporter + compact` passed 10,599 assertions in five cases. The suite compares gathered + binary64 Z/Y matrices, asymmetric current solves, all port quantities, and + complex far fields for R1, R2, R4, T1, T2, and G1. It also covers immutable + metadata, retry after descriptor preflight failure, post-completion mutation, + incomplete/unequal/complete/all-segment loads, and ground compatibility. +- The pre-existing stateful partitions passed from the clean binary when run + directly: WP1 non-stress 49 assertions plus its isolated 1,000-solve stress + case 5 assertions; WP2 66; WP3 206; and WP4 59. Keeping the WP1 stress case + in a separate process avoids the Windows CTest accumulated-context launcher + stall already recorded by WP-S1. +- The focused CTest regression command selecting `necpp_unit`, `necpp_wp_s2`, + and `necpp_smoke_hertzian_dipole` passed 3/3. Attempts to run every older + stateful partition in one CTest invocation reproduced the existing Windows + launcher instability (one accumulated WP1 timeout and, on a later run, a WP2 + process fault); the same Catch cases passed from the clean binary as listed + above, so no numerical or assertion failure was skipped silently. +- After the clean target, prebuilding `necpp_static` and running + `cmake --build build-wps1 --config Release` completed the full native build. + This ordering avoids the existing clean MSVC shared/static import-library + filename race; incremental full builds also pass. +- `npm --prefix packages/necpp-wasm test` passed all 38 Node tests and strict + TypeScript typechecking. + +Contract decisions for WP-S3 and later: + +- The source-compatible `void complete_geometry(connection)` overload remains. + The new descriptor overload returns a stable const reference, and + `geometry_completion()` is the sole read-only stateful metadata accessor. +- Symmetric completion calls WP-S1's strict `generate_symmetry()` before the + ordinary geometry completion path. Expected descriptor and ground-connection + failures therefore leave the model in geometry-building state and retryable. +- Load definitions are retained and validated at `prepare()` as exact multisets + on corresponding generated segment orbits. Complete per-copy definitions and + an all-segment scalar load pass; missing or unequal copy definitions fail + before factorization or matrix publication. +- Structural `z=0` reflection rejects a non-none ground connection and every + non-free-space ground model. X/Y reflections and Z-axis rotations retain + perfect and homogeneous finite-ground support. +- Port definitions, requested source order, and simultaneous complex weights + are not treated as structural symmetry. The native solver continues to + preserve arbitrary port order and asymmetric excitations through its retained + factorization path. + Handoff focus: WP-S3 gets a complete native API with stable result ownership. ### WP-S3 — Additive C/WASM ABI diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index 1a24e886..ef3e905c 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace { @@ -195,14 +196,42 @@ void nec_stateful_model::add_wire(const nec_wire_definition& wire) } void nec_stateful_model::complete_geometry(nec_ground_connection connection) +{ + static_cast(complete_geometry(nec_geometry_symmetry{}, connection)); +} + +const nec_geometry_completion_result& nec_stateful_model::complete_geometry( + const nec_geometry_symmetry& symmetry, + nec_ground_connection connection) { require_state(nec_model_state::geometry_building, "COMPLETE GEOMETRY"); const int flag = static_cast(connection); if (flag < 0 || flag > 2) fail("COMPLETE GEOMETRY", "UNKNOWN GROUND CONNECTION MODE"); + + if (connection != nec_ground_connection::none && + symmetry.kind == nec_geometry_symmetry_kind::reflection && + (symmetry.reflection_plane_mask & nec_reflection_plane_z) != 0u) + fail( + "COMPLETE GEOMETRY", + "Z=0 STRUCTURAL REFLECTION IS INCOMPATIBLE WITH A GROUND CONNECTION"); + + const nec_geometry_completion_result completion = + m_context->get_geometry()->generate_symmetry(symmetry); m_context->geometry_complete(flag); + m_geometry_completion = completion; m_state = nec_model_state::geometry_complete; m_configuration_dirty = true; + return m_geometry_completion; +} + +const nec_geometry_completion_result& +nec_stateful_model::geometry_completion() const +{ + if (m_state == nec_model_state::empty || + m_state == nec_model_state::geometry_building) + fail("GEOMETRY COMPLETION", "GEOMETRY IS NOT COMPLETE"); + return m_geometry_completion; } void nec_stateful_model::define_ports( @@ -280,10 +309,16 @@ void nec_stateful_model::add_load(const nec_load_definition& load) ? load.first_segment : load.last_segment; - m_context->ld_card( - load_kind, load.tag, - load.first_segment, last_segment, - load.value1, load.value2, load.value3); + m_loads.push_back(load); + try { + m_context->ld_card( + load_kind, load.tag, + load.first_segment, last_segment, + load.value1, load.value2, load.value3); + } catch (...) { + m_loads.pop_back(); + throw; + } invalidate_factorization(); } @@ -291,12 +326,90 @@ void nec_stateful_model::clear_loads() { require_configurable("CLEAR LOADS"); m_context->stateful_clear_loads(); + m_loads.clear(); invalidate_factorization(); } +void nec_stateful_model::validate_symmetry_ground( + const nec_ground_definition& ground, const char* operation) const +{ + if (ground.kind != nec_ground_kind::free_space && + m_geometry_completion.symmetry.kind == + nec_geometry_symmetry_kind::reflection && + (m_geometry_completion.symmetry.reflection_plane_mask & + nec_reflection_plane_z) != 0u) + fail( + operation, + "Z=0 STRUCTURAL REFLECTION IS INCOMPATIBLE WITH GROUND"); +} + +void nec_stateful_model::validate_symmetric_load_orbits() const +{ + if (m_geometry_completion.section_count <= 1 || m_loads.empty()) + return; + + const int64_t fundamental_count = + m_geometry_completion.fundamental_segment_count; + const int64_t full_count = m_geometry_completion.full_segment_count; + using load_signature = + std::tuple; + if (fundamental_count <= 0 || full_count <= 0 || + full_count % m_geometry_completion.section_count != 0 || + full_count / m_geometry_completion.section_count != fundamental_count) + fail("PREPARE", "SYMMETRY COMPLETION METADATA IS INCONSISTENT"); + if (static_cast(full_count) > + static_cast(std::vector().max_size())) + fail("PREPARE", "SYMMETRY LOAD VALIDATION SIZE IS TOO LARGE"); + + std::vector> loads_by_segment( + static_cast(full_count)); + const c_geometry* geometry = m_context->get_geometry(); + + for (const nec_load_definition& load : m_loads) { + const load_signature signature{ + static_cast(load.kind), load.value1, load.value2, load.value3}; + const int64_t first = load.first_segment == 0 ? 1 : load.first_segment; + const int64_t last = load.last_segment == 0 + ? (load.first_segment == 0 ? full_count : first) + : load.last_segment; + int64_t tag_occurrence = 0; + + for (int64_t index = 0; index < full_count; ++index) { + bool selected = false; + if (load.tag == 0) { + const int64_t absolute_segment = index + 1; + selected = absolute_segment >= first && absolute_segment <= last; + } else if (geometry->segment_tags[index] == load.tag) { + ++tag_occurrence; + selected = load.first_segment == 0 || + (tag_occurrence >= first && tag_occurrence <= last); + } + if (selected) + loads_by_segment[static_cast(index)].push_back(signature); + } + } + + for (std::vector& segment_loads : loads_by_segment) + std::sort(segment_loads.begin(), segment_loads.end()); + + for (int64_t fundamental = 0; + fundamental < fundamental_count; ++fundamental) { + const std::vector& expected = + loads_by_segment[static_cast(fundamental)]; + for (int copy = 1; + copy < m_geometry_completion.section_count; ++copy) { + const size_t generated = static_cast( + fundamental + static_cast(copy) * fundamental_count); + if (loads_by_segment[generated] != expected) + fail("PREPARE", "INCOMPLETE OR UNEQUAL SYMMETRY LOAD ORBIT"); + } + } +} + void nec_stateful_model::set_ground(const nec_ground_definition& ground) { require_configurable("SET GROUND"); + validate_symmetry_ground(ground, "SET GROUND"); int ground_type = -1; switch (ground.kind) { @@ -327,6 +440,7 @@ void nec_stateful_model::set_ground(const nec_ground_definition& ground) ground_type, 0, ground.relative_permittivity, ground.conductivity_s_per_m, 0.0, 0.0, 0.0, 0.0); + m_ground = ground; invalidate_factorization(); } @@ -338,6 +452,9 @@ void nec_stateful_model::prepare(nec_float frequency_mhz) if (!finite_value(frequency_mhz) || !(frequency_mhz > 0.0)) fail("PREPARE", "FREQUENCY MUST BE POSITIVE AND FINITE"); + validate_symmetry_ground(m_ground, "PREPARE"); + validate_symmetric_load_orbits(); + if (!m_configuration_dirty && m_frequency_mhz == frequency_mhz) return; diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index 5fd7f2e5..4503ede6 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -197,8 +197,18 @@ class nec_stateful_model { nec_model_state state() const { return m_state; } void add_wire(const nec_wire_definition& wire); + + /*! Complete ordinary geometry while preserving the pre-symmetry API. */ void complete_geometry( nec_ground_connection connection = nec_ground_connection::none); + + /*! Generate the final symmetry copies, complete geometry, and retain metadata. */ + const nec_geometry_completion_result& complete_geometry( + const nec_geometry_symmetry& symmetry, + nec_ground_connection connection = nec_ground_connection::none); + + /*! Read immutable metadata for successfully completed geometry. */ + const nec_geometry_completion_result& geometry_completion() const; void define_ports(const std::vector& ports); void add_load(const nec_load_definition& load); @@ -254,6 +264,9 @@ class nec_stateful_model { void require_configurable(const char* operation) const; void invalidate_factorization(); void validate_load_target(const nec_load_definition& load) const; + void validate_symmetric_load_orbits() const; + void validate_symmetry_ground( + const nec_ground_definition& ground, const char* operation) const; void clear_matrix_cache(); void clear_consumer_solution(); void execute_voltage_solve( @@ -273,7 +286,9 @@ class nec_stateful_model { std::unique_ptr m_context; nec_model_state m_state = nec_model_state::empty; + nec_geometry_completion_result m_geometry_completion; std::vector m_ports; + std::vector m_loads; std::vector m_absolute_port_segments; std::vector m_port_currents; nec_complex_matrix m_admittance_matrix; @@ -281,6 +296,7 @@ class nec_stateful_model { nec_port_solution m_last_port_solution; nec_far_field_result m_far_field_result; nec_embedded_far_field_result m_embedded_far_field_result; + nec_ground_definition m_ground; nec_float m_frequency_mhz = 0.0; uint64_t m_factorization_generation = 0; uint64_t m_solve_generation = 0; diff --git a/src/nec_stateful_model_symmetry_tb.cpp b/src/nec_stateful_model_symmetry_tb.cpp new file mode 100644 index 00000000..c7e8d3db --- /dev/null +++ b/src/nec_stateful_model_symmetry_tb.cpp @@ -0,0 +1,607 @@ +#include + +#include "electromag.h" +#include "nec_exception.h" +#include "nec_stateful_model.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr nec_float kFrequencyMHz = 300.0; +constexpr int kSegments = 11; +constexpr int kFeedSegment = 6; + +struct array_point { + nec_float x = 0.0; + nec_float y = 0.0; +}; + +enum class test_ground { + perfect, + finite, +}; + +class scoped_cout_sink { +public: + scoped_cout_sink() + : previous(std::cout.rdbuf(sink.rdbuf())) + { + } + + ~scoped_cout_sink() + { + std::cout.rdbuf(previous); + } + +private: + std::ostringstream sink; + std::streambuf* previous; +}; + +nec_float wavelength_m() +{ + return em::get_wavelength(kFrequencyMHz * 1.0e6); +} + +nec_wire_definition reference_wire(int tag, const array_point& point) +{ + const nec_float wavelength = wavelength_m(); + return { + tag, kSegments, + point.x, point.y, wavelength / 12.0, + point.x, point.y, 5.0 * wavelength / 12.0, + wavelength / 1000.0, + }; +} + +std::vector square_points(int side) +{ + const nec_float spacing = wavelength_m() / 2.0; + std::vector points; + points.reserve(static_cast(side * side)); + for (int y = 0; y < side; ++y) { + for (int x = 0; x < side; ++x) { + points.push_back({ + (static_cast(x) - + (static_cast(side) - 1.0) / 2.0) * spacing, + (static_cast(y) - + (static_cast(side) - 1.0) / 2.0) * spacing, + }); + } + } + return points; +} + +std::vector select_fundamental( + const std::vector& caller_points, + const nec_geometry_symmetry& symmetry) +{ + std::vector fundamental; + for (const array_point& point : caller_points) { + bool selected = false; + if (symmetry.kind == nec_geometry_symmetry_kind::reflection) { + selected = + ((symmetry.reflection_plane_mask & nec_reflection_plane_x) == 0u || + point.x > 0.0) && + ((symmetry.reflection_plane_mask & nec_reflection_plane_y) == 0u || + point.y > 0.0); + } else if (symmetry.kind == nec_geometry_symmetry_kind::rotational) { + selected = symmetry.rotational_order == 2 + ? point.y > 0.0 + : point.x > 0.0 && point.y > 0.0; + } + if (selected) + fundamental.push_back(point); + } + return fundamental; +} + +std::vector generated_points( + const std::vector& fundamental, + const nec_geometry_symmetry& symmetry) +{ + std::vector generated; + if (symmetry.kind == nec_geometry_symmetry_kind::reflection) { + const std::vector x_signs = + (symmetry.reflection_plane_mask & nec_reflection_plane_x) != 0u + ? std::vector{1, -1} + : std::vector{1}; + const std::vector y_signs = + (symmetry.reflection_plane_mask & nec_reflection_plane_y) != 0u + ? std::vector{1, -1} + : std::vector{1}; + // NEC applies Y before X, so X is the outer copy block. + for (const int x_sign : x_signs) { + for (const int y_sign : y_signs) { + for (const array_point& point : fundamental) + generated.push_back({x_sign * point.x, y_sign * point.y}); + } + } + } else { + for (int copy = 0; copy < symmetry.rotational_order; ++copy) { + const nec_float angle = + two_pi() * static_cast(copy) / + static_cast(symmetry.rotational_order); + const nec_float cosine = std::cos(angle); + const nec_float sine = std::sin(angle); + for (const array_point& point : fundamental) { + generated.push_back({ + cosine * point.x - sine * point.y, + sine * point.x + cosine * point.y, + }); + } + } + } + return generated; +} + +std::vector generated_to_caller_map( + const std::vector& generated, + const std::vector& caller) +{ + // NEC's historical pi() constant is intentionally preserved to ten decimal + // places, so quarter-turn test coordinates carry a few picometres of roundoff. + const nec_float tolerance = wavelength_m() * 1.0e-9; + std::vector mapping; + std::vector used(caller.size(), false); + mapping.reserve(generated.size()); + for (const array_point& point : generated) { + size_t match = caller.size(); + for (size_t index = 0; index < caller.size(); ++index) { + if (!used[index] && + std::abs(point.x - caller[index].x) <= tolerance && + std::abs(point.y - caller[index].y) <= tolerance) { + match = index; + break; + } + } + REQUIRE(match < caller.size()); + used[match] = true; + mapping.push_back(match); + } + REQUIRE(std::all_of(used.begin(), used.end(), [](bool value) { return value; })); + return mapping; +} + +void set_test_ground(nec_stateful_model& model, test_ground ground) +{ + if (ground == test_ground::perfect) { + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); + } else { + model.set_ground({ + nec_ground_kind::finite_reflection_coefficient, + 13.0, 0.005, + }); + } +} + +void build_explicit( + nec_stateful_model& model, + const std::vector& points, + test_ground ground) +{ + std::vector ports; + for (size_t index = 0; index < points.size(); ++index) { + const int tag = static_cast(index + 1); + model.add_wire(reference_wire(tag, points[index])); + ports.push_back({tag, kFeedSegment}); + } + model.complete_geometry(); + model.define_ports(ports); + set_test_ground(model, ground); + model.prepare(kFrequencyMHz); +} + +std::vector build_symmetric( + nec_stateful_model& model, + const std::vector& caller_points, + const nec_geometry_symmetry& symmetry, + test_ground ground) +{ + const std::vector fundamental = + select_fundamental(caller_points, symmetry); + REQUIRE(!fundamental.empty()); + for (size_t index = 0; index < fundamental.size(); ++index) + model.add_wire(reference_wire(static_cast(index + 1), fundamental[index])); + + const nec_geometry_completion_result& completion = + model.complete_geometry(symmetry); + REQUIRE(completion.symmetry.kind == symmetry.kind); + REQUIRE(completion.fundamental_segment_count == + static_cast(fundamental.size() * kSegments)); + REQUIRE(completion.full_segment_count == + static_cast(caller_points.size() * kSegments)); + REQUIRE(&completion == &model.geometry_completion()); + + const std::vector generated = + generated_points(fundamental, symmetry); + REQUIRE(generated.size() == caller_points.size()); + std::vector ports; + for (size_t index = 0; index < generated.size(); ++index) + ports.push_back({static_cast(index + 1), kFeedSegment}); + model.define_ports(ports); + set_test_ground(model, ground); + model.prepare(kFrequencyMHz); + return generated_to_caller_map(generated, caller_points); +} + +std::vector gather_complex( + const std::vector& native, + const std::vector& native_to_caller) +{ + REQUIRE(native.size() == native_to_caller.size()); + std::vector caller(native.size()); + for (size_t native_index = 0; native_index < native.size(); ++native_index) + caller[native_to_caller[native_index]] = native[native_index]; + return caller; +} + +std::vector gather_real( + const std::vector& native, + const std::vector& native_to_caller) +{ + REQUIRE(native.size() == native_to_caller.size()); + std::vector caller(native.size()); + for (size_t native_index = 0; native_index < native.size(); ++native_index) + caller[native_to_caller[native_index]] = native[native_index]; + return caller; +} + +std::vector gather_matrix( + const nec_complex_matrix& native, + const std::vector& native_to_caller) +{ + const size_t order = native_to_caller.size(); + REQUIRE(native.rows == order); + REQUIRE(native.columns == order); + std::vector caller(order * order); + for (size_t native_row = 0; native_row < order; ++native_row) { + for (size_t native_column = 0; native_column < order; ++native_column) { + const size_t caller_row = native_to_caller[native_row]; + const size_t caller_column = native_to_caller[native_column]; + caller[caller_row * order + caller_column] = + native.at(native_row, native_column); + } + } + return caller; +} + +void require_complex_close( + const std::vector& actual, + const std::vector& expected, + nec_float tolerance = 1.0e-8) +{ + REQUIRE(actual.size() == expected.size()); + nec_float difference_squared = 0.0; + nec_float expected_squared = 0.0; + nec_float max_difference = 0.0; + nec_float max_expected = 0.0; + for (size_t index = 0; index < actual.size(); ++index) { + REQUIRE(std::isfinite(actual[index].real())); + REQUIRE(std::isfinite(actual[index].imag())); + REQUIRE(std::isfinite(expected[index].real())); + REQUIRE(std::isfinite(expected[index].imag())); + difference_squared += std::norm(actual[index] - expected[index]); + expected_squared += std::norm(expected[index]); + max_difference = std::max(max_difference, std::abs(actual[index] - expected[index])); + max_expected = std::max(max_expected, std::abs(expected[index])); + } + const nec_float relative_l2 = std::sqrt(difference_squared) / + std::max(nec_float(1.0e-12), std::sqrt(expected_squared)); + const nec_float scaled_max = max_difference / + std::max(nec_float(1.0e-12), max_expected); + REQUIRE(relative_l2 <= tolerance); + REQUIRE(scaled_max <= tolerance); +} + +void require_real_close( + const std::vector& actual, + const std::vector& expected, + nec_float tolerance = 1.0e-8) +{ + REQUIRE(actual.size() == expected.size()); + nec_float max_difference = 0.0; + nec_float max_expected = 0.0; + for (size_t index = 0; index < actual.size(); ++index) { + REQUIRE(std::isfinite(actual[index])); + REQUIRE(std::isfinite(expected[index])); + max_difference = std::max(max_difference, std::abs(actual[index] - expected[index])); + max_expected = std::max(max_expected, std::abs(expected[index])); + } + REQUIRE(max_difference / std::max(nec_float(1.0e-12), max_expected) <= + tolerance); +} + +void run_equivalence_case( + int side, + nec_geometry_symmetry symmetry, + test_ground ground) +{ + scoped_cout_sink silence_debug_trace; + const std::vector caller_points = square_points(side); + const std::vector fundamental = + select_fundamental(caller_points, symmetry); + symmetry.tag_increment = static_cast(fundamental.size()); + + nec_stateful_model explicit_model; + nec_stateful_model symmetric_model; + build_explicit(explicit_model, caller_points, ground); + const std::vector native_to_caller = + build_symmetric(symmetric_model, caller_points, symmetry, ground); + + REQUIRE(explicit_model.geometry_completion().section_count == 1); + REQUIRE(explicit_model.geometry_completion().fundamental_segment_count == + static_cast(caller_points.size() * kSegments)); + REQUIRE(symmetric_model.factorization_generation() == 1); + REQUIRE(explicit_model.factorization_generation() == 1); + + const nec_impedance_result& explicit_matrices = + explicit_model.compute_impedance_matrix(); + const nec_impedance_result& symmetric_matrices = + symmetric_model.compute_impedance_matrix(); + require_complex_close( + gather_matrix(symmetric_matrices.impedance, native_to_caller), + explicit_matrices.impedance.values); + require_complex_close( + gather_matrix(symmetric_matrices.admittance, native_to_caller), + explicit_matrices.admittance.values); + + std::vector caller_currents; + caller_currents.reserve(caller_points.size()); + for (size_t index = 0; index < caller_points.size(); ++index) { + const nec_float amplitude = 0.004 + 0.0003 * (index % 5); + const nec_float phase = 0.23 * index - 0.07 * ((index * index) % 3); + caller_currents.push_back(std::polar(amplitude, phase)); + } + std::vector native_currents(caller_currents.size()); + for (size_t native_index = 0; + native_index < native_to_caller.size(); ++native_index) + native_currents[native_index] = caller_currents[native_to_caller[native_index]]; + + const nec_port_solution explicit_solution = + explicit_model.solve_port_currents(caller_currents); + const nec_port_solution symmetric_solution = + symmetric_model.solve_port_currents(native_currents); + require_complex_close( + gather_complex(symmetric_solution.voltages, native_to_caller), + explicit_solution.voltages); + require_complex_close( + gather_complex(symmetric_solution.currents, native_to_caller), + explicit_solution.currents); + require_complex_close( + gather_complex(symmetric_solution.active_impedances, native_to_caller), + explicit_solution.active_impedances); + require_real_close( + gather_real(symmetric_solution.powers_w, native_to_caller), + explicit_solution.powers_w); + + const nec_far_field_grid field_grid{ + 2.0, + 25.0, 4, 20.0, + 0.0, 4, 60.0, + }; + const nec_far_field_result& explicit_field = + explicit_model.compute_far_field(field_grid); + const nec_far_field_result& symmetric_field = + symmetric_model.compute_far_field(field_grid); + REQUIRE(symmetric_field.theta_deg == explicit_field.theta_deg); + REQUIRE(symmetric_field.phi_deg == explicit_field.phi_deg); + require_complex_close(symmetric_field.e_theta, explicit_field.e_theta); + require_complex_close(symmetric_field.e_phi, explicit_field.e_phi); + REQUIRE(symmetric_model.factorization_generation() == 1); + REQUIRE(explicit_model.factorization_generation() == 1); +} + +nec_geometry_symmetry reflection(uint32_t planes) +{ + return { + nec_geometry_symmetry_kind::reflection, + planes, + 1, + 1, + }; +} + +nec_geometry_symmetry rotation(int order) +{ + return { + nec_geometry_symmetry_kind::rotational, + 0u, + order, + 1, + }; +} + +void build_loaded_quadrant(nec_stateful_model& model) +{ + const nec_float quarter = wavelength_m() / 4.0; + model.add_wire(reference_wire(1, {quarter, quarter})); + model.complete_geometry(reflection( + nec_reflection_plane_x | nec_reflection_plane_y)); + model.define_ports({{1, 6}, {2, 6}, {3, 6}, {4, 6}}); +} + +std::string exception_message(const nec_exception& error) +{ + return error.get_message(); +} + +} // namespace + +TEST_CASE("WP-S2 reflection arrays match explicit Z solve and complex field", + "[symmetry][wp_s2][equivalence][reflection]") +{ + SECTION("R1 2x2 X/Y reflection over perfect ground") { + run_equivalence_case( + 2, + reflection(nec_reflection_plane_x | nec_reflection_plane_y), + test_ground::perfect); + } + SECTION("R2 4x4 X/Y reflection over perfect ground") { + run_equivalence_case( + 4, + reflection(nec_reflection_plane_x | nec_reflection_plane_y), + test_ground::perfect); + } + SECTION("R4 4x4 X reflection over perfect ground") { + run_equivalence_case( + 4, + reflection(nec_reflection_plane_x), + test_ground::perfect); + } + SECTION("G1 4x4 X/Y reflection over finite ground") { + run_equivalence_case( + 4, + reflection(nec_reflection_plane_x | nec_reflection_plane_y), + test_ground::finite); + } +} + +TEST_CASE("WP-S2 rotational arrays match explicit Z solve and complex field", + "[symmetry][wp_s2][equivalence][rotation]") +{ + SECTION("T1 order-two 2x2 array over perfect ground") { + run_equivalence_case(2, rotation(2), test_ground::perfect); + } + SECTION("T2 order-four 4x4 array over perfect ground") { + run_equivalence_case(4, rotation(4), test_ground::perfect); + } +} + +TEST_CASE("WP-S2 completion metadata and lifecycle are immutable", + "[symmetry][wp_s2][lifecycle]") +{ + nec_stateful_model retry; + retry.add_wire(reference_wire(1, {0.2, 0.2})); + nec_geometry_symmetry invalid = reflection(nec_reflection_plane_x); + invalid.tag_increment = 0; + REQUIRE_THROWS_AS(retry.complete_geometry(invalid), nec_exception); + REQUIRE(retry.state() == nec_model_state::geometry_building); + REQUIRE_NOTHROW(retry.complete_geometry(reflection(nec_reflection_plane_x))); + + nec_stateful_model model; + REQUIRE_THROWS_AS(model.geometry_completion(), nec_exception); + model.add_wire(reference_wire(1, {0.2, 0.2})); + REQUIRE_THROWS_AS(model.geometry_completion(), nec_exception); + + const nec_geometry_completion_result& result = + model.complete_geometry(reflection(nec_reflection_plane_x)); + REQUIRE(result.section_count == 2); + REQUIRE(result.fundamental_segment_count == kSegments); + REQUIRE(result.full_segment_count == 2 * kSegments); + REQUIRE(result.symmetry.reflection_plane_mask == nec_reflection_plane_x); + REQUIRE_THROWS_AS( + model.add_wire(reference_wire(2, {0.3, 0.2})), + nec_exception); + REQUIRE(&result == &model.geometry_completion()); +} + +TEST_CASE("WP-S2 validates complete and equal load orbits at prepare", + "[symmetry][wp_s2][loads]") +{ + SECTION("incomplete orbit fails before preparation") { + nec_stateful_model model; + build_loaded_quadrant(model); + model.add_load({nec_load_kind::impedance, 1, 6, 0, 10.0, 2.0, 0.0}); + try { + model.prepare(kFrequencyMHz); + FAIL("incomplete load orbit was accepted"); + } catch (const nec_exception& error) { + REQUIRE(exception_message(error).find("INCOMPLETE OR UNEQUAL") != + std::string::npos); + } + REQUIRE(model.state() == nec_model_state::geometry_complete); + REQUIRE(model.factorization_generation() == 0); + REQUIRE_THROWS_AS(model.compute_impedance_matrix(), nec_exception); + } + + SECTION("unequal orbit fails before preparation") { + nec_stateful_model model; + build_loaded_quadrant(model); + for (int tag = 1; tag <= 4; ++tag) { + model.add_load({ + nec_load_kind::impedance, tag, 6, 0, + tag == 4 ? 11.0 : 10.0, 2.0, 0.0, + }); + } + REQUIRE_THROWS_AS(model.prepare(kFrequencyMHz), nec_exception); + REQUIRE(model.factorization_generation() == 0); + } + + SECTION("complete equal orbit passes") { + scoped_cout_sink silence_debug_trace; + nec_stateful_model model; + build_loaded_quadrant(model); + for (int tag = 1; tag <= 4; ++tag) { + model.add_load({ + nec_load_kind::impedance, tag, 6, 0, + 10.0, 2.0, 0.0, + }); + } + model.prepare(kFrequencyMHz); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.compute_impedance_matrix().impedance.rows == 4); + } + + SECTION("all-segment scalar load passes without expansion") { + scoped_cout_sink silence_debug_trace; + nec_stateful_model model; + build_loaded_quadrant(model); + model.add_load({ + nec_load_kind::conductivity, 0, 0, 0, + 3.72e7, 0.0, 0.0, + }); + model.prepare(kFrequencyMHz); + REQUIRE(model.factorization_generation() == 1); + REQUIRE(model.compute_impedance_matrix().impedance.rows == 4); + } +} + +TEST_CASE("WP-S2 rejects only ground that conflicts with structural symmetry", + "[symmetry][wp_s2][ground]") +{ + const array_point point{0.2, 0.2}; + + SECTION("z reflection rejects a ground connection before generation") { + nec_stateful_model model; + model.add_wire(reference_wire(1, point)); + REQUIRE_THROWS_AS( + model.complete_geometry( + reflection(nec_reflection_plane_z), + nec_ground_connection::interpolate), + nec_exception); + REQUIRE(model.state() == nec_model_state::geometry_building); + } + + SECTION("z reflection rejects a later ground model") { + nec_stateful_model model; + model.add_wire(reference_wire(1, point)); + model.complete_geometry(reflection(nec_reflection_plane_z)); + REQUIRE_THROWS_AS( + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}), + nec_exception); + REQUIRE(model.state() == nec_model_state::geometry_complete); + } + + SECTION("vertical reflection planes remain valid over ground") { + nec_stateful_model model; + model.add_wire(reference_wire(1, point)); + model.complete_geometry(reflection( + nec_reflection_plane_x | nec_reflection_plane_y)); + REQUIRE_NOTHROW(model.set_ground({nec_ground_kind::perfect, 0.0, 0.0})); + } + + SECTION("Z-axis rotation remains valid over ground") { + nec_stateful_model model; + model.add_wire(reference_wire(1, point)); + model.complete_geometry(rotation(4)); + REQUIRE_NOTHROW(model.set_ground({nec_ground_kind::perfect, 0.0, 0.0})); + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 376b35b0..b24ab831 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,7 @@ set(NECPP_TEST_SRCS ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp2_tb.cpp ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_wp3_tb.cpp + ${CMAKE_SOURCE_DIR}/src/nec_stateful_model_symmetry_tb.cpp ${CMAKE_SOURCE_DIR}/src/necpp_wasm_v1_c_tb.c ${CMAKE_SOURCE_DIR}/src/necpp_wasm_v1_tb.cpp ) @@ -100,7 +101,7 @@ endif() # stress suite on independent timeout budgets. Otherwise a slow numerical # fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]~[wp4]") + COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]~[wp4]~[wp_s2]") add_test(NAME necpp_wp1 COMMAND nec2++_tests "[wp1]") add_test(NAME necpp_wp2 @@ -109,6 +110,8 @@ add_test(NAME necpp_wp3 COMMAND nec2++_tests "[wp3]") add_test(NAME necpp_wp4 COMMAND nec2++_tests "[wp4]") +add_test(NAME necpp_wp_s2 + COMMAND nec2++_tests "[wp_s2]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of # blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a # 1,000-excitation retained-factorization stress case; it completes in a few @@ -118,6 +121,7 @@ set_tests_properties(necpp_wp1 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp2 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp3 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp4 PROPERTIES TIMEOUT 180) +set_tests_properties(necpp_wp_s2 PROPERTIES TIMEOUT 240) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From 0959bd7dfad0dd8b5acf35793aa0b8018a390aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 13:59:15 +0200 Subject: [PATCH 39/46] feat: expose symmetry through additive wasm abi --- docs/01_symmetry_support.md | 52 +++++++- packages/necpp-wasm/src/wasm-internal.ts | 12 ++ .../necpp-wasm/test/pack/consumer.test.mjs | 12 ++ packages/necpp-wasm/test/pack/helpers.mjs | 11 ++ scripts/wasm_smoke_test.mjs | 106 +++++++++++++++ src/CMakeLists.txt | 2 +- src/nec_exception.h | 8 ++ src/nec_stateful_model.cpp | 19 ++- src/necpp_wasm_v1.cpp | 85 ++++++++++++ src/necpp_wasm_v1.h | 39 ++++++ src/necpp_wasm_v1_c_tb.c | 122 ++++++++++++++++++ src/necpp_wasm_v1_tb.cpp | 48 +++++++ tests/CMakeLists.txt | 5 +- 13 files changed, 513 insertions(+), 8 deletions(-) diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index ec4f0dea..46b9babb 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1233,7 +1233,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S0 Contract and shared fixtures | complete | Codex | Native `[wp_s0]`: 15 assertions; `necpp_unit`: 1/1; npm: 38/38 + typecheck | Preserve the finalized descriptor values, branded rotational order, Z/Y/X copy order, and golden scatter/gather maps. | | WP-S1 Native geometry safety and metadata | complete | Codex | Native `[wp_s1]`: 291 assertions; aggregate: 1,044; 52/52 legacy decks matched; npm: 38/38 | WP-S2 must call `c_geometry::generate_symmetry()` and consume its immutable result instead of reading geometry arrays. | | WP-S2 Stateful symmetry and validation | complete | Codex | Native `[wp_s2]`: 10,599 assertions; direct WP1-WP4 regressions and npm 38/38 | WP-S3 should call the symmetric overload, then expose only `geometry_completion()` metadata through additive ABI getters. | -| WP-S3 Additive C/WASM ABI | not started | — | — | — | +| WP-S3 Additive C/WASM ABI | complete | Codex | Native `[wp_s3]`: 8 assertions/2 cases plus pure-C contract; CTest 8/8; WASM smoke; npm 38/38; pack 5/5; browser 3/3 | WP-S4 should convert the private WASM `bigint` segment counts to checked safe numbers and derive copy transforms from the validated descriptor. | | WP-S4 Direct and worker TypeScript API | not started | — | — | — | | WP-S5 Transparent symmetrizer | not started | — | — | — | | WP-S6 End-to-end equivalence suite | not started | — | — | — | @@ -1506,6 +1506,56 @@ DoD: - ABI version policy is documented: additive v1 symbols do not silently change existing signatures or enum values. +Completion evidence (2026-08-30, Windows/MSVC and Emscripten 4.0.7): + +- A clean bounds-checked native test executable was rebuilt with the Visual + Studio `nec2++_tests.vcxproj` `Rebuild` target. The initial incremental/LTCG + binary reproduced the repository's known Windows `SIGILL` behavior in the + first isolated WP-S2 lifecycle case; the clean rebuild removed it, and the + isolated lifecycle, load, and ground cases plus the full 10,599-assertion + `[wp_s2]` partition then passed. +- `build-wps1\tests\Release\nec2++_tests.exe "[wp_s3]" --reporter compact` + passed eight Catch assertions in two cases. Its separately C-compiled + contract function returned success after checking valid two-plane reflection, + valid order-four rotation, ordinary-completion metadata, null/unavailable + getter sentinels, and controlled lifecycle, input, geometry, incomplete-load, + and incompatible-ground failures. +- The full serial CTest run passed 8/8 registered tests, including WP1 through + WP4, WP-S2, the new WP-S3 partition, the aggregate native suite, and the + command-line smoke test. The full native Release `ALL_BUILD` target also + completed; its warnings were the existing conversion and unknown-pragma + warnings. +- `scripts\build_wasm_docker.ps1` rebuilt the module with the pinned + `emscripten/emsdk:4.0.7` image. `scripts/wasm_smoke_test.mjs` verified every + additive export and built the shared over-ground 2 x 2 reference array from + one positive-XY wire; its four-port Z matrix, asymmetric solve, and complex + far field were finite. +- The Docker build's `npm --prefix packages/necpp-wasm run test:wasm` gate + passed 38/38 Node tests plus strict typechecking and 5/5 packed-consumer + tests. The packed loader assertion names every new symbol. A fresh local + tarball also passed the direct, worker, and example browser integration + modes. Generated WASM artifacts remain intentionally ignored by source + control and were not added to the commit. + +Contract decisions for WP-S4 and later: + +- ABI version remains `1`. The original completion function, all prior + signatures, status values, and enum values are unchanged; symmetry is + exposed only through additive v1 symbols. +- Completion metadata uses scalar getters. Segment counts remain signed + 64-bit in C and therefore appear as `bigint` at the private Emscripten + boundary; WP-S4 must range-check before converting them to public TypeScript + `number` values. No new getter returns a borrowed pointer. +- The ABI stores a plain copy obtained from the stateful + `geometry_completion()` accessor. Before successful completion, kind is + `-1` and the other scalar getters return zero. Copy transforms are not ABI + arrays; WP-S4 derives them deterministically from the descriptor it already + validated and supplied. +- Symmetry configuration failures raised while preparing, including incomplete + or unequal load orbits, have a native geometry-exception classification so + they map to `NECPP_WASM_V1_GEOMETRY_ERROR` rather than being mislabeled as + numerical solver failures. + Handoff focus: WP-S4 should only translate/validate data and must not reproduce native electromagnetic logic. diff --git a/packages/necpp-wasm/src/wasm-internal.ts b/packages/necpp-wasm/src/wasm-internal.ts index 84167185..165cb3cb 100644 --- a/packages/necpp-wasm/src/wasm-internal.ts +++ b/packages/necpp-wasm/src/wasm-internal.ts @@ -29,6 +29,13 @@ export interface NecWasmModule { radiusM: number, ): number; _necpp_wasm_v1_complete_geometry(model: number, connection: number): number; + _necpp_wasm_v1_complete_geometry_symmetric( + model: number, + connection: number, + symmetryKind: number, + parameter: number, + tagIncrement: number, + ): number; _necpp_wasm_v1_define_ports( model: number, tags: number, @@ -112,6 +119,11 @@ export interface NecWasmModule { _necpp_wasm_v1_embedded_samples_per_port(model: number): number; _necpp_wasm_v1_embedded_normalization(model: number): number; + _necpp_wasm_v1_geometry_symmetry_kind(model: number): number; + _necpp_wasm_v1_geometry_section_count(model: number): number; + _necpp_wasm_v1_geometry_fundamental_segment_count(model: number): bigint; + _necpp_wasm_v1_geometry_full_segment_count(model: number): bigint; + _necpp_wasm_v1_result_buffer(model: number, kind: number): number; _necpp_wasm_v1_result_buffer_length(model: number, kind: number): number; diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 7b94ea30..302eb765 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -16,6 +16,7 @@ import { installFixture, packPackage, readInstalledWasm, + readInstalledLoader, run, runAsync, serveWasm, @@ -122,6 +123,17 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp-engine\/wasm/); assert.doesNotMatch(direct.resolved, /packages[/\\]necpp-wasm[/\\]src[/\\]/); + const packedLoader = readInstalledLoader(fixture.root); + for (const symbol of [ + "_necpp_wasm_v1_complete_geometry_symmetric", + "_necpp_wasm_v1_geometry_symmetry_kind", + "_necpp_wasm_v1_geometry_section_count", + "_necpp_wasm_v1_geometry_fundamental_segment_count", + "_necpp_wasm_v1_geometry_full_segment_count", + ]) { + assert.ok(packedLoader.includes(symbol), `packed loader is missing ${symbol}`); + } + const worker = parseJsonLine( run("node", ["worker-dipole.mjs"], { cwd: fixture.root, diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 9872fc74..d5d369d1 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -339,3 +339,14 @@ export function readInstalledWasm(fixtureRoot) { "nec2pp.wasm", )); } + +export function readInstalledLoader(fixtureRoot) { + return readFileSync(join( + fixtureRoot, + "node_modules", + "@necpp-engine", + "wasm", + "dist", + "nec2pp.generated.js", + ), "utf8"); +} diff --git a/scripts/wasm_smoke_test.mjs b/scripts/wasm_smoke_test.mjs index 9ece486a..cc7ef0f9 100644 --- a/scripts/wasm_smoke_test.mjs +++ b/scripts/wasm_smoke_test.mjs @@ -2,6 +2,8 @@ import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { createReferenceArrayFixture } from "../packages/necpp-wasm/test/fixtures/reference-array.mjs"; + const modulePath = process.argv[2]; if (!modulePath) { throw new Error("usage: node wasm_smoke_test.mjs "); @@ -94,6 +96,18 @@ check( ); check(typeof module._malloc === "function", "_malloc was not exported"); check(typeof module._free === "function", "_free was not exported"); +check( + typeof module._necpp_wasm_v1_complete_geometry_symmetric === "function", + "symmetric completion was not exported", +); +for (const getter of [ + "_necpp_wasm_v1_geometry_symmetry_kind", + "_necpp_wasm_v1_geometry_section_count", + "_necpp_wasm_v1_geometry_fundamental_segment_count", + "_necpp_wasm_v1_geometry_full_segment_count", +]) { + check(typeof module[getter] === "function", `${getter} was not exported`); +} check(module._nec_create_context === undefined, "legacy ABI leaked into module"); const model = module._necpp_wasm_v1_model_create(); @@ -237,6 +251,98 @@ try { module._necpp_wasm_v1_model_delete(model); } +// Build the shared 2 x 2 reference array from its positive-XY quadrant and +// prove the additive ABI reaches finite retained-matrix and solve results. +const reference = createReferenceArrayFixture({ side: 2, frequencyMHz: 300 }); +const symmetricModel = module._necpp_wasm_v1_model_create(); +check(symmetricModel !== 0, "symmetric model_create returned null"); +let symmetricTagsPointer = 0; +let symmetricSegmentsPointer = 0; +let symmetricRealPointer = 0; +let symmetricImagPointer = 0; +try { + const wire = reference.reflection.fundamentalWires[0]; + check(wire !== undefined, "2 x 2 reference fundamental wire is missing"); + check( + module._necpp_wasm_v1_add_wire( + symmetricModel, wire.tag, wire.segments, + ...wire.start, ...wire.end, wire.radiusM, + ) === OK, + `symmetric addWire failed: ${modelError(symmetricModel)}`, + ); + check( + module._necpp_wasm_v1_complete_geometry_symmetric( + symmetricModel, 0, 1, 3, 1, + ) === OK, + `symmetric completion failed: ${modelError(symmetricModel)}`, + ); + check( + module._necpp_wasm_v1_geometry_symmetry_kind(symmetricModel) === 1 && + module._necpp_wasm_v1_geometry_section_count(symmetricModel) === 4 && + module._necpp_wasm_v1_geometry_fundamental_segment_count(symmetricModel) === 11n && + module._necpp_wasm_v1_geometry_full_segment_count(symmetricModel) === 44n, + "symmetric completion metadata is incorrect", + ); + symmetricTagsPointer = allocateInt32(new Int32Array([1, 2, 3, 4])); + symmetricSegmentsPointer = allocateInt32(new Int32Array([6, 6, 6, 6])); + check( + module._necpp_wasm_v1_define_ports( + symmetricModel, symmetricTagsPointer, symmetricSegmentsPointer, 4, + ) === OK, + `symmetric definePorts failed: ${modelError(symmetricModel)}`, + ); + check( + module._necpp_wasm_v1_set_ground(symmetricModel, 1, 0, 0) === OK, + `symmetric setGround failed: ${modelError(symmetricModel)}`, + ); + check( + module._necpp_wasm_v1_prepare(symmetricModel, 300) === OK, + `symmetric prepare failed: ${modelError(symmetricModel)}`, + ); + check( + module._necpp_wasm_v1_compute_impedance(symmetricModel) === OK, + `symmetric impedance failed: ${modelError(symmetricModel)}`, + ); + const symmetricImpedance = copyResult(symmetricModel, IMPEDANCE_REAL); + check( + symmetricImpedance.length === 16 && + symmetricImpedance.every(Number.isFinite), + "symmetric 2 x 2 impedance is invalid", + ); + symmetricRealPointer = allocateFloat64(new Float64Array([1, 0, 0, 0])); + symmetricImagPointer = allocateFloat64(new Float64Array(4)); + check( + module._necpp_wasm_v1_solve_voltages( + symmetricModel, symmetricRealPointer, symmetricImagPointer, 4, + ) === OK, + `symmetric solve failed: ${modelError(symmetricModel)}`, + ); + check( + copyResult(symmetricModel, SOLUTION_CURRENTS_REAL).every(Number.isFinite), + "symmetric 2 x 2 currents are invalid", + ); + check( + module._necpp_wasm_v1_compute_far_field( + symmetricModel, + 1, + 90, 1, 0, + 0, 1, 0, + ) === OK, + `symmetric far field failed: ${modelError(symmetricModel)}`, + ); + check( + copyResult(symmetricModel, FAR_FIELD_E_THETA_REAL).every(Number.isFinite) && + copyResult(symmetricModel, FAR_FIELD_E_PHI_REAL).every(Number.isFinite), + "symmetric 2 x 2 far field is invalid", + ); +} finally { + if (symmetricTagsPointer) module._free(symmetricTagsPointer); + if (symmetricSegmentsPointer) module._free(symmetricSegmentsPointer); + if (symmetricRealPointer) module._free(symmetricRealPointer); + if (symmetricImagPointer) module._free(symmetricImagPointer); + module._necpp_wasm_v1_model_delete(symmetricModel); +} + const deck = module._necpp_wasm_v1_deck_create(); check(deck !== 0, "deck_create returned null"); let deckPointer = 0; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 28591a40..3e43706c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -160,7 +160,7 @@ if(NECPP_BUILD_WASM) target_link_options(nec2pp_wasm PRIVATE -fexceptions -sWASM=1 - "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" + "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_complete_geometry_symmetric\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_geometry_symmetry_kind\",\"_necpp_wasm_v1_geometry_section_count\",\"_necpp_wasm_v1_geometry_fundamental_segment_count\",\"_necpp_wasm_v1_geometry_full_segment_count\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 "-sSTACK_SIZE=${NECPP_WASM_STACK_SIZE}" diff --git a/src/nec_exception.h b/src/nec_exception.h index d96c7b9a..8aab39bb 100644 --- a/src/nec_exception.h +++ b/src/nec_exception.h @@ -62,6 +62,14 @@ class nec_exception std::stringstream m_message; }; +/* Configuration failures that must remain distinct from numerical solves at + * exception-safe C/WASM boundaries. */ +class nec_geometry_exception : public nec_exception +{ +public: + using nec_exception::nec_exception; +}; + #ifdef _MSC_VER /* Visual C++ does not allow macros with variable argument lists. Therefore error messages diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index ef3e905c..f8813c8e 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -35,6 +35,15 @@ void fail(const char* operation, const char* reason) throw error; } +void fail_geometry(const char* operation, const char* reason) +{ + nec_geometry_exception error("STATEFUL MODEL "); + error.append(operation); + error.append(": "); + error.append(reason); + throw error; +} + bool finite_value(nec_float value) { return std::isfinite(value); @@ -212,7 +221,7 @@ const nec_geometry_completion_result& nec_stateful_model::complete_geometry( if (connection != nec_ground_connection::none && symmetry.kind == nec_geometry_symmetry_kind::reflection && (symmetry.reflection_plane_mask & nec_reflection_plane_z) != 0u) - fail( + fail_geometry( "COMPLETE GEOMETRY", "Z=0 STRUCTURAL REFLECTION IS INCOMPATIBLE WITH A GROUND CONNECTION"); @@ -338,7 +347,7 @@ void nec_stateful_model::validate_symmetry_ground( nec_geometry_symmetry_kind::reflection && (m_geometry_completion.symmetry.reflection_plane_mask & nec_reflection_plane_z) != 0u) - fail( + fail_geometry( operation, "Z=0 STRUCTURAL REFLECTION IS INCOMPATIBLE WITH GROUND"); } @@ -356,10 +365,10 @@ void nec_stateful_model::validate_symmetric_load_orbits() const if (fundamental_count <= 0 || full_count <= 0 || full_count % m_geometry_completion.section_count != 0 || full_count / m_geometry_completion.section_count != fundamental_count) - fail("PREPARE", "SYMMETRY COMPLETION METADATA IS INCONSISTENT"); + fail_geometry("PREPARE", "SYMMETRY COMPLETION METADATA IS INCONSISTENT"); if (static_cast(full_count) > static_cast(std::vector().max_size())) - fail("PREPARE", "SYMMETRY LOAD VALIDATION SIZE IS TOO LARGE"); + fail_geometry("PREPARE", "SYMMETRY LOAD VALIDATION SIZE IS TOO LARGE"); std::vector> loads_by_segment( static_cast(full_count)); @@ -401,7 +410,7 @@ void nec_stateful_model::validate_symmetric_load_orbits() const const size_t generated = static_cast( fundamental + static_cast(copy) * fundamental_count); if (loads_by_segment[generated] != expected) - fail("PREPARE", "INCOMPLETE OR UNEQUAL SYMMETRY LOAD ORBIT"); + fail_geometry("PREPARE", "INCOMPLETE OR UNEQUAL SYMMETRY LOAD ORBIT"); } } } diff --git a/src/necpp_wasm_v1.cpp b/src/necpp_wasm_v1.cpp index 6c68d0cd..6f99a8bd 100644 --- a/src/necpp_wasm_v1.cpp +++ b/src/necpp_wasm_v1.cpp @@ -139,6 +139,8 @@ struct embedded_buffers : far_field_buffers { struct necpp_wasm_v1_model { nec_stateful_model native; + nec_geometry_completion_result geometry_completion; + bool geometry_completion_available = false; int32_t last_status = NECPP_WASM_V1_OK; std::string last_error; std::vector port_tags; @@ -219,6 +221,14 @@ int32_t invoke( return NECPP_WASM_V1_OK; } catch (const std::bad_alloc& error) { return set_error(model, NECPP_WASM_V1_RUNTIME_ERROR, error.what()); + } catch (const nec_geometry_exception& error) { + try { + return set_error( + model, NECPP_WASM_V1_GEOMETRY_ERROR, error.get_message().c_str()); + } catch (...) { + return set_error(model, NECPP_WASM_V1_GEOMETRY_ERROR, + "NEC geometry exception"); + } } catch (const nec_exception& error) { try { return set_error(model, failure_status, error.get_message().c_str()); @@ -636,6 +646,53 @@ int32_t necpp_wasm_v1_complete_geometry( return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { model->native.complete_geometry( static_cast(ground_connection)); + model->geometry_completion = model->native.geometry_completion(); + model->geometry_completion_available = true; + clear_calculated_results(*model); + }); +} + +int32_t necpp_wasm_v1_complete_geometry_symmetric( + necpp_wasm_v1_model* model, + int32_t ground_connection, + int32_t symmetry_kind, + int32_t parameter, + int32_t tag_increment) +{ + if (model == nullptr) + return NECPP_WASM_V1_RUNTIME_ERROR; + if (!is_state(model, nec_model_state::geometry_building)) + return fail(model, NECPP_WASM_V1_STATE_ERROR, + "completeGeometry requires geometry-building state"); + if (ground_connection < NECPP_WASM_V1_GROUND_CONNECTION_NONE || + ground_connection > NECPP_WASM_V1_GROUND_CONNECTION_ZERO_CURRENT) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Unknown ground connection"); + const int32_t valid_planes = + NECPP_WASM_V1_REFLECTION_PLANE_X | + NECPP_WASM_V1_REFLECTION_PLANE_Y | + NECPP_WASM_V1_REFLECTION_PLANE_Z; + if (tag_increment <= 0 || + (symmetry_kind == NECPP_WASM_V1_SYMMETRY_REFLECTION && + (parameter <= 0 || (parameter & ~valid_planes) != 0)) || + (symmetry_kind == NECPP_WASM_V1_SYMMETRY_ROTATIONAL && parameter < 2) || + (symmetry_kind != NECPP_WASM_V1_SYMMETRY_REFLECTION && + symmetry_kind != NECPP_WASM_V1_SYMMETRY_ROTATIONAL)) + return fail(model, NECPP_WASM_V1_INPUT_ERROR, + "Invalid geometry symmetry descriptor"); + + nec_geometry_symmetry symmetry; + symmetry.kind = static_cast(symmetry_kind); + symmetry.tag_increment = tag_increment; + if (symmetry.kind == nec_geometry_symmetry_kind::reflection) + symmetry.reflection_plane_mask = static_cast(parameter); + else + symmetry.rotational_order = parameter; + + return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { + model->geometry_completion = model->native.complete_geometry( + symmetry, static_cast(ground_connection)); + model->geometry_completion_available = true; clear_calculated_results(*model); }); } @@ -965,6 +1022,34 @@ const int32_t* necpp_wasm_v1_port_segments(const necpp_wasm_v1_model* model) ? nullptr : model->port_segments.data(); } +int32_t necpp_wasm_v1_geometry_symmetry_kind( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->geometry_completion_available + ? static_cast(model->geometry_completion.symmetry.kind) : -1; +} + +int32_t necpp_wasm_v1_geometry_section_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->geometry_completion_available + ? model->geometry_completion.section_count : 0; +} + +int64_t necpp_wasm_v1_geometry_fundamental_segment_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->geometry_completion_available + ? model->geometry_completion.fundamental_segment_count : 0; +} + +int64_t necpp_wasm_v1_geometry_full_segment_count( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->geometry_completion_available + ? model->geometry_completion.full_segment_count : 0; +} + size_t necpp_wasm_v1_impedance_order(const necpp_wasm_v1_model* model) { return model != nullptr && model->impedance.available diff --git a/src/necpp_wasm_v1.h b/src/necpp_wasm_v1.h index b9b1ed0d..bf86ebeb 100644 --- a/src/necpp_wasm_v1.h +++ b/src/necpp_wasm_v1.h @@ -43,6 +43,22 @@ enum necpp_wasm_v1_ground_connection { NECPP_WASM_V1_GROUND_CONNECTION_ZERO_CURRENT = 2 }; +/* + * Geometry-symmetry values are additive ABI v1 symbols. Existing v1 + * function signatures, status values, and enum values remain unchanged. + */ +enum necpp_wasm_v1_symmetry_kind { + NECPP_WASM_V1_SYMMETRY_NONE = 0, + NECPP_WASM_V1_SYMMETRY_REFLECTION = 1, + NECPP_WASM_V1_SYMMETRY_ROTATIONAL = 2 +}; + +enum necpp_wasm_v1_reflection_plane { + NECPP_WASM_V1_REFLECTION_PLANE_X = 1, + NECPP_WASM_V1_REFLECTION_PLANE_Y = 2, + NECPP_WASM_V1_REFLECTION_PLANE_Z = 4 +}; + enum necpp_wasm_v1_load_kind { NECPP_WASM_V1_LOAD_SERIES_RLC = 0, NECPP_WASM_V1_LOAD_PARALLEL_RLC = 1, @@ -119,6 +135,16 @@ int32_t necpp_wasm_v1_add_wire( double radius_m); int32_t necpp_wasm_v1_complete_geometry( necpp_wasm_v1_model* model, int32_t ground_connection); +/* + * Complete a fundamental section with one final symmetry operation. + * parameter is a reflection-plane bit mask or the total rotational order. + */ +int32_t necpp_wasm_v1_complete_geometry_symmetric( + necpp_wasm_v1_model* model, + int32_t ground_connection, + int32_t symmetry_kind, + int32_t parameter, + int32_t tag_increment); int32_t necpp_wasm_v1_define_ports( necpp_wasm_v1_model* model, const int32_t* tags, const int32_t* segments, size_t count); @@ -155,6 +181,19 @@ size_t necpp_wasm_v1_port_count(const necpp_wasm_v1_model* model); const int32_t* necpp_wasm_v1_port_tags(const necpp_wasm_v1_model* model); const int32_t* necpp_wasm_v1_port_segments(const necpp_wasm_v1_model* model); +/* + * Scalar completion metadata has no borrowed-pointer lifetime. Before a + * successful completion, kind is -1 and all other values are zero. + */ +int32_t necpp_wasm_v1_geometry_symmetry_kind( + const necpp_wasm_v1_model* model); +int32_t necpp_wasm_v1_geometry_section_count( + const necpp_wasm_v1_model* model); +int64_t necpp_wasm_v1_geometry_fundamental_segment_count( + const necpp_wasm_v1_model* model); +int64_t necpp_wasm_v1_geometry_full_segment_count( + const necpp_wasm_v1_model* model); + size_t necpp_wasm_v1_impedance_order(const necpp_wasm_v1_model* model); double necpp_wasm_v1_impedance_frequency_mhz( const necpp_wasm_v1_model* model); diff --git a/src/necpp_wasm_v1_c_tb.c b/src/necpp_wasm_v1_c_tb.c index 505c0665..e31d64ea 100644 --- a/src/necpp_wasm_v1_c_tb.c +++ b/src/necpp_wasm_v1_c_tb.c @@ -20,6 +20,123 @@ static int buffer_is_finite( return 0; } +static int check_symmetric_completion( + int32_t symmetry_kind, int32_t parameter, + int32_t expected_sections, int64_t expected_segments, + double x, double y) +{ + necpp_wasm_v1_model* model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_geometry_symmetry_kind(model) == -1); + CHECK(necpp_wasm_v1_geometry_section_count(model) == 0); + CHECK(necpp_wasm_v1_geometry_fundamental_segment_count(model) == 0); + CHECK(necpp_wasm_v1_geometry_full_segment_count(model) == 0); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, x, y, 0.1, x, y, 0.4, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE, + symmetry_kind, parameter, 1) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_geometry_symmetry_kind(model) == symmetry_kind); + CHECK(necpp_wasm_v1_geometry_section_count(model) == expected_sections); + CHECK(necpp_wasm_v1_geometry_fundamental_segment_count(model) == 11); + CHECK(necpp_wasm_v1_geometry_full_segment_count(model) == expected_segments); + necpp_wasm_v1_model_delete(model); + return 0; +} + +int necpp_wasm_v1_run_c_symmetry_contract_test(void) +{ + static const int32_t tags[2] = {1, 2}; + static const int32_t segments[2] = {6, 6}; + necpp_wasm_v1_model* model; + + CHECK(NECPP_WASM_V1_SYMMETRY_NONE == 0); + CHECK(NECPP_WASM_V1_SYMMETRY_REFLECTION == 1); + CHECK(NECPP_WASM_V1_SYMMETRY_ROTATIONAL == 2); + CHECK(NECPP_WASM_V1_REFLECTION_PLANE_X == 1); + CHECK(NECPP_WASM_V1_REFLECTION_PLANE_Y == 2); + CHECK(NECPP_WASM_V1_REFLECTION_PLANE_Z == 4); + CHECK(necpp_wasm_v1_geometry_symmetry_kind(NULL) == -1); + CHECK(necpp_wasm_v1_geometry_section_count(NULL) == 0); + CHECK(necpp_wasm_v1_geometry_fundamental_segment_count(NULL) == 0); + CHECK(necpp_wasm_v1_geometry_full_segment_count(NULL) == 0); + + CHECK(check_symmetric_completion( + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_X | + NECPP_WASM_V1_REFLECTION_PLANE_Y, + 4, 44, 0.25, 0.25) == 0); + CHECK(check_symmetric_completion( + NECPP_WASM_V1_SYMMETRY_ROTATIONAL, + 4, 4, 44, 0.25, 0.0) == 0); + + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE, + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_X, 1) == + NECPP_WASM_V1_STATE_ERROR); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0.25, 0.0, 0.1, 0.25, 0.0, 0.4, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE, + 99, 4, 1) == NECPP_WASM_V1_INPUT_ERROR); + CHECK(necpp_wasm_v1_model_state(model) == + NECPP_WASM_V1_STATE_GEOMETRY_BUILDING); + necpp_wasm_v1_model_delete(model); + + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0.0, 0.25, 0.1, 0.0, 0.25, 0.4, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE, + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_X, 1) == + NECPP_WASM_V1_GEOMETRY_ERROR); + CHECK(necpp_wasm_v1_geometry_section_count(model) == 0); + CHECK(necpp_wasm_v1_model_state(model) == + NECPP_WASM_V1_STATE_GEOMETRY_BUILDING); + necpp_wasm_v1_model_delete(model); + + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0.25, 0.25, 0.1, 0.25, 0.25, 0.4, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_NONE, + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_X, 1) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_define_ports(model, tags, segments, 2) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_add_load( + model, NECPP_WASM_V1_LOAD_IMPEDANCE, + 1, 6, 6, 10.0, 0.0, 0.0) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_prepare(model, 300.0) == + NECPP_WASM_V1_GEOMETRY_ERROR); + CHECK(necpp_wasm_v1_impedance_order(model) == 0); + necpp_wasm_v1_model_delete(model); + + model = necpp_wasm_v1_model_create(); + CHECK(model != NULL); + CHECK(necpp_wasm_v1_add_wire( + model, 1, 11, 0.25, 0.25, 0.1, 0.25, 0.25, 0.4, 0.001) == + NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_complete_geometry_symmetric( + model, NECPP_WASM_V1_GROUND_CONNECTION_INTERPOLATE, + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_Z, 1) == + NECPP_WASM_V1_GEOMETRY_ERROR); + necpp_wasm_v1_model_delete(model); + + return 0; +} + int necpp_wasm_v1_run_c_contract_test(void) { static const int32_t tags[2] = {1, 2}; @@ -106,6 +223,11 @@ int necpp_wasm_v1_run_c_contract_test(void) NECPP_WASM_V1_INPUT_ERROR); CHECK(necpp_wasm_v1_complete_geometry( model, NECPP_WASM_V1_GROUND_CONNECTION_NONE) == NECPP_WASM_V1_OK); + CHECK(necpp_wasm_v1_geometry_symmetry_kind(model) == + NECPP_WASM_V1_SYMMETRY_NONE); + CHECK(necpp_wasm_v1_geometry_section_count(model) == 1); + CHECK(necpp_wasm_v1_geometry_fundamental_segment_count(model) == 22); + CHECK(necpp_wasm_v1_geometry_full_segment_count(model) == 22); CHECK(necpp_wasm_v1_model_state(model) == NECPP_WASM_V1_STATE_GEOMETRY_COMPLETE); CHECK(necpp_wasm_v1_add_wire( diff --git a/src/necpp_wasm_v1_tb.cpp b/src/necpp_wasm_v1_tb.cpp index 004b477e..e3707abc 100644 --- a/src/necpp_wasm_v1_tb.cpp +++ b/src/necpp_wasm_v1_tb.cpp @@ -8,6 +8,7 @@ #include extern "C" int necpp_wasm_v1_run_c_contract_test(void); +extern "C" int necpp_wasm_v1_run_c_symmetry_contract_test(void); TEST_CASE("WP4 versioned ABI is consumable from C", "[wp4][wasm_abi]") { @@ -17,6 +18,15 @@ TEST_CASE("WP4 versioned ABI is consumable from C", "[wp4][wasm_abi]") REQUIRE(failed_line == 0); } +TEST_CASE("WP-S3 additive symmetry ABI is consumable from pure C", + "[wp_s3][wasm_abi][symmetry]") +{ + const int failed_line = necpp_wasm_v1_run_c_symmetry_contract_test(); + INFO("C symmetry ABI contract check failed at necpp_wasm_v1_c_tb.c line " + << failed_line); + REQUIRE(failed_line == 0); +} + namespace { void build_one_port_model(nec_stateful_model& model) @@ -57,6 +67,44 @@ void require_complex_buffer_matches( } // namespace +TEST_CASE("WP-S3 ABI completion metadata matches the stateful model", + "[wp_s3][wasm_abi][symmetry]") +{ + nec_geometry_symmetry symmetry; + symmetry.kind = nec_geometry_symmetry_kind::reflection; + symmetry.reflection_plane_mask = + nec_reflection_plane_x | nec_reflection_plane_y; + symmetry.tag_increment = 1; + + nec_stateful_model native; + native.add_wire({1, 11, 0.25, 0.25, 0.1, 0.25, 0.25, 0.4, 0.001}); + const nec_geometry_completion_result& expected = + native.complete_geometry(symmetry); + + std::unique_ptr + abi(necpp_wasm_v1_model_create(), &necpp_wasm_v1_model_delete); + REQUIRE(abi != nullptr); + REQUIRE(necpp_wasm_v1_add_wire( + abi.get(), 1, 11, + 0.25, 0.25, 0.1, + 0.25, 0.25, 0.4, + 0.001) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_complete_geometry_symmetric( + abi.get(), NECPP_WASM_V1_GROUND_CONNECTION_NONE, + NECPP_WASM_V1_SYMMETRY_REFLECTION, + NECPP_WASM_V1_REFLECTION_PLANE_X | + NECPP_WASM_V1_REFLECTION_PLANE_Y, + 1) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_geometry_symmetry_kind(abi.get()) == + static_cast(expected.symmetry.kind)); + REQUIRE(necpp_wasm_v1_geometry_section_count(abi.get()) == + expected.section_count); + REQUIRE(necpp_wasm_v1_geometry_fundamental_segment_count(abi.get()) == + expected.fundamental_segment_count); + REQUIRE(necpp_wasm_v1_geometry_full_segment_count(abi.get()) == + expected.full_segment_count); +} + TEST_CASE("WP4 bulk ABI buffers reproduce native results", "[wp4][wasm_abi][numerical_contract]") { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b24ab831..fddfbf76 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -101,7 +101,7 @@ endif() # stress suite on independent timeout budgets. Otherwise a slow numerical # fixture can consume the aggregate timeout just before the bounded WP1 loop. add_test(NAME necpp_unit - COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]~[wp4]~[wp_s2]") + COMMAND nec2++_tests "~[wp1]~[wp2]~[wp3]~[wp4]~[wp_s2]~[wp_s3]") add_test(NAME necpp_wp1 COMMAND nec2++_tests "[wp1]") add_test(NAME necpp_wp2 @@ -112,6 +112,8 @@ add_test(NAME necpp_wp4 COMMAND nec2++_tests "[wp4]") add_test(NAME necpp_wp_s2 COMMAND nec2++_tests "[wp_s2]") +add_test(NAME necpp_wp_s3 + COMMAND nec2++_tests "[wp_s3]") # Hard cap so a hung test fails fast (and CTest surfaces its output) instead of # blocking the CI runner for its full timeout ceiling. WP1 deliberately adds a # 1,000-excitation retained-factorization stress case; it completes in a few @@ -122,6 +124,7 @@ set_tests_properties(necpp_wp2 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp3 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp4 PROPERTIES TIMEOUT 180) set_tests_properties(necpp_wp_s2 PROPERTIES TIMEOUT 240) +set_tests_properties(necpp_wp_s3 PROPERTIES TIMEOUT 180) # Smoke test: run a real simulation through the binary and check it produced # the expected output marker. From 3d154dd222e61c7de69e8ab4ddbdb0a15c14975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 16:38:06 +0200 Subject: [PATCH 40/46] feat: expose symmetry in TypeScript APIs --- docs/01_symmetry_support.md | 45 +++- docs/wasm-api.md | 16 +- packages/necpp-wasm/README.md | 69 ++++++ packages/necpp-wasm/src/model.ts | 160 ++++++++++-- packages/necpp-wasm/src/symmetry.ts | 227 +++++++++++++++++- packages/necpp-wasm/src/worker-client.ts | 8 +- packages/necpp-wasm/src/worker-protocol.ts | 130 ++++++++++ .../necpp-wasm/test/browser-integration.mjs | 21 +- .../necpp-wasm/test/facade-mapping.test.mjs | 195 ++++++++++++++- .../necpp-wasm/test/pack/consumer.test.mjs | 2 + packages/necpp-wasm/test/pack/helpers.mjs | 30 ++- .../necpp-wasm/test/worker-client.test.mjs | 42 ++++ .../test/worker-integration.test.mjs | 173 ++++++++++++- 13 files changed, 1071 insertions(+), 47 deletions(-) diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 46b9babb..ca737635 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1234,7 +1234,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S1 Native geometry safety and metadata | complete | Codex | Native `[wp_s1]`: 291 assertions; aggregate: 1,044; 52/52 legacy decks matched; npm: 38/38 | WP-S2 must call `c_geometry::generate_symmetry()` and consume its immutable result instead of reading geometry arrays. | | WP-S2 Stateful symmetry and validation | complete | Codex | Native `[wp_s2]`: 10,599 assertions; direct WP1-WP4 regressions and npm 38/38 | WP-S3 should call the symmetric overload, then expose only `geometry_completion()` metadata through additive ABI getters. | | WP-S3 Additive C/WASM ABI | complete | Codex | Native `[wp_s3]`: 8 assertions/2 cases plus pure-C contract; CTest 8/8; WASM smoke; npm 38/38; pack 5/5; browser 3/3 | WP-S4 should convert the private WASM `bigint` segment counts to checked safe numbers and derive copy transforms from the validated descriptor. | -| WP-S4 Direct and worker TypeScript API | not started | — | — | — | +| WP-S4 Direct and worker TypeScript API | complete | Codex | npm/WASM: 46/46; pack: 5/5; browser: 3/3; native ABI: 8 assertions | WP-S5 may use only the exported descriptors, immutable completion metadata, typed failure details, and direct/worker methods; private ABI `bigint` values never escape. | | WP-S5 Transparent symmetrizer | not started | — | — | — | | WP-S6 End-to-end equivalence suite | not started | — | — | — | | WP-S7 Benchmarks and performance gates | not started | — | — | — | @@ -1591,6 +1591,49 @@ DoD: Handoff focus: WP-S5 may depend only on public TypeScript types/methods, not the private WASM module. +Completion evidence (2026-08-30, Windows, Node 24.14.1, TypeScript 5.8.3, +Chromium through Playwright, and the WP-S3 Emscripten artifact): + +- `npm --prefix packages/necpp-wasm run test:wasm` passed 46/46 Node tests, + strict declaration/type tests, and 5/5 packed-consumer tests. The mapping + tests cover every reflection bit, both symmetry kinds, every native argument, + fixed Z/Y/X copy order, rotational angles, deep freezing, invalid/duplicate + descriptors, tag overflow, incompatible ground, and checked `bigint` segment + counts. +- The real-WASM R1 direct/worker test built the shared over-ground 4 x 4 model + from its positive quadrant. Completion metadata agreed exactly; gathered + complex Z and an asymmetrically excited complex far-field grid agreed within + `1e-12`. An incomplete load orbit retained `NEC_GEOMETRY` plus + `INCOMPLETE_LOAD_ORBIT` through worker serialization. +- Packed direct and worker Node consumers both completed a two-plane reflection + and observed four sections. Every npm README TypeScript example compiled + against emitted declarations, while the unchanged ordinary direct/worker + examples continued to run while ignoring completion metadata. +- `npm --prefix packages/necpp-wasm run test:browser -- direct`, `worker`, and + `example` passed against the tested tarball. Direct and worker browser modes + exercised two-plane reflection; existing progress, serialization, + termination, and cancellation tests remained deterministic. +- `build-wps1\tests\Release\nec2++_tests.exe "[wp_s3]" --reporter compact` + passed 8 assertions in 2 cases. A full CTest rerun was not required for this + TypeScript-only WP and the standalone `ctest` command was unavailable in the + active PowerShell PATH; WP-S3's recorded 8/8 CTest evidence remains the + native baseline. + +Contract decisions for WP-S5 and later: + +- Runtime validation occurs before the additive completion call. Reflection + plane-list order never changes native Z/Y/X copy order, and generated tag + arithmetic is checked with `bigint` before it can overflow signed 32-bit + tags. +- Native signed-64-bit segment counts are accepted only when they convert to + safe public JavaScript integers. Direct metadata and worker-revived metadata + are deeply frozen, structured-cloneable snapshots containing no native + pointers or private module values. +- Structural `z=0` reflection/ground conflicts report + `INCOMPATIBLE_GROUND`; prepare-time asymmetric loads report + `INCOMPLETE_LOAD_ORBIT`. Invalid descriptors report `INVALID_SYMMETRY` + without mutating geometry. + ### WP-S5 — Transparent symmetrizer Dependencies: WP-S4. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 2231acd8..846931b2 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -5,10 +5,11 @@ 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). -The WP-S0 symmetry names, metadata, lifecycle shape, and fixture mappings below -are finalized as an interface-first contract. Non-symmetric completion already -returns an empty `GeometryCompletionResult`; native symmetry execution and its -additive ABI entry point are staged for WP-S1 through WP-S4. +The symmetry names, metadata, lifecycle shape, and fixture mappings below are +implemented by the native engine, additive ABI, direct facade, and worker +facade. Non-symmetric completion returns an empty, immutable +`GeometryCompletionResult`; symmetric completion returns immutable copy and +segment-count metadata in both execution modes. ## Package and runtime boundary @@ -252,7 +253,7 @@ Allocation, cancellation, conditioning, solver, and invalid full-geometry failures are not representation-eligibility failures and are never hidden by a retry. -## Geometry symmetry contract (WP-S0) +## Geometry symmetry contract `CompleteGeometryOptions.symmetry` accepts exactly one of: @@ -295,6 +296,11 @@ At frequency `f`, it uses the NEC engine's speed-of-light constant, length height `lambda/4`, radius `lambda/1000`, 11 segments, and feed segment 6. The primary environment is perfect ground with geometry `groundConnection: "none"`. +The npm-rendered package README contains an executable manual 4 x 4 quadrant +example. Direct and worker callers pass the same descriptor; only the worker's +method call is awaited. Returned metadata is deeply frozen after direct +construction or worker structured-clone revival. + ## Worker facade `createNecWorkerModel()` is imported from `@necpp-engine/wasm/worker`. The package diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 4b553c36..9179c429 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -56,6 +56,75 @@ try { This complete example is executed from the packed npm tarball in CI. +## Manual symmetry from one quadrant + +When the geometry is known to be symmetric, build only its fundamental section +and make symmetry the final geometry operation. This 300 MHz reference model +creates a 4 x 4 array from the four positive-X/positive-Y dipoles: + +```ts +import { createNecModel } from "@necpp-engine/wasm"; + +const frequencyMHz = 300; +const epsilon0 = 8.854e-12; +const mu0 = 4 * Math.PI * 1e-7; +const wavelengthM = (1 / Math.sqrt(epsilon0 * mu0)) / (frequencyMHz * 1e6); +const side = 4; +const half = side / 2; +const fundamentalCount = half * half; +const model = await createNecModel(); + +try { + for (let y = half; y < side; y += 1) { + for (let x = half; x < side; x += 1) { + const tag = (y - half) * half + (x - half) + 1; + const xM = (x - (side - 1) / 2) * wavelengthM / 2; + const yM = (y - (side - 1) / 2) * wavelengthM / 2; + model.addWire({ + tag, + segments: 11, + start: [xM, yM, wavelengthM / 12], + end: [xM, yM, 5 * wavelengthM / 12], + radiusM: wavelengthM / 1000, + }); + } + } + + const completion = model.completeGeometry({ + groundConnection: "none", + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: fundamentalCount, + }, + }); + model.definePorts(Array.from( + { length: side * side }, + (_, index) => ({ tag: index + 1, segment: 6 }), + )); + model.setGround({ kind: "perfect" }); + model.prepare({ frequencyMHz }); + + console.log(completion.symmetry); + console.log(model.computeImpedanceMatrix().impedance); +} finally { + model.dispose(); +} +``` + +The four copy blocks are the fundamental section, Y reflection, X reflection, +then XY reflection. Their tag offsets are `0`, `4`, `8`, and `12`; this native +copy-major order is not XY row-major order. `completion.symmetry` reports the +section count, fundamental/full segment counts, transforms, and offsets. It is +deeply immutable and has the same shape when returned by the worker API. Use +`rotationalOrder(n)` for N-fold rotation about global Z. + +Plane reflection rejects wires that lie in or cross a generating plane. +Structural `z=0` reflection is incompatible with ground, while the vertical +planes used above remain valid over homogeneous horizontal ground. Geometry +cannot be added after symmetric completion, and structural loads must cover +complete symmetry orbits before `prepare()`. + ## Numerical conventions - Coordinates, wire radius, and field radius are metres. Public frequencies diff --git a/packages/necpp-wasm/src/model.ts b/packages/necpp-wasm/src/model.ts index 2efcb078..1170971c 100644 --- a/packages/necpp-wasm/src/model.ts +++ b/packages/necpp-wasm/src/model.ts @@ -8,6 +8,11 @@ import { NecStateError, } from "./errors.js"; import { transitionModelState, type ModelOperation } from "./state-machine.js"; +import { + createSymmetryExpansion, + validateGeometrySymmetry, + type ValidatedGeometrySymmetry, +} from "./symmetry.js"; import type { ComplexMatrix, ComplexVector, @@ -246,6 +251,25 @@ function snapshotPorts(ports: readonly PortDefinition[]): readonly PortDefinitio ))); } +function incompatibleGroundError(message: string): never { + throw new NecGeometryError(message, { + details: { symmetryFailure: "INCOMPATIBLE_GROUND" }, + }); +} + +function checkedNativeSegmentCount(value: bigint, name: string): number { + if ( + typeof value !== "bigint" + || value < 0n + || value > BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new NecRuntimeError(`Native ${name} is outside the safe integer range`, { + details: { name, value }, + }); + } + return Number(value); +} + function nativeState(value: number): Exclude { switch (value) { case 0: @@ -268,6 +292,8 @@ export class WasmNecModel implements NecModel { #handle: number; #state: NecModelState = "empty"; #ports: readonly PortDefinition[] = Object.freeze([]); + #maximumWireTag = 0; + #geometrySymmetry: ValidatedGeometrySymmetry | undefined; constructor(module: NecWasmModule, handle: number) { this.#moduleStorage = module; @@ -352,9 +378,15 @@ export class WasmNecModel implements NecModel { } } - #statusError(status: number, operation: ModelOperation): never { + #statusError( + status: number, + operation: ModelOperation, + symmetryFailure?: "INCOMPATIBLE_GROUND" | "INCOMPLETE_LOAD_ORBIT", + ): never { const message = this.#lastError() || `${operation} failed with native status ${status}`; - const details = { operation, nativeStatus: status }; + const details = symmetryFailure === undefined || status !== STATUS_GEOMETRY + ? { operation, nativeStatus: status } + : { operation, nativeStatus: status, symmetryFailure }; switch (status) { case STATUS_STATE: throw new NecStateError(operation, this.#state, message); @@ -378,7 +410,11 @@ export class WasmNecModel implements NecModel { } } - #invokeStatus(operation: ModelOperation, call: () => number): void { + #invokeStatus( + operation: ModelOperation, + call: () => number, + symmetryFailure?: "INCOMPATIBLE_GROUND" | "INCOMPLETE_LOAD_ORBIT", + ): void { let status: number; try { status = call(); @@ -398,10 +434,70 @@ export class WasmNecModel implements NecModel { }); } if (status !== STATUS_OK) { - this.#statusError(status, operation); + this.#statusError(status, operation, symmetryFailure); } } + #completionResult( + symmetry: ValidatedGeometrySymmetry | undefined, + ): GeometryCompletionResult { + return this.#readResult("completeGeometry", () => { + const nativeKind = this.#module._necpp_wasm_v1_geometry_symmetry_kind( + this.#handle, + ); + const sectionCount = this.#module._necpp_wasm_v1_geometry_section_count( + this.#handle, + ); + const fundamentalRaw = + this.#module._necpp_wasm_v1_geometry_fundamental_segment_count( + this.#handle, + ); + const fullRaw = this.#module._necpp_wasm_v1_geometry_full_segment_count( + this.#handle, + ); + const fundamentalSegmentCount = checkedNativeSegmentCount( + fundamentalRaw, + "fundamental segment count", + ); + const fullSegmentCount = checkedNativeSegmentCount( + fullRaw, + "full segment count", + ); + const expectedKind = symmetry?.nativeKind ?? 0; + const expectedSections = symmetry?.sectionCount ?? 1; + if ( + nativeKind !== expectedKind + || sectionCount !== expectedSections + || !Number.isSafeInteger(sectionCount) + || fullRaw !== fundamentalRaw * BigInt(sectionCount) + ) { + throw new NecRuntimeError( + "Native geometry completion metadata is inconsistent", + { + details: { + nativeKind, + expectedKind, + sectionCount, + expectedSections, + fundamentalSegmentCount, + fullSegmentCount, + }, + }, + ); + } + if (symmetry === undefined) { + return Object.freeze({}); + } + return Object.freeze({ + symmetry: createSymmetryExpansion( + symmetry, + fundamentalSegmentCount, + fullSegmentCount, + ), + }); + }); + } + #readResult(operation: ModelOperation, read: () => T): T { try { return read(); @@ -548,6 +644,7 @@ export class WasmNecModel implements NecModel { end[2], radiusM, )); + this.#maximumWireTag = Math.max(this.#maximumWireTag, tag); } completeGeometry( @@ -555,12 +652,6 @@ export class WasmNecModel implements NecModel { ): GeometryCompletionResult { this.#assertOperation("completeGeometry"); const record = requireRecord(options, "options"); - if (record.symmetry !== undefined) { - throw new NecRuntimeError( - "Symmetric geometry completion is reserved by the WP-S0 contract but is not implemented by this runtime yet", - { details: { operation: "completeGeometry", symmetry: record.symmetry } }, - ); - } const connection = record.groundConnection ?? "none"; const nativeConnection = connection === "none" ? 0 @@ -569,14 +660,37 @@ export class WasmNecModel implements NecModel { : connection === "zero-current" ? 2 : inputError("Unknown ground connection", { connection }); - this.#invokeStatus( - "completeGeometry", - () => this.#module._necpp_wasm_v1_complete_geometry( - this.#handle, - nativeConnection, - ), - ); - return Object.freeze({}); + const symmetry = record.symmetry === undefined + ? undefined + : validateGeometrySymmetry(record.symmetry, this.#maximumWireTag); + if (symmetry?.reflectsZ === true && nativeConnection !== 0) { + incompatibleGroundError( + "Structural reflection through z=0 is incompatible with ground connection", + ); + } + if (symmetry === undefined) { + this.#invokeStatus( + "completeGeometry", + () => this.#module._necpp_wasm_v1_complete_geometry( + this.#handle, + nativeConnection, + ), + ); + } else { + this.#invokeStatus( + "completeGeometry", + () => this.#module._necpp_wasm_v1_complete_geometry_symmetric( + this.#handle, + nativeConnection, + symmetry.nativeKind, + symmetry.parameter, + symmetry.tagIncrement, + ), + ); + } + const result = this.#completionResult(symmetry); + this.#geometrySymmetry = symmetry; + return result; } definePorts(ports: readonly PortDefinition[]): void { @@ -714,12 +828,17 @@ export class WasmNecModel implements NecModel { default: return inputError("Unknown ground kind", { kind: record.kind }); } + if (kind !== 0 && this.#geometrySymmetry?.reflectsZ === true) { + incompatibleGroundError( + "Structural reflection through z=0 is incompatible with ground", + ); + } this.#invokeStatus("setGround", () => this.#module._necpp_wasm_v1_set_ground( this.#handle, kind, relativePermittivity, conductivitySPerM, - )); + ), this.#geometrySymmetry === undefined ? undefined : "INCOMPATIBLE_GROUND"); } prepare(options: PrepareOptions): void { @@ -732,6 +851,7 @@ export class WasmNecModel implements NecModel { this.#invokeStatus( "prepare", () => this.#module._necpp_wasm_v1_prepare(this.#handle, frequencyMHz), + this.#geometrySymmetry === undefined ? undefined : "INCOMPLETE_LOAD_ORBIT", ); } @@ -997,6 +1117,8 @@ export class WasmNecModel implements NecModel { this.#moduleStorage = undefined; this.#state = "disposed"; this.#ports = Object.freeze([]); + this.#geometrySymmetry = undefined; + this.#maximumWireTag = 0; try { module?._necpp_wasm_v1_model_delete(handle); } catch { diff --git a/packages/necpp-wasm/src/symmetry.ts b/packages/necpp-wasm/src/symmetry.ts index 38084ce3..741ce00d 100644 --- a/packages/necpp-wasm/src/symmetry.ts +++ b/packages/necpp-wasm/src/symmetry.ts @@ -1,8 +1,233 @@ import { NecInputError } from "./errors.js"; -import type { RotationalOrder } from "./types.js"; +import type { + GeometrySymmetry, + ReflectionPlane, + RotationalOrder, + SymmetryCopy, + SymmetryExpansion, +} from "./types.js"; const INT32_MAX = 2_147_483_647; +const PLANE_BITS: Readonly> = Object.freeze({ + "x=0": 1, + "y=0": 2, + "z=0": 4, +}); + +export interface ValidatedGeometrySymmetry { + readonly kind: GeometrySymmetry["kind"]; + readonly nativeKind: 1 | 2; + readonly parameter: number; + readonly tagIncrement: number; + readonly sectionCount: number; + readonly reflectsZ: boolean; +} + +function invalidSymmetry( + message: string, + details: Readonly> = {}, +): never { + throw new NecInputError(message, { + details: { ...details, symmetryFailure: "INVALID_SYMMETRY" }, + }); +} + +function symmetryRecord(value: unknown): Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalidSymmetry("Geometry symmetry must be an object"); + } + return value as Readonly>; +} + +function positiveInt32(value: unknown, name: string): number { + if ( + typeof value !== "number" + || !Number.isSafeInteger(value) + || value < 1 + || value > INT32_MAX + ) { + invalidSymmetry(`${name} must be an integer from 1 through ${INT32_MAX}`, { + name, + value, + }); + } + return value; +} + +function freezeSigns( + signs: [1 | -1, 1 | -1, 1 | -1], +): readonly [1 | -1, 1 | -1, 1 | -1] { + return Object.freeze(signs); +} + +function reflectionCopies( + planes: ReadonlySet, + tagIncrement: number, +): readonly SymmetryCopy[] { + const signs: Array = [ + freezeSigns([1, 1, 1]), + ]; + for (const [plane, coordinate] of [ + ["z=0", 2], + ["y=0", 1], + ["x=0", 0], + ] as const) { + if (!planes.has(plane)) { + continue; + } + const existingCount = signs.length; + for (let index = 0; index < existingCount; index += 1) { + const reflected = [...signs[index]!] as [1 | -1, 1 | -1, 1 | -1]; + reflected[coordinate] = reflected[coordinate] === 1 ? -1 : 1; + signs.push(freezeSigns(reflected)); + } + } + return Object.freeze(signs.map((copySigns, index) => Object.freeze({ + index, + tagOffset: index * tagIncrement, + transform: Object.freeze({ + kind: "cartesian-signs" as const, + signs: copySigns, + }), + }))); +} + +function rotationalCopies( + order: number, + tagIncrement: number, +): readonly SymmetryCopy[] { + return Object.freeze(Array.from({ length: order }, (_, index) => Object.freeze({ + index, + tagOffset: index * tagIncrement, + transform: Object.freeze({ + kind: "rotate-z" as const, + angleDeg: index * 360 / order, + }), + }))); +} + +function validateTagRange( + maximumFundamentalTag: number, + sectionCount: number, + tagIncrement: number, +): void { + const maximumGeneratedTag = BigInt(maximumFundamentalTag) + + BigInt(sectionCount - 1) * BigInt(tagIncrement); + if (maximumGeneratedTag > BigInt(INT32_MAX)) { + invalidSymmetry("Symmetry-generated tags exceed the signed 32-bit range", { + maximumFundamentalTag, + sectionCount, + tagIncrement, + }); + } +} + +/** Validate a public descriptor and derive its exact additive-ABI arguments. */ +export function validateGeometrySymmetry( + value: unknown, + maximumFundamentalTag: number, +): ValidatedGeometrySymmetry { + const record = symmetryRecord(value); + const tagIncrement = positiveInt32( + record.tagIncrement, + "symmetry.tagIncrement", + ); + + if (record.kind === "reflection") { + if (record.axis !== undefined || record.order !== undefined) { + invalidSymmetry("Reflection symmetry cannot contain rotation fields"); + } + if (!Array.isArray(record.planes) || record.planes.length === 0) { + invalidSymmetry("Reflection symmetry requires at least one plane"); + } + if (record.planes.length > 3) { + invalidSymmetry("Reflection symmetry contains too many planes"); + } + const planes = new Set(); + let mask = 0; + for (let index = 0; index < record.planes.length; index += 1) { + const plane: unknown = record.planes[index]; + if (plane !== "x=0" && plane !== "y=0" && plane !== "z=0") { + invalidSymmetry("Unknown coordinate reflection plane", { index, plane }); + } + if (planes.has(plane)) { + invalidSymmetry("Reflection planes must not contain duplicates", { + plane, + }); + } + planes.add(plane); + mask |= PLANE_BITS[plane]; + } + const sectionCount = 2 ** planes.size; + validateTagRange(maximumFundamentalTag, sectionCount, tagIncrement); + return Object.freeze({ + kind: "reflection", + nativeKind: 1, + parameter: mask, + tagIncrement, + sectionCount, + reflectsZ: planes.has("z=0"), + }); + } + + if (record.kind === "rotational") { + if (record.planes !== undefined) { + invalidSymmetry("Rotational symmetry cannot contain reflection fields"); + } + if (record.axis !== "z") { + invalidSymmetry("Rotational symmetry supports only the global Z axis", { + axis: record.axis, + }); + } + const order = record.order; + if ( + typeof order !== "number" + || !Number.isSafeInteger(order) + || order < 2 + || order > INT32_MAX + ) { + invalidSymmetry( + `symmetry.order must be an integer from 2 through ${INT32_MAX}`, + { order }, + ); + } + validateTagRange(maximumFundamentalTag, order, tagIncrement); + return Object.freeze({ + kind: "rotational", + nativeKind: 2, + parameter: order, + tagIncrement, + sectionCount: order, + reflectsZ: false, + }); + } + + return invalidSymmetry("Unknown geometry symmetry kind", { kind: record.kind }); +} + +/** Create the deeply immutable public metadata snapshot for a completed model. */ +export function createSymmetryExpansion( + symmetry: ValidatedGeometrySymmetry, + fundamentalSegmentCount: number, + fullSegmentCount: number, +): SymmetryExpansion { + const copies = symmetry.kind === "reflection" + ? reflectionCopies(new Set( + (["x=0", "y=0", "z=0"] as const).filter( + (plane) => (symmetry.parameter & PLANE_BITS[plane]) !== 0, + ), + ), symmetry.tagIncrement) + : rotationalCopies(symmetry.sectionCount, symmetry.tagIncrement); + return Object.freeze({ + kind: symmetry.kind, + sectionCount: symmetry.sectionCount, + fundamentalSegmentCount, + fullSegmentCount, + copies, + }); +} + /** * Validate and brand an N-fold rotational section count for geometry symmetry. * The native ABI represents the value as a signed 32-bit integer. diff --git a/packages/necpp-wasm/src/worker-client.ts b/packages/necpp-wasm/src/worker-client.ts index c19a11e0..5e19f282 100644 --- a/packages/necpp-wasm/src/worker-client.ts +++ b/packages/necpp-wasm/src/worker-client.ts @@ -26,6 +26,7 @@ import { reviveEmbeddedFarFieldResult, reviveError, reviveFarFieldResult, + reviveGeometryCompletionResult, reviveImpedanceResult, revivePortSolution, serializeCreateOptions, @@ -206,12 +207,7 @@ class WorkerNecModel implements NecWorkerModel { "completeGeometry", options === undefined ? [] : [options], ); - if (typeof result !== "object" || result === null) { - throw new NecRuntimeError( - "Worker geometry completion result is not an object", - ); - } - return result as GeometryCompletionResult; + return reviveGeometryCompletionResult(result); } definePorts(ports: readonly PortDefinition[]): Promise { diff --git a/packages/necpp-wasm/src/worker-protocol.ts b/packages/necpp-wasm/src/worker-protocol.ts index 457ea5db..dda18512 100644 --- a/packages/necpp-wasm/src/worker-protocol.ts +++ b/packages/necpp-wasm/src/worker-protocol.ts @@ -15,6 +15,7 @@ import type { CreateNecWorkerModelOptions, EmbeddedFarFieldResult, FarFieldResult, + GeometryCompletionResult, ImpedanceResult, NecModelState, NecWorkerOperation, @@ -227,6 +228,135 @@ export function snapshotPorts( ))); } +function safeNonnegativeInteger(value: unknown, name: string): number { + if ( + typeof value !== "number" + || !Number.isSafeInteger(value) + || value < 0 + ) { + throw new NecRuntimeError(`Worker result ${name} is not a safe integer`); + } + return value; +} + +/** Validate, copy, and deeply freeze structured-cloned completion metadata. */ +export function reviveGeometryCompletionResult( + value: unknown, +): GeometryCompletionResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NecRuntimeError("Worker geometry completion result is not an object"); + } + const record = value as { readonly symmetry?: unknown }; + if (record.symmetry === undefined) { + return Object.freeze({}); + } + if ( + typeof record.symmetry !== "object" + || record.symmetry === null + || Array.isArray(record.symmetry) + ) { + throw new NecRuntimeError("Worker symmetry metadata is not an object"); + } + const symmetry = record.symmetry as { + readonly kind?: unknown; + readonly sectionCount?: unknown; + readonly fundamentalSegmentCount?: unknown; + readonly fullSegmentCount?: unknown; + readonly copies?: unknown; + }; + if (symmetry.kind !== "reflection" && symmetry.kind !== "rotational") { + throw new NecRuntimeError("Worker symmetry metadata has an unknown kind"); + } + const sectionCount = safeNonnegativeInteger( + symmetry.sectionCount, + "symmetry.sectionCount", + ); + const fundamentalSegmentCount = safeNonnegativeInteger( + symmetry.fundamentalSegmentCount, + "symmetry.fundamentalSegmentCount", + ); + const fullSegmentCount = safeNonnegativeInteger( + symmetry.fullSegmentCount, + "symmetry.fullSegmentCount", + ); + if ( + sectionCount < 2 + || BigInt(fullSegmentCount) + !== BigInt(fundamentalSegmentCount) * BigInt(sectionCount) + || !Array.isArray(symmetry.copies) + || symmetry.copies.length !== sectionCount + ) { + throw new NecRuntimeError("Worker symmetry metadata has inconsistent counts"); + } + const copies = symmetry.copies.map((value, position) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NecRuntimeError(`Worker symmetry copy ${position} is invalid`); + } + const copy = value as { + readonly index?: unknown; + readonly tagOffset?: unknown; + readonly transform?: unknown; + }; + const index = safeNonnegativeInteger( + copy.index, + `symmetry.copies[${position}].index`, + ); + const tagOffset = safeNonnegativeInteger( + copy.tagOffset, + `symmetry.copies[${position}].tagOffset`, + ); + if (index !== position || typeof copy.transform !== "object" || copy.transform === null) { + throw new NecRuntimeError(`Worker symmetry copy ${position} is inconsistent`); + } + const transform = copy.transform as { + readonly kind?: unknown; + readonly signs?: unknown; + readonly angleDeg?: unknown; + }; + if (symmetry.kind === "reflection") { + if ( + transform.kind !== "cartesian-signs" + || !Array.isArray(transform.signs) + || transform.signs.length !== 3 + || !transform.signs.every((sign) => sign === 1 || sign === -1) + ) { + throw new NecRuntimeError(`Worker reflection copy ${position} is invalid`); + } + const signs = Object.freeze([ + transform.signs[0], + transform.signs[1], + transform.signs[2], + ] as [1 | -1, 1 | -1, 1 | -1]); + return Object.freeze({ + index, + tagOffset, + transform: Object.freeze({ kind: "cartesian-signs" as const, signs }), + }); + } + if (transform.kind !== "rotate-z" || typeof transform.angleDeg !== "number" + || !Number.isFinite(transform.angleDeg)) { + throw new NecRuntimeError(`Worker rotational copy ${position} is invalid`); + } + return Object.freeze({ + index, + tagOffset, + transform: Object.freeze({ + kind: "rotate-z" as const, + angleDeg: transform.angleDeg, + }), + }); + }); + return Object.freeze({ + symmetry: Object.freeze({ + kind: symmetry.kind, + sectionCount, + fundamentalSegmentCount, + fullSegmentCount, + copies: Object.freeze(copies), + }), + }); +} + function copyFloat64(value: unknown, name: string): Float64Array { if (Object.prototype.toString.call(value) !== "[object Float64Array]") { throw new NecRuntimeError(`Worker result ${name} is not a Float64Array`); diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index ac3a023e..a72f1f6c 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -166,9 +166,10 @@ try { const frequencyMHz = 300; const wavelengthM = 299792458 / (frequencyMHz * 1e6); const ports = []; - for (let y = 0; y < side; y += 1) { - for (let x = 0; x < side; x += 1) { - const tag = y * side + x + 1; + const half = side / 2; + for (let y = half; y < side; y += 1) { + for (let x = half; x < side; x += 1) { + const tag = (y - half) * half + (x - half) + 1; const xM = (x - (side - 1) / 2) * wavelengthM / 2; const yM = (y - (side - 1) / 2) * wavelengthM / 2; ${awaitPrefix}model.addWire({ @@ -178,10 +179,18 @@ try { end: [xM, yM, wavelengthM / 8], radiusM: wavelengthM / 1000, }); - ports.push({ tag, segment: (segments + 1) / 2 }); } } - ${awaitPrefix}model.completeGeometry(); + const completion = ${awaitPrefix}model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: half * half, + }, + }); + for (let tag = 1; tag <= side * side; tag += 1) { + ports.push({ tag, segment: (segments + 1) / 2 }); + } ${awaitPrefix}model.definePorts(ports); ${awaitPrefix}model.prepare({ frequencyMHz }); const matrices = ${awaitPrefix}model.computeImpedanceMatrix(); @@ -202,6 +211,7 @@ try { fieldSamples: field.eThetaReal.length, fieldFinite: [...field.eThetaReal, ...field.eThetaImag, ...field.ePhiReal, ...field.ePhiImag].every(Number.isFinite), + sectionCount: completion.symmetry?.sectionCount, mode: ${JSON.stringify(mode)}, }; } finally { @@ -263,6 +273,7 @@ try { assert.ok(result.resistanceOhm > 0); assert.equal(result.fieldSamples, 3); assert.equal(result.fieldFinite, true); + assert.equal(result.sectionCount, 4); } assert.ok(wasmResponses.length >= 1, "the browser must request the emitted WASM asset"); assert.ok( diff --git a/packages/necpp-wasm/test/facade-mapping.test.mjs b/packages/necpp-wasm/test/facade-mapping.test.mjs index 9a1699f0..2008e5d4 100644 --- a/packages/necpp-wasm/test/facade-mapping.test.mjs +++ b/packages/necpp-wasm/test/facade-mapping.test.mjs @@ -20,6 +20,10 @@ function createRecordingModule() { let nextStatus = 0; let allocation = 256; let deleted = false; + let symmetryKind = -1; + let sectionCount = 0; + let fundamentalSegmentCount = 0n; + let fullSegmentCount = 0n; HEAPU8.set(new TextEncoder().encode("controlled native failure\0"), 8); const complete = (name, args, nextState) => { @@ -53,7 +57,38 @@ function createRecordingModule() { return complete("addWire", args, 1); }, _necpp_wasm_v1_complete_geometry(...args) { - return complete("completeGeometry", args, 2); + const status = complete("completeGeometry", args, 2); + if (status === 0) { + symmetryKind = 0; + sectionCount = 1; + fundamentalSegmentCount = 3n; + fullSegmentCount = 3n; + } + return status; + }, + _necpp_wasm_v1_complete_geometry_symmetric(...args) { + const status = complete("completeGeometrySymmetric", args, 2); + if (status === 0) { + symmetryKind = args[2]; + sectionCount = args[2] === 1 + ? 2 ** [1, 2, 4].filter((bit) => (args[3] & bit) !== 0).length + : args[3]; + fundamentalSegmentCount = 3n; + fullSegmentCount = 3n * BigInt(sectionCount); + } + return status; + }, + _necpp_wasm_v1_geometry_symmetry_kind() { + return symmetryKind; + }, + _necpp_wasm_v1_geometry_section_count() { + return sectionCount; + }, + _necpp_wasm_v1_geometry_fundamental_segment_count() { + return fundamentalSegmentCount; + }, + _necpp_wasm_v1_geometry_full_segment_count() { + return fullSegmentCount; }, _necpp_wasm_v1_define_ports(...args) { return complete("definePorts", args); @@ -81,6 +116,18 @@ function createRecordingModule() { }; } +function symmetryModel(recording) { + const model = new WasmNecModel(recording.module, 1); + model.addWire({ + tag: 1, + segments: 3, + start: [0.25, 0.5, 0.75], + end: [0.25, 0.5, 1.25], + radiusM: 0.001, + }); + return model; +} + function createConfigurableModel(recording) { const model = new WasmNecModel(recording.module, 1); model.addWire({ @@ -242,3 +289,149 @@ test("every stable native status becomes its public typed error", () => { } model.dispose(); }); + +test("reflection and rotation map every ABI argument and return frozen copy metadata", () => { + const reflectionRecording = createRecordingModule(); + const reflectionModel = symmetryModel(reflectionRecording); + const reflection = reflectionModel.completeGeometry({ + groundConnection: "none", + symmetry: { + kind: "reflection", + planes: ["x=0", "z=0", "y=0"], + tagIncrement: 2, + }, + }); + assert.deepEqual( + reflectionRecording.calls.find(([name]) => name === "completeGeometrySymmetric"), + ["completeGeometrySymmetric", 1, 0, 1, 7, 2], + ); + assert.deepEqual(reflection, { + symmetry: { + kind: "reflection", + sectionCount: 8, + fundamentalSegmentCount: 3, + fullSegmentCount: 24, + copies: [ + { index: 0, tagOffset: 0, transform: { kind: "cartesian-signs", signs: [1, 1, 1] } }, + { index: 1, tagOffset: 2, transform: { kind: "cartesian-signs", signs: [1, 1, -1] } }, + { index: 2, tagOffset: 4, transform: { kind: "cartesian-signs", signs: [1, -1, 1] } }, + { index: 3, tagOffset: 6, transform: { kind: "cartesian-signs", signs: [1, -1, -1] } }, + { index: 4, tagOffset: 8, transform: { kind: "cartesian-signs", signs: [-1, 1, 1] } }, + { index: 5, tagOffset: 10, transform: { kind: "cartesian-signs", signs: [-1, 1, -1] } }, + { index: 6, tagOffset: 12, transform: { kind: "cartesian-signs", signs: [-1, -1, 1] } }, + { index: 7, tagOffset: 14, transform: { kind: "cartesian-signs", signs: [-1, -1, -1] } }, + ], + }, + }); + assert.ok(Object.isFrozen(reflection)); + assert.ok(Object.isFrozen(reflection.symmetry)); + assert.ok(Object.isFrozen(reflection.symmetry.copies)); + assert.ok(Object.isFrozen(reflection.symmetry.copies[0].transform)); + assert.ok(Object.isFrozen(reflection.symmetry.copies[0].transform.signs)); + + const rotationRecording = createRecordingModule(); + const rotationModel = symmetryModel(rotationRecording); + const rotation = rotationModel.completeGeometry({ + groundConnection: "interpolate", + symmetry: { + kind: "rotational", + axis: "z", + order: 4, + tagIncrement: 3, + }, + }); + assert.deepEqual( + rotationRecording.calls.find(([name]) => name === "completeGeometrySymmetric"), + ["completeGeometrySymmetric", 1, 1, 2, 4, 3], + ); + assert.deepEqual( + rotation.symmetry.copies.map((copy) => copy.transform), + [ + { kind: "rotate-z", angleDeg: 0 }, + { kind: "rotate-z", angleDeg: 90 }, + { kind: "rotate-z", angleDeg: 180 }, + { kind: "rotate-z", angleDeg: 270 }, + ], + ); +}); + +test("invalid symmetry descriptors fail before native mutation with typed details", () => { + const invalid = [ + { kind: "reflection", planes: [], tagIncrement: 1 }, + { kind: "reflection", planes: ["x=0", "x=0"], tagIncrement: 1 }, + { kind: "reflection", planes: ["x=y"], tagIncrement: 1 }, + { kind: "reflection", planes: ["x=0"], tagIncrement: 0 }, + { kind: "reflection", planes: ["x=0"], tagIncrement: Number.MAX_SAFE_INTEGER }, + { kind: "reflection", planes: ["x=0"], tagIncrement: 2_147_483_647 }, + { kind: "rotational", axis: "x", order: 4, tagIncrement: 1 }, + { kind: "rotational", axis: "z", order: 1, tagIncrement: 1 }, + { kind: "rotational", axis: "z", order: 2.5, tagIncrement: 1 }, + { kind: "rotational", axis: "z", order: Number.MAX_SAFE_INTEGER, tagIncrement: 1 }, + { kind: "reflection", planes: ["x=0"], order: 2, tagIncrement: 1 }, + { kind: "rotational", axis: "z", order: 2, planes: ["x=0"], tagIncrement: 1 }, + ]; + for (const symmetry of invalid) { + const recording = createRecordingModule(); + const model = symmetryModel(recording); + assert.throws( + () => model.completeGeometry({ symmetry }), + (error) => error instanceof NecInputError + && error.code === "NEC_INPUT" + && error.details?.symmetryFailure === "INVALID_SYMMETRY", + ); + assert.equal(model.state, "geometry-building"); + assert.equal( + recording.calls.some(([name]) => name === "completeGeometrySymmetric"), + false, + ); + } +}); + +test("z reflection rejects ground without mutating geometry", () => { + const recording = createRecordingModule(); + const model = symmetryModel(recording); + assert.throws( + () => model.completeGeometry({ + groundConnection: "zero-current", + symmetry: { kind: "reflection", planes: ["z=0"], tagIncrement: 1 }, + }), + (error) => error instanceof NecGeometryError + && error.details?.symmetryFailure === "INCOMPATIBLE_GROUND", + ); + assert.equal(model.state, "geometry-building"); +}); + +test("signed 64-bit segment counts never narrow to unsafe public numbers", () => { + const recording = createRecordingModule(); + recording.module._necpp_wasm_v1_geometry_fundamental_segment_count = () => + BigInt(Number.MAX_SAFE_INTEGER) + 1n; + recording.module._necpp_wasm_v1_geometry_full_segment_count = () => + 2n * (BigInt(Number.MAX_SAFE_INTEGER) + 1n); + const model = symmetryModel(recording); + assert.throws( + () => model.completeGeometry({ + symmetry: { kind: "reflection", planes: ["x=0"], tagIncrement: 1 }, + }), + (error) => error instanceof NecRuntimeError + && error.message.includes("safe integer range"), + ); +}); + +test("z-reflected completion rejects a later non-free-space ground", () => { + const recording = createRecordingModule(); + const model = symmetryModel(recording); + model.completeGeometry({ + symmetry: { kind: "reflection", planes: ["z=0"], tagIncrement: 1 }, + }); + assert.throws( + () => model.setGround({ kind: "perfect" }), + (error) => error instanceof NecGeometryError + && error.details?.symmetryFailure === "INCOMPATIBLE_GROUND", + ); + assert.equal( + recording.calls.some(([name]) => name === "setGround"), + false, + ); + model.setGround({ kind: "free-space" }); + assert.equal(recording.calls.at(-1)[0], "setGround"); +}); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 302eb765..ba19add7 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -119,6 +119,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.equal(direct.packageVersion, packageJson.version); assert.equal(direct.engineVersion, "2.3.4"); assert.equal(direct.abiVersion, 1); + assert.equal(direct.sectionCount, 4); assert.ok(direct.resistanceOhm > 0); assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp-engine\/wasm/); assert.doesNotMatch(direct.resolved, /packages[/\\]necpp-wasm[/\\]src[/\\]/); @@ -141,6 +142,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { }).stdout, ); assert.equal(worker.packageVersion, packageJson.version); + assert.equal(worker.sectionCount, 4); assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); }); diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index d5d369d1..9fe00a21 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -194,12 +194,18 @@ try { model.addWire({ tag: 1, segments: 11, - start: [0, 0, -0.25], - end: [0, 0, 0.25], + start: [0.25, 0.25, -0.25], + end: [0.25, 0.25, 0.25], radiusM: 0.001, }); - model.completeGeometry(); - model.definePorts([{ tag: 1, segment: 6 }]); + const completion = model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + model.definePorts([1, 2, 3, 4].map((tag) => ({ tag, segment: 6 }))); model.prepare({ frequencyMHz: 300 }); const matrices = model.computeImpedanceMatrix(); if (!(matrices.impedance.real[0] > 0)) { @@ -209,6 +215,7 @@ try { abiVersion, engineVersion, packageVersion, + sectionCount: completion.symmetry?.sectionCount, resistanceOhm: matrices.impedance.real[0], resolved, })); @@ -234,12 +241,18 @@ try { await model.addWire({ tag: 1, segments: 11, - start: [0, 0, -0.25], - end: [0, 0, 0.25], + start: [0.25, 0.25, -0.25], + end: [0.25, 0.25, 0.25], radiusM: 0.001, }); - await model.completeGeometry(); - await model.definePorts([{ tag: 1, segment: 6 }]); + const completion = await model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + await model.definePorts([1, 2, 3, 4].map((tag) => ({ tag, segment: 6 }))); await model.prepare({ frequencyMHz: 300 }); const matrices = await model.computeImpedanceMatrix(); if (!(matrices.impedance.real[0] > 0)) { @@ -249,6 +262,7 @@ try { abiVersion, engineVersion, packageVersion, + sectionCount: completion.symmetry?.sectionCount, resistanceOhm: matrices.impedance.real[0], resolved, })); diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs index 0b7530a4..ad40f0e1 100644 --- a/packages/necpp-wasm/test/worker-client.test.mjs +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -227,6 +227,48 @@ test("the worker runtime preserves state across serialized requests", async () = assert.ok(progress.includes("addWire:complete")); }); +test("worker completion preserves progress and revives immutable symmetry metadata", async () => { + const progress = []; + const fake = createFakeModel({ + completeGeometry() { + return { + symmetry: { + kind: "reflection", + sectionCount: 4, + fundamentalSegmentCount: 11, + fullSegmentCount: 44, + copies: [ + { index: 0, tagOffset: 0, transform: { kind: "cartesian-signs", signs: [1, 1, 1] } }, + { index: 1, tagOffset: 1, transform: { kind: "cartesian-signs", signs: [1, -1, 1] } }, + { index: 2, tagOffset: 2, transform: { kind: "cartesian-signs", signs: [-1, 1, 1] } }, + { index: 3, tagOffset: 3, transform: { kind: "cartesian-signs", signs: [-1, -1, 1] } }, + ], + }, + }; + }, + }); + const model = await createNecWorkerModelFromHost( + createLoopbackHost(async () => fake), + { onProgress: (event) => progress.push(`${event.operation}:${event.phase}`) }, + ); + await model.addWire(dipoleWire); + const completion = await model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + assert.equal(completion.symmetry.sectionCount, 4); + assert.ok(Object.isFrozen(completion)); + assert.ok(Object.isFrozen(completion.symmetry)); + assert.ok(Object.isFrozen(completion.symmetry.copies)); + assert.ok(Object.isFrozen(completion.symmetry.copies[0].transform.signs)); + assert.ok(progress.includes("completeGeometry:start")); + assert.ok(progress.includes("completeGeometry:complete")); + await model.dispose(); +}); + test("worker client serializes operations, reports progress, and transfers fields", async () => { const progress = []; const { model } = await preparedModel(); diff --git a/packages/necpp-wasm/test/worker-integration.test.mjs b/packages/necpp-wasm/test/worker-integration.test.mjs index 4c3bb407..2480329f 100644 --- a/packages/necpp-wasm/test/worker-integration.test.mjs +++ b/packages/necpp-wasm/test/worker-integration.test.mjs @@ -2,8 +2,9 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import test from "node:test"; -import { createNecModel } from "../.test-build/src/index.js"; +import { createNecModel, NecGeometryError } from "../.test-build/src/index.js"; import { createNecWorkerModel } from "../.test-build/src/worker.js"; +import { createReferenceArrayFixture } from "./fixtures/reference-array.mjs"; const generatedLoader = new URL( "../.test-build/src/nec2pp.generated.js", @@ -46,6 +47,42 @@ async function buildDipole(model) { await Promise.resolve(model.prepare({ frequencyMHz: 300 })); } +async function buildR1Reflection(model, fixture) { + const reflection = fixture.reflection; + assert.ok(reflection); + for (const wire of reflection.fundamentalWires) { + await Promise.resolve(model.addWire(wire)); + } + const completion = await Promise.resolve(model.completeGeometry({ + groundConnection: fixture.groundConnection, + symmetry: reflection.symmetry, + })); + const ports = Array.from( + { length: fixture.ports.length }, + (_, index) => ({ tag: index + 1, segment: fixture.feedSegment }), + ); + await Promise.resolve(model.definePorts(ports)); + await Promise.resolve(model.setGround(fixture.ground)); + await Promise.resolve(model.prepare({ frequencyMHz: fixture.frequencyMHz })); + return completion; +} + +function gatherMatrix(matrix, scatterCallerToGenerated) { + const order = scatterCallerToGenerated.length; + const real = new Float64Array(order * order); + const imag = new Float64Array(order * order); + for (let row = 0; row < order; row += 1) { + for (let column = 0; column < order; column += 1) { + const source = scatterCallerToGenerated[row] * order + + scatterCallerToGenerated[column]; + const target = row * order + column; + real[target] = matrix.real[source]; + imag[target] = matrix.imag[source]; + } + } + return { real, imag }; +} + test("worker Z matrices and fields match direct-mode results", { skip: !hasWasm && "WASM artifacts have not been built", }, async () => { @@ -82,6 +119,140 @@ test("worker Z matrices and fields match direct-mode results", { } }); +test("R1 reflection metadata, gathered Z, and fields agree in direct and worker modes", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const fixture = createReferenceArrayFixture(); + const reflection = fixture.reflection; + assert.ok(reflection); + const direct = await createNecModel(); + const worker = await createNecWorkerModel(); + try { + const [directCompletion, workerCompletion] = await Promise.all([ + buildR1Reflection(direct, fixture), + buildR1Reflection(worker, fixture), + ]); + assert.deepEqual(workerCompletion, directCompletion); + assert.equal(directCompletion.symmetry.sectionCount, 4); + assert.equal(directCompletion.symmetry.fundamentalSegmentCount, 44); + assert.equal(directCompletion.symmetry.fullSegmentCount, 176); + assert.deepEqual( + directCompletion.symmetry.copies.map((copy) => copy.transform.signs), + reflection.copies.map((copy) => copy.transform.signs), + ); + assert.ok(Object.isFrozen(directCompletion.symmetry.copies)); + assert.ok(Object.isFrozen(workerCompletion.symmetry.copies)); + + const [directMatrices, workerMatrices] = await Promise.all([ + Promise.resolve(direct.computeImpedanceMatrix()), + worker.computeImpedanceMatrix(), + ]); + const directZ = gatherMatrix( + directMatrices.impedance, + reflection.scatterCallerToGenerated, + ); + const workerZ = gatherMatrix( + workerMatrices.impedance, + reflection.scatterCallerToGenerated, + ); + assert.ok(relativeError(directZ.real, workerZ.real) <= 1e-12); + assert.ok(relativeError(directZ.imag, workerZ.imag) <= 1e-12); + + const callerReal = Float64Array.from( + { length: fixture.ports.length }, + (_, index) => Math.cos(index * 0.37), + ); + const callerImag = Float64Array.from( + { length: fixture.ports.length }, + (_, index) => Math.sin(index * 0.37), + ); + const nativeReal = new Float64Array(fixture.ports.length); + const nativeImag = new Float64Array(fixture.ports.length); + for (let caller = 0; caller < fixture.ports.length; caller += 1) { + const native = reflection.scatterCallerToGenerated[caller]; + nativeReal[native] = callerReal[caller]; + nativeImag[native] = callerImag[caller]; + } + const currents = { real: nativeReal, imag: nativeImag }; + await Promise.all([ + Promise.resolve(direct.solveCurrents(currents)), + worker.solveCurrents(currents), + ]); + const [directField, workerField] = await Promise.all([ + Promise.resolve(direct.computeFarField(farFieldRequest)), + worker.computeFarField(farFieldRequest), + ]); + for (const component of [ + "eThetaReal", + "eThetaImag", + "ePhiReal", + "ePhiImag", + ]) { + assert.ok(relativeError(directField[component], workerField[component]) <= 1e-12); + } + } finally { + direct.dispose(); + await worker.dispose(); + } +}); + +test("incomplete symmetric load orbits retain typed failure details across workers", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const progress = []; + const direct = await createNecModel(); + const worker = await createNecWorkerModel({ + onProgress: (event) => progress.push(`${event.operation}:${event.phase}`), + }); + const configure = async (model) => { + await Promise.resolve(model.addWire({ + tag: 1, + segments: 11, + start: [0.25, 0.25, 0.1], + end: [0.25, 0.25, 0.4], + radiusM: 0.001, + })); + await Promise.resolve(model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + })); + await Promise.resolve(model.definePorts( + [1, 2, 3, 4].map((tag) => ({ tag, segment: 6 })), + )); + await Promise.resolve(model.addLoad({ + kind: "impedance", + target: { tag: 1 }, + resistanceOhm: 25, + reactanceOhm: 0, + })); + }; + try { + await configure(direct); + await configure(worker); + const isIncompleteLoadError = (error) => error instanceof NecGeometryError + && error.code === "NEC_GEOMETRY" + && error.details?.symmetryFailure === "INCOMPLETE_LOAD_ORBIT"; + assert.throws( + () => direct.prepare({ frequencyMHz: 300 }), + isIncompleteLoadError, + ); + await assert.rejects( + worker.prepare({ frequencyMHz: 300 }), + isIncompleteLoadError, + ); + assert.equal(direct.state, "geometry-complete"); + assert.equal(worker.state, "geometry-complete"); + assert.ok(progress.includes("prepare:start")); + assert.ok(progress.includes("prepare:complete")); + } finally { + direct.dispose(); + await worker.dispose(); + } +}); + test("two real worker models remain isolated", { skip: !hasWasm && "WASM artifacts have not been built", }, async () => { From f0bbf36abd335f7fd72b3581b5d8e6b35c528f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 17:15:49 +0200 Subject: [PATCH 41/46] feat: add transparent array symmetrizer --- docs/01_symmetry_support.md | 52 +- packages/necpp-wasm/src/array-solver.ts | 601 +++++++++++ packages/necpp-wasm/src/array-symmetry.ts | 965 ++++++++++++++++++ packages/necpp-wasm/src/index.ts | 31 + packages/necpp-wasm/src/types.ts | 173 ++++ packages/necpp-wasm/src/worker.ts | 31 + packages/necpp-wasm/test-d/public-api.test.ts | 60 ++ .../test/array-solver.integration.test.mjs | 143 +++ .../necpp-wasm/test/array-symmetry.test.mjs | 305 ++++++ 9 files changed, 2360 insertions(+), 1 deletion(-) create mode 100644 packages/necpp-wasm/src/array-solver.ts create mode 100644 packages/necpp-wasm/src/array-symmetry.ts create mode 100644 packages/necpp-wasm/test/array-solver.integration.test.mjs create mode 100644 packages/necpp-wasm/test/array-symmetry.test.mjs diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index ca737635..fcd63983 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1235,7 +1235,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S2 Stateful symmetry and validation | complete | Codex | Native `[wp_s2]`: 10,599 assertions; direct WP1-WP4 regressions and npm 38/38 | WP-S3 should call the symmetric overload, then expose only `geometry_completion()` metadata through additive ABI getters. | | WP-S3 Additive C/WASM ABI | complete | Codex | Native `[wp_s3]`: 8 assertions/2 cases plus pure-C contract; CTest 8/8; WASM smoke; npm 38/38; pack 5/5; browser 3/3 | WP-S4 should convert the private WASM `bigint` segment counts to checked safe numbers and derive copy transforms from the validated descriptor. | | WP-S4 Direct and worker TypeScript API | complete | Codex | npm/WASM: 46/46; pack: 5/5; browser: 3/3; native ABI: 8 assertions | WP-S5 may use only the exported descriptors, immutable completion metadata, typed failure details, and direct/worker methods; private ABI `bigint` values never escape. | -| WP-S5 Transparent symmetrizer | not started | — | — | — | +| WP-S5 Transparent symmetrizer | complete | Codex | npm/WASM: 60/60; pack: 5/5; browser: 3/3; focused WP-S5: 14/14 | WP-S6 can consume the public facade without plan-kind branches; caller-order tags, ports, vectors, matrices, and field bases are representation-independent. | | WP-S6 End-to-end equivalence suite | not started | — | — | — | | WP-S7 Benchmarks and performance gates | not started | — | — | — | | WP-S8 Public documentation, examples, and release hardening | not started | — | — | — | @@ -1679,6 +1679,56 @@ DoD: Handoff focus: WP-S6 combines public planner and engine paths and should not patch around mapping failures in test code. +Completion evidence (2026-08-30, Windows, Node 24.14.1, TypeScript 5.8.3, +Chromium through Playwright, and the WP-S3 Emscripten artifact): + +- `npm --prefix packages/necpp-wasm run test:wasm` passed 60/60 Node tests, + strict declaration/type tests, and 5/5 packed-consumer tests. The 14 focused + WP-S5 cases cover exact 2 x 2, 4 x 4, and 8 x 8 reflection plans, odd-grid + fixed-element fallback, exact cardinal rotation, permutation invariance, + epsilon adjustment reporting, outside-epsilon and ambiguous rejection, + pattern mismatch, every first-release pattern prohibition, preservation of + unsupported local rotation in the explicit fallback, synthetic gather maps, + direct plan application, the unbranched facade, and the off-origin complex- + field phase-sign proof. +- `npm --prefix packages/necpp-wasm run pack:release -- .pack-work` produced + `necpp-engine-wasm-0.1.1.tgz`, 327,294 bytes, SHA-256 + `80dbd4c3ffb450cadef9c93909f0e1a99ae513ad14572570806f04c042718afa`. + With `NECPP_WASM_TARBALL` set to that artifact, `npm --prefix + packages/necpp-wasm run test:browser -- direct`, `worker`, and `example` all + passed. +- The first restricted-sandbox pack and browser attempts failed only because + npm could not write its cache below `%LOCALAPPDATA%`. The unchanged commands + passed after granting the test processes the required cache/temp access; no + source or assertion was changed in response. +- Native tests were not rerun because WP-S5 changes only the public TypeScript + planning/facade layer and consumes the already-tested WP-S4 API. The full + package run exercised the real WP-S3 WASM binary through direct, worker, + packed Node, Vite, and browser paths. + +Contract decisions for WP-S6 and later: + +- `analyzeArraySymmetry()` remains pure and requires an explicit finite, + nonnegative `positionEpsilonM`. Since `createNecArraySolver()` defaults to + `"auto"`, callers selecting auto/require must supply symmetrizer options; + `"off"` needs no tolerance and constructs the unchanged explicit model. +- Automatic rotation enumeration tests divisors from largest to smallest and + caps inferred order at 64; configured branded orders are tested explicitly. + Equal-section candidates prefer reflection, then the stable documented + plane/order sequence. +- Symmetric fundamental XY coordinates are centered model coordinates. + Ordinary impedance and port results are gathered to caller order, while + combined and embedded complex fields restore the caller's XY translation + with the executable positive-sign phase rule. +- Ordinary result ports use the same logical caller-order tags in explicit and + symmetric representations. Generated tags, copy indices, fundamental port + order, and scatter maps remain confined to plans, application metadata, and + diagnostics. +- `applyArrayBuildPlan()` accepts either a direct or worker model. The public + `NecArraySolver` factory is worker-backed, expands structural loads over all + native copies, and retries the unchanged explicit description at most once + for a classified representation-eligibility failure in `"auto"` mode. + ### WP-S6 — End-to-end equivalence suite Dependencies: WP-S5. diff --git a/packages/necpp-wasm/src/array-solver.ts b/packages/necpp-wasm/src/array-solver.ts new file mode 100644 index 00000000..34b31479 --- /dev/null +++ b/packages/necpp-wasm/src/array-solver.ts @@ -0,0 +1,601 @@ +import { + analyzeArraySymmetry, + createExplicitArrayBuildPlan, +} from "./array-symmetry.js"; +import { NecError, NecGeometryError, NecInputError } from "./errors.js"; +import { createNecWorkerModel } from "./worker-client.js"; +import type { + ArrayBuildPlan, + ArraySolverDiagnostics, + ComplexMatrix, + ComplexVector, + CreateArraySolverOptions, + ElementWirePattern, + EmbeddedFarFieldResult, + EmbeddedFieldNormalization, + FarFieldRequest, + FarFieldResult, + FullArrayDescription, + GeometryCompletionResult, + ImpedanceResult, + LoadDefinition, + NecArraySolver, + NecModel, + NecModelState, + NecWorkerModel, + PortDefinition, + PortSolution, + PrepareOptions, + RelativeLoadDefinition, + SymmetrizationReason, + SymmetrizationReasonCode, + SymmetryFailureReason, +} from "./types.js"; + +const NEC_VACUUM_PERMITTIVITY_F_PER_M = 8.854e-12; +const NEC_VACUUM_PERMEABILITY_H_PER_M = 4 * Math.PI * 1e-7; +const NEC_SPEED_OF_LIGHT_M_PER_S = 1 / Math.sqrt( + NEC_VACUUM_PERMITTIVITY_F_PER_M * NEC_VACUUM_PERMEABILITY_H_PER_M, +); + +type ArrayModel = NecModel | NecWorkerModel; + +export interface AppliedArrayBuildPlan { + readonly completion: GeometryCompletionResult; + readonly callerPorts: readonly PortDefinition[]; + /** Caller-order port index to the native model's port order. */ + readonly scatterCallerToNative: readonly number[]; +} + +interface ElementAllocation { + readonly pattern: ElementWirePattern; + readonly wireTags: ReadonlyMap; +} + +async function invoke(value: T | Promise): Promise { + return Promise.resolve(value); +} + +function patternsById(description: FullArrayDescription): ReadonlyMap { + return new Map(description.patterns.map((pattern) => [pattern.id, pattern])); +} + +function allocateCallerModel(description: FullArrayDescription): { + readonly allocations: readonly ElementAllocation[]; + readonly ports: readonly PortDefinition[]; +} { + const patterns = patternsById(description); + const allocations: ElementAllocation[] = []; + const ports: PortDefinition[] = []; + let nextTag = 1; + for (const element of description.elements) { + const pattern = patterns.get(element.patternId); + if (pattern === undefined) { + throw new NecInputError(`Unknown pattern ${element.patternId}`); + } + const wireTags = new Map(); + for (const wire of pattern.wires) { + wireTags.set(wire.id, nextTag); + nextTag += 1; + } + for (const port of pattern.ports) { + const tag = wireTags.get(port.wireId)!; + ports.push(Object.freeze(port.name === undefined + ? { tag, segment: port.segment } + : { tag, segment: port.segment, name: port.name })); + } + allocations.push(Object.freeze({ pattern, wireTags })); + } + return Object.freeze({ + allocations: Object.freeze(allocations), + ports: Object.freeze(ports), + }); +} + +function retargetLoad(load: RelativeLoadDefinition, tag: number): LoadDefinition { + return { + ...load, + target: { + tag, + ...(load.target.firstSegment === undefined + ? {} + : { firstSegment: load.target.firstSegment }), + ...(load.target.lastSegment === undefined + ? {} + : { lastSegment: load.target.lastSegment }), + }, + } as LoadDefinition; +} + +async function addElementWires( + model: ArrayModel, + pattern: ElementWirePattern, + positionM: readonly [number, number], + wireTags: ReadonlyMap, + rotationDeg = 0, +): Promise { + const angle = rotationDeg * Math.PI / 180; + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + const rotate = (x: number, y: number): readonly [number, number] => [ + cosine * x - sine * y, + sine * x + cosine * y, + ]; + for (const wire of pattern.wires) { + const start = rotate(wire.startM[0], wire.startM[1]); + const end = rotate(wire.endM[0], wire.endM[1]); + await invoke(model.addWire({ + tag: wireTags.get(wire.id)!, + segments: wire.segments, + start: [ + positionM[0] + start[0], + positionM[1] + start[1], + wire.startM[2], + ], + end: [ + positionM[0] + end[0], + positionM[1] + end[1], + wire.endM[2], + ], + radiusM: wire.radiusM, + })); + } +} + +async function applyExplicit( + model: ArrayModel, + description: FullArrayDescription, + plan: Extract, + caller: ReturnType, +): Promise { + for (let index = 0; index < plan.elements.length; index += 1) { + const allocation = caller.allocations[index]!; + await addElementWires( + model, + allocation.pattern, + plan.elements[index]!.positionM, + allocation.wireTags, + description.elements[index]!.rotationDeg ?? 0, + ); + } + const completion = await invoke(model.completeGeometry({ groundConnection: "none" })); + await invoke(model.definePorts(caller.ports)); + for (const allocation of caller.allocations) { + for (const load of allocation.pattern.loads ?? []) { + await invoke(model.addLoad(retargetLoad(load, allocation.wireTags.get(load.target.wireId)!))); + } + } + await invoke(model.setGround(description.ground)); + return Object.freeze({ + completion, + callerPorts: caller.ports, + scatterCallerToNative: Object.freeze(Array.from( + { length: caller.ports.length }, + (_, index) => index, + )), + }); +} + +async function applySymmetric( + model: ArrayModel, + description: FullArrayDescription, + plan: Extract, + caller: ReturnType, +): Promise { + const patterns = patternsById(description); + const fundamentalAllocations: ElementAllocation[] = []; + let nextTag = 1; + for (const element of plan.fundamentalElements) { + const pattern = patterns.get(element.patternId)!; + const wireTags = new Map(); + for (const wire of pattern.wires) { + wireTags.set(wire.id, nextTag); + nextTag += 1; + } + fundamentalAllocations.push(Object.freeze({ pattern, wireTags })); + await addElementWires(model, pattern, element.positionM, wireTags); + } + const completion = await invoke(model.completeGeometry({ + groundConnection: "none", + symmetry: plan.symmetry, + })); + const nativePorts: PortDefinition[] = []; + for (const copy of plan.expansion.copies) { + for (const allocation of fundamentalAllocations) { + for (const port of allocation.pattern.ports) { + const tag = allocation.wireTags.get(port.wireId)! + copy.tagOffset; + nativePorts.push(port.name === undefined + ? { tag, segment: port.segment } + : { tag, segment: port.segment, name: port.name }); + } + } + } + await invoke(model.definePorts(nativePorts)); + // Loads are structural. Expand every fundamental load atomically over all + // native copies so prepare never observes an incomplete orbit. + for (const copy of plan.expansion.copies) { + for (const allocation of fundamentalAllocations) { + for (const load of allocation.pattern.loads ?? []) { + const tag = allocation.wireTags.get(load.target.wireId)! + copy.tagOffset; + await invoke(model.addLoad(retargetLoad(load, tag))); + } + } + } + await invoke(model.setGround(description.ground)); + const scatter = new Array(caller.ports.length); + for (const mapping of plan.mappings) { + for (let portIndex = 0; portIndex < mapping.callerPortIndices.length; portIndex += 1) { + scatter[mapping.callerPortIndices[portIndex]!] = mapping.generatedPortIndices[portIndex]!; + } + } + if (scatter.some((index) => !Number.isSafeInteger(index))) { + throw new NecGeometryError("Symmetry plan does not map every caller port"); + } + return Object.freeze({ + completion, + callerPorts: caller.ports, + scatterCallerToNative: Object.freeze(scatter), + }); +} + +/** Apply a validated planner result to either a direct or worker model. */ +export async function applyArrayBuildPlan( + model: ArrayModel, + description: FullArrayDescription, + plan: ArrayBuildPlan, +): Promise { + const caller = allocateCallerModel(description); + return plan.kind === "explicit" + ? applyExplicit(model, description, plan, caller) + : applySymmetric(model, description, plan, caller); +} + +function validateVector(vector: ComplexVector, length: number): void { + if (!(vector.real instanceof Float64Array) || !(vector.imag instanceof Float64Array) + || vector.real.length !== length || vector.imag.length !== length) { + throw new NecInputError(`Complex vector must contain ${length} real and imaginary values`); + } +} + +/** Scatter a caller-order vector into native port order. */ +export function scatterComplexVector( + vector: ComplexVector, + scatterCallerToNative: readonly number[], +): ComplexVector { + validateVector(vector, scatterCallerToNative.length); + const real = new Float64Array(scatterCallerToNative.length); + const imag = new Float64Array(scatterCallerToNative.length); + for (let caller = 0; caller < scatterCallerToNative.length; caller += 1) { + const native = scatterCallerToNative[caller]!; + real[native] = vector.real[caller]!; + imag[native] = vector.imag[caller]!; + } + return { real, imag }; +} + +/** Gather a native-order vector into caller port order. */ +export function gatherComplexVector( + vector: ComplexVector, + scatterCallerToNative: readonly number[], +): ComplexVector { + validateVector(vector, scatterCallerToNative.length); + const real = new Float64Array(scatterCallerToNative.length); + const imag = new Float64Array(scatterCallerToNative.length); + for (let caller = 0; caller < scatterCallerToNative.length; caller += 1) { + const native = scatterCallerToNative[caller]!; + real[caller] = vector.real[native]!; + imag[caller] = vector.imag[native]!; + } + return { real, imag }; +} + +/** Gather both dimensions of a native row-major matrix. */ +export function gatherComplexMatrix( + matrix: ComplexMatrix, + scatterCallerToNative: readonly number[], +): ComplexMatrix { + const order = scatterCallerToNative.length; + if (matrix.rows !== order || matrix.columns !== order + || matrix.real.length !== order * order || matrix.imag.length !== order * order) { + throw new NecInputError(`Complex matrix must be ${order} by ${order}`); + } + const real = new Float64Array(order * order); + const imag = new Float64Array(order * order); + for (let row = 0; row < order; row += 1) { + for (let column = 0; column < order; column += 1) { + const source = scatterCallerToNative[row]! * order + scatterCallerToNative[column]!; + const target = row * order + column; + real[target] = matrix.real[source]!; + imag[target] = matrix.imag[source]!; + } + } + return { rows: order, columns: order, order: "row-major", real, imag }; +} + +/** Gather the basis-major outer port dimension of a field array. */ +export function gatherEmbeddedBasis( + values: Float64Array, + samplesPerPort: number, + scatterCallerToNative: readonly number[], +): Float64Array { + if (values.length !== samplesPerPort * scatterCallerToNative.length) { + throw new NecInputError("Embedded field array has an invalid basis dimension"); + } + const gathered = new Float64Array(values.length); + for (let caller = 0; caller < scatterCallerToNative.length; caller += 1) { + const native = scatterCallerToNative[caller]!; + gathered.set( + values.subarray(native * samplesPerPort, (native + 1) * samplesPerPort), + caller * samplesPerPort, + ); + } + return gathered; +} + +function rephaseArrays( + result: FarFieldResult, + centerM: readonly [number, number], + basisCount: number, +): Pick { + const samples = result.thetaDeg.length * result.phiDeg.length; + const thetaReal = result.eThetaReal.slice(); + const thetaImag = result.eThetaImag.slice(); + const phiReal = result.ePhiReal.slice(); + const phiImag = result.ePhiImag.slice(); + if (centerM[0] === 0 && centerM[1] === 0) { + return { eThetaReal: thetaReal, eThetaImag: thetaImag, ePhiReal: phiReal, ePhiImag: phiImag }; + } + const waveNumber = 2 * Math.PI * result.frequencyMHz * 1e6 / NEC_SPEED_OF_LIGHT_M_PER_S; + for (let basis = 0; basis < basisCount; basis += 1) { + for (let phiIndex = 0; phiIndex < result.phiDeg.length; phiIndex += 1) { + const phi = result.phiDeg[phiIndex]! * Math.PI / 180; + for (let thetaIndex = 0; thetaIndex < result.thetaDeg.length; thetaIndex += 1) { + const theta = result.thetaDeg[thetaIndex]! * Math.PI / 180; + const phase = waveNumber * Math.sin(theta) + * (Math.cos(phi) * centerM[0] + Math.sin(phi) * centerM[1]); + const cosine = Math.cos(phase); + const sine = Math.sin(phase); + const index = basis * samples + phiIndex * result.thetaDeg.length + thetaIndex; + for (const [real, imag] of [ + [thetaReal, thetaImag], + [phiReal, phiImag], + ] as const) { + const originalReal = real[index]!; + const originalImag = imag[index]!; + real[index] = originalReal * cosine - originalImag * sine; + imag[index] = originalReal * sine + originalImag * cosine; + } + } + } + } + return { eThetaReal: thetaReal, eThetaImag: thetaImag, ePhiReal: phiReal, ePhiImag: phiImag }; +} + +/** Restore the far-zone phase removed by centering a symmetric XY model. */ +export function rephaseFarField( + result: FarFieldResult, + centerM: readonly [number, number], +): FarFieldResult { + return { ...result, ...rephaseArrays(result, centerM, 1) }; +} + +function failureReason(error: unknown): SymmetryFailureReason | undefined { + if (!(error instanceof NecError)) { + return undefined; + } + const value = error.details?.symmetryFailure; + return value === "INCOMPATIBLE_GROUND" + || value === "INCOMPLETE_LOAD_ORBIT" + || value === "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM" + ? value + : undefined; +} + +function retryReason(value: SymmetryFailureReason): SymmetrizationReason { + const code: SymmetrizationReasonCode = value === "INCOMPATIBLE_GROUND" + ? "GROUND_BREAKS_SYMMETRY" + : value === "INCOMPLETE_LOAD_ORBIT" + ? "UNSYMMETRIC_LOAD" + : "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM"; + return Object.freeze({ + code, + message: `Symmetric construction failed with ${value}; rebuilt the unchanged explicit model`, + }); +} + +function explicitRetryPlan( + description: FullArrayDescription, + previous: ArrayBuildPlan, + failure: SymmetryFailureReason, +): Extract { + const base = createExplicitArrayBuildPlan(description); + const reason = retryReason(failure); + const reasons = Object.freeze([reason]); + const diagnostics = Object.freeze({ + ...base.diagnostics, + candidates: previous.diagnostics.candidates, + reasons, + }); + return Object.freeze({ ...base, reasons, diagnostics }); +} + +async function buildWorker( + description: FullArrayDescription, + plan: ArrayBuildPlan, +): Promise<{ readonly model: NecWorkerModel; readonly application: AppliedArrayBuildPlan }> { + const model = await createNecWorkerModel(); + try { + return { model, application: await applyArrayBuildPlan(model, description, plan) }; + } catch (error) { + await model.dispose(); + throw error; + } +} + +class WorkerNecArraySolver implements NecArraySolver { + #model: NecWorkerModel; + #plan: ArrayBuildPlan; + #application: AppliedArrayBuildPlan; + readonly #description: FullArrayDescription; + readonly #mode: "auto" | "off" | "require"; + #retried = false; + + constructor( + model: NecWorkerModel, + description: FullArrayDescription, + plan: ArrayBuildPlan, + application: AppliedArrayBuildPlan, + mode: "auto" | "off" | "require", + ) { + this.#model = model; + this.#description = description; + this.#plan = plan; + this.#application = application; + this.#mode = mode; + } + + get state(): NecModelState { + return this.#model.state; + } + + async prepare(options: PrepareOptions): Promise { + try { + await this.#model.prepare(options); + } catch (error) { + const failure = failureReason(error); + if (this.#mode !== "auto" || this.#plan.kind !== "symmetric" + || this.#retried || failure === undefined) { + throw error; + } + this.#retried = true; + await this.#model.dispose(); + const retryPlan = explicitRetryPlan(this.#description, this.#plan, failure); + const built = await buildWorker(this.#description, retryPlan); + this.#model = built.model; + this.#plan = retryPlan; + this.#application = built.application; + await this.#model.prepare(options); + } + } + + async computeImpedanceMatrix(): Promise { + const result = await this.#model.computeImpedanceMatrix(); + const scatter = this.#application.scatterCallerToNative; + return { + impedance: gatherComplexMatrix(result.impedance, scatter), + admittance: gatherComplexMatrix(result.admittance, scatter), + ...(result.conditionEstimate === undefined ? {} : { conditionEstimate: result.conditionEstimate }), + frequencyMHz: result.frequencyMHz, + factorizationGeneration: result.factorizationGeneration, + }; + } + + async #solve(drive: "voltage" | "current", vector: ComplexVector): Promise { + const scatter = this.#application.scatterCallerToNative; + const nativeVector = scatterComplexVector(vector, scatter); + const result = drive === "voltage" + ? await this.#model.solveVoltages(nativeVector) + : await this.#model.solveCurrents(nativeVector); + const powersW = new Float64Array(scatter.length); + for (let caller = 0; caller < scatter.length; caller += 1) { + powersW[caller] = result.powersW[scatter[caller]!]!; + } + return { + drive: result.drive, + frequencyMHz: result.frequencyMHz, + ports: this.#application.callerPorts, + requested: gatherComplexVector(result.requested, scatter), + voltages: gatherComplexVector(result.voltages, scatter), + currents: gatherComplexVector(result.currents, scatter), + activeImpedances: gatherComplexVector(result.activeImpedances, scatter), + powersW, + factorizationGeneration: result.factorizationGeneration, + solveGeneration: result.solveGeneration, + }; + } + + solveVoltages(voltages: ComplexVector): Promise { + return this.#solve("voltage", voltages); + } + + solveCurrents(currents: ComplexVector): Promise { + return this.#solve("current", currents); + } + + async computeFarField(request: FarFieldRequest): Promise { + const result = await this.#model.computeFarField(request); + const center = this.#plan.kind === "symmetric" ? this.#plan.centerM : [0, 0] as const; + return rephaseFarField(result, center); + } + + async computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise { + const result = await this.#model.computeEmbeddedFarFields(request, normalization); + const scatter = this.#application.scatterCallerToNative; + const gathered: EmbeddedFarFieldResult = { + ...result, + ports: this.#application.callerPorts, + eThetaReal: gatherEmbeddedBasis(result.eThetaReal, result.samplesPerPort, scatter), + eThetaImag: gatherEmbeddedBasis(result.eThetaImag, result.samplesPerPort, scatter), + ePhiReal: gatherEmbeddedBasis(result.ePhiReal, result.samplesPerPort, scatter), + ePhiImag: gatherEmbeddedBasis(result.ePhiImag, result.samplesPerPort, scatter), + }; + const center = this.#plan.kind === "symmetric" ? this.#plan.centerM : [0, 0] as const; + return { ...gathered, ...rephaseArrays(gathered, center, scatter.length) }; + } + + dispose(): Promise { + return this.#model.dispose(); + } + + getDiagnostics(): ArraySolverDiagnostics { + return Object.freeze({ + representation: this.#plan.kind, + planner: this.#plan.diagnostics, + ...(this.#application.completion.symmetry === undefined + ? {} + : { symmetry: this.#application.completion.symmetry }), + }); + } +} + +/** Create one asynchronous solver facade for explicit and symmetric arrays. */ +export async function createNecArraySolver( + description: FullArrayDescription, + options: CreateArraySolverOptions = {}, +): Promise { + if (typeof options !== "object" || options === null) { + throw new NecInputError("Array solver options must be an object"); + } + const mode = options.symmetry ?? "auto"; + if (mode !== "auto" && mode !== "off" && mode !== "require") { + throw new NecInputError("Unknown array symmetry mode", { details: { mode } }); + } + if (mode !== "off" && options.symmetrizer === undefined) { + throw new NecInputError( + "Automatic array symmetry requires an explicit symmetrizer.positionEpsilonM", + ); + } + let plan = mode === "off" + ? createExplicitArrayBuildPlan(description) + : analyzeArraySymmetry(description, options.symmetrizer!); + if (mode === "require" && plan.kind !== "symmetric") { + throw new NecGeometryError("The array cannot be represented by supported symmetry", { + details: { reasons: plan.reasons }, + }); + } + try { + const built = await buildWorker(description, plan); + return new WorkerNecArraySolver(built.model, description, plan, built.application, mode); + } catch (error) { + const failure = failureReason(error); + if (mode !== "auto" || plan.kind !== "symmetric" || failure === undefined) { + throw error; + } + plan = explicitRetryPlan(description, plan, failure); + const built = await buildWorker(description, plan); + return new WorkerNecArraySolver(built.model, description, plan, built.application, mode); + } +} diff --git a/packages/necpp-wasm/src/array-symmetry.ts b/packages/necpp-wasm/src/array-symmetry.ts new file mode 100644 index 00000000..22979704 --- /dev/null +++ b/packages/necpp-wasm/src/array-symmetry.ts @@ -0,0 +1,965 @@ +import { NecGeometryError, NecInputError } from "./errors.js"; +import { + createSymmetryExpansion, + validateGeometrySymmetry, +} from "./symmetry.js"; +import type { + ArrayBuildPlan, + ArrayElementId, + ArrayElementMapping, + CanonicalArrayElement, + ElementWirePattern, + FullArrayDescription, + GeometrySymmetry, + PositionedArrayElement, + PositionCanonicalization, + RotationalOrder, + SymmetrizationReason, + SymmetrizationReasonCode, + SymmetrizerDiagnostics, + SymmetrizerOptions, + SymmetryCandidateDiagnostics, + SymmetryCopyTransform, +} from "./types.js"; + +const INT32_MAX = 2_147_483_647; +const AUTO_ROTATION_ORDER_CAP = 64; + +type Point2 = readonly [number, number]; + +interface ValidatedDescription { + readonly description: FullArrayDescription; + readonly patterns: ReadonlyMap; + readonly callerPortStarts: readonly number[]; +} + +interface CandidateSpec { + readonly symmetry: GeometrySymmetry; + readonly centerM: Point2; + readonly transforms: readonly SymmetryCopyTransform[]; + readonly ordinal: number; +} + +interface MatchedCandidate { + readonly spec: CandidateSpec; + readonly mappingsByCopy: readonly (readonly number[])[]; +} + +interface CanonicalOrbit { + readonly representativeIndex: number; + readonly canonicalBase: Point2; + readonly targetsByCopy: readonly number[]; + readonly canonicalByCaller: ReadonlyMap; +} + +interface AcceptedCandidate { + readonly plan: Extract; + readonly ordinal: number; +} + +function inputError(message: string, details: Readonly> = {}): never { + throw new NecInputError(message, { details }); +} + +function point(x: number, y: number): Point2 { + return Object.freeze([x, y] as [number, number]); +} + +function finite(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + inputError(`${name} must be finite`, { name, value }); + } + return value; +} + +function positiveInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + inputError(`${name} must be a positive safe integer`, { name, value }); + } + return value as number; +} + +function reason( + code: SymmetrizationReasonCode, + message: string, + callerElementIndex?: number, + patternId?: string, +): SymmetrizationReason { + return Object.freeze({ + code, + message, + ...(callerElementIndex === undefined ? {} : { callerElementIndex }), + ...(patternId === undefined ? {} : { patternId }), + }); +} + +function validateGround(description: FullArrayDescription): void { + const ground = description.ground as unknown; + if (typeof ground !== "object" || ground === null || Array.isArray(ground)) { + inputError("description.ground must be an object"); + } + const record = ground as Readonly>; + if (record.kind === "free-space" || record.kind === "perfect") { + return; + } + if (record.kind !== "finite") { + inputError("description.ground has an unknown kind", { kind: record.kind }); + } + if ( + record.method !== "reflection-coefficient" + && record.method !== "sommerfeld-norton" + ) { + inputError("description.ground has an unknown finite-ground method", { + method: record.method, + }); + } + if (finite(record.relativePermittivity, "ground.relativePermittivity") <= 0) { + inputError("ground.relativePermittivity must be positive"); + } + if (finite(record.conductivitySPerM, "ground.conductivitySPerM") <= 0) { + inputError("ground.conductivitySPerM must be positive"); + } +} + +function validateDescription(description: FullArrayDescription): ValidatedDescription { + if (typeof description !== "object" || description === null) { + inputError("Array description must be an object"); + } + if (!Array.isArray(description.elements) || description.elements.length === 0) { + inputError("Array description requires at least one element"); + } + if (!Array.isArray(description.patterns) || description.patterns.length === 0) { + inputError("Array description requires at least one element pattern"); + } + validateGround(description); + + const patterns = new Map(); + for (let patternIndex = 0; patternIndex < description.patterns.length; patternIndex += 1) { + const pattern = description.patterns[patternIndex]!; + if (typeof pattern !== "object" || pattern === null) { + inputError(`patterns[${patternIndex}] must be an object`); + } + if (typeof pattern.id !== "string" || pattern.id.length === 0) { + inputError(`patterns[${patternIndex}].id must be a nonempty string`); + } + if (patterns.has(pattern.id)) { + inputError("Pattern IDs must be unique", { patternId: pattern.id }); + } + if (!Array.isArray(pattern.wires) || pattern.wires.length === 0) { + inputError(`patterns[${patternIndex}].wires must be nonempty`); + } + if (!Array.isArray(pattern.ports) || pattern.ports.length === 0) { + inputError(`patterns[${patternIndex}].ports must be nonempty`); + } + const wireIds = new Map(); + for (let wireIndex = 0; wireIndex < pattern.wires.length; wireIndex += 1) { + const wire = pattern.wires[wireIndex]!; + if (typeof wire.id !== "string" || wire.id.length === 0 || wireIds.has(wire.id)) { + inputError("Pattern wire IDs must be nonempty and unique", { + patternId: pattern.id, + wireId: wire.id, + }); + } + wireIds.set(wire.id, wireIndex); + positiveInteger(wire.segments, `patterns[${patternIndex}].wires[${wireIndex}].segments`); + if (!Array.isArray(wire.startM) || wire.startM.length !== 3 + || !Array.isArray(wire.endM) || wire.endM.length !== 3) { + inputError("Pattern wire endpoints must contain three coordinates", { + patternId: pattern.id, + wireId: wire.id, + }); + } + for (let coordinate = 0; coordinate < 3; coordinate += 1) { + finite( + wire.startM[coordinate], + `patterns[${patternIndex}].wires[${wireIndex}].startM[${coordinate}]`, + ); + finite( + wire.endM[coordinate], + `patterns[${patternIndex}].wires[${wireIndex}].endM[${coordinate}]`, + ); + } + if (finite(wire.radiusM, `patterns[${patternIndex}].wires[${wireIndex}].radiusM`) <= 0) { + inputError("Pattern wire radius must be positive", { patternId: pattern.id }); + } + } + for (let portIndex = 0; portIndex < pattern.ports.length; portIndex += 1) { + const port = pattern.ports[portIndex]!; + const wireIndex = wireIds.get(port.wireId); + if (wireIndex === undefined) { + inputError("Pattern port references an unknown wire", { + patternId: pattern.id, + wireId: port.wireId, + }); + } + const segment = positiveInteger( + port.segment, + `patterns[${patternIndex}].ports[${portIndex}].segment`, + ); + if (segment > pattern.wires[wireIndex]!.segments) { + inputError("Pattern port segment exceeds its wire segment count", { + patternId: pattern.id, + wireId: port.wireId, + segment, + }); + } + if (port.name !== undefined && typeof port.name !== "string") { + inputError("Pattern port name must be a string", { patternId: pattern.id }); + } + } + for (const load of pattern.loads ?? []) { + if (wireIds.get(load.target.wireId) === undefined) { + inputError("Pattern load references an unknown wire", { + patternId: pattern.id, + wireId: load.target.wireId, + }); + } + } + patterns.set(pattern.id, pattern); + } + + const ids = new Set(); + const callerPortStarts: number[] = []; + let portCount = 0; + for (let index = 0; index < description.elements.length; index += 1) { + const element = description.elements[index]!; + if ((typeof element.id !== "string" && typeof element.id !== "number") + || (typeof element.id === "number" && !Number.isFinite(element.id))) { + inputError(`elements[${index}].id must be a string or finite number`); + } + if (ids.has(element.id)) { + inputError("Element IDs must be unique", { elementId: element.id }); + } + ids.add(element.id); + if (!Array.isArray(element.positionM) || element.positionM.length !== 2) { + inputError(`elements[${index}].positionM must contain two coordinates`); + } + finite(element.positionM[0], `elements[${index}].positionM[0]`); + finite(element.positionM[1], `elements[${index}].positionM[1]`); + const pattern = patterns.get(element.patternId); + if (pattern === undefined) { + inputError("Element references an unknown pattern", { + callerElementIndex: index, + patternId: element.patternId, + }); + } + if (element.rotationDeg !== undefined) { + finite(element.rotationDeg, `elements[${index}].rotationDeg`); + } + callerPortStarts.push(portCount); + portCount += pattern.ports.length; + } + return Object.freeze({ + description, + patterns, + callerPortStarts: Object.freeze(callerPortStarts), + }); +} + +function validateOptions(options: SymmetrizerOptions): Required> & SymmetrizerOptions { + if (typeof options !== "object" || options === null) { + inputError("Symmetrizer options must be supplied"); + } + const epsilon = finite(options.positionEpsilonM, "positionEpsilonM"); + if (epsilon < 0) { + inputError("positionEpsilonM must be nonnegative"); + } + if (options.center !== undefined && options.center !== "auto") { + if (!Array.isArray(options.center) || options.center.length !== 2) { + inputError("symmetrizer.center must be auto or two finite coordinates"); + } + finite(options.center[0], "symmetrizer.center[0]"); + finite(options.center[1], "symmetrizer.center[1]"); + } + if (options.allowReflection !== undefined && typeof options.allowReflection !== "boolean") { + inputError("allowReflection must be boolean"); + } + if (options.allowRotation !== undefined && typeof options.allowRotation !== "boolean") { + inputError("allowRotation must be boolean"); + } + if (options.onUnsupported !== undefined + && options.onUnsupported !== "explicit-fallback" + && options.onUnsupported !== "error") { + inputError("onUnsupported has an unknown value"); + } + for (const order of options.preferredRotationOrders ?? []) { + if (!Number.isSafeInteger(order) || order < 2 || order > INT32_MAX) { + inputError("preferredRotationOrders contains an invalid order", { order }); + } + } + return { + ...options, + positionEpsilonM: epsilon, + allowReflection: options.allowReflection ?? true, + allowRotation: options.allowRotation ?? true, + onUnsupported: options.onUnsupported ?? "explicit-fallback", + }; +} + +function canonicalElement(element: PositionedArrayElement, positionM = element.positionM): CanonicalArrayElement { + return Object.freeze({ + id: element.id, + positionM: point(positionM[0], positionM[1]), + patternId: element.patternId, + rotationDeg: 0 as const, + }); +} + +function zeroCanonicalizations(elements: readonly PositionedArrayElement[]): readonly PositionCanonicalization[] { + return Object.freeze(elements.map((element, callerElementIndex) => Object.freeze({ + callerElementIndex, + originalPositionM: point(element.positionM[0], element.positionM[1]), + canonicalPositionM: point(element.positionM[0], element.positionM[1]), + adjustmentM: point(0, 0), + distanceM: 0, + }))); +} + +function bboxCenter(elements: readonly PositionedArrayElement[]): Point2 { + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const element of elements) { + minX = Math.min(minX, element.positionM[0]); + maxX = Math.max(maxX, element.positionM[0]); + minY = Math.min(minY, element.positionM[1]); + maxY = Math.max(maxY, element.positionM[1]); + } + return point((minX + maxX) / 2, (minY + maxY) / 2); +} + +function centroid(elements: readonly PositionedArrayElement[]): Point2 { + let x = 0; + let y = 0; + for (const element of elements) { + x += element.positionM[0]; + y += element.positionM[1]; + } + return point(x / elements.length, y / elements.length); +} + +function explicitPlan( + validated: ValidatedDescription, + centerM: Point2, + reasons: readonly SymmetrizationReason[], + candidates: readonly SymmetryCandidateDiagnostics[], +): Extract { + const canonicalizations = zeroCanonicalizations(validated.description.elements); + const frozenReasons = Object.freeze([...reasons]); + const diagnostics: SymmetrizerDiagnostics = Object.freeze({ + representation: "explicit", + exact: true, + effectiveCenterM: centerM, + maxPositionAdjustmentM: 0, + canonicalizations, + candidates: Object.freeze([...candidates]), + reasons: frozenReasons, + }); + return Object.freeze({ + kind: "explicit", + elements: Object.freeze(validated.description.elements.map((element) => canonicalElement(element))), + reasons: frozenReasons, + diagnostics, + }); +} + +/** Build an explicit, identity-mapped plan after validating the full description. */ +export function createExplicitArrayBuildPlan( + description: FullArrayDescription, +): Extract { + const validated = validateDescription(description); + return explicitPlan(validated, bboxCenter(description.elements), Object.freeze([]), Object.freeze([])); +} + +function unsupportedPatternReasons(validated: ValidatedDescription): readonly SymmetrizationReason[] { + const result: SymmetrizationReason[] = []; + const reported = new Set(); + for (let index = 0; index < validated.description.elements.length; index += 1) { + const element = validated.description.elements[index]!; + const pattern = validated.patterns.get(element.patternId)!; + let message: string | undefined; + if ((pattern as { readonly kind?: unknown }).kind !== "straight-wire-pattern") { + message = `Pattern ${pattern.id} is not a supported straight-wire pattern`; + } else if (element.rotationDeg !== undefined && element.rotationDeg !== 0) { + message = `Element ${String(element.id)} has a nonzero local rotation`; + } else { + const wire = pattern.wires.find((candidate) => + candidate.startM[0] !== 0 || candidate.startM[1] !== 0 + || candidate.endM[0] !== 0 || candidate.endM[1] !== 0); + if (wire !== undefined) { + message = `Pattern ${pattern.id} wire ${wire.id} is not pointwise on the local Z axis`; + } + } + if (message !== undefined && !reported.has(`${pattern.id}:${message}`)) { + reported.add(`${pattern.id}:${message}`); + result.push(reason( + "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM", + message, + index, + pattern.id, + )); + } + } + return Object.freeze(result); +} + +function reflectionTransforms(planes: readonly ("x=0" | "y=0")[]): readonly SymmetryCopyTransform[] { + const transforms: SymmetryCopyTransform[] = [Object.freeze({ + kind: "cartesian-signs", + signs: Object.freeze([1, 1, 1] as [1, 1, 1]), + })]; + for (const plane of ["y=0", "x=0"] as const) { + if (!planes.includes(plane)) { + continue; + } + const count = transforms.length; + for (let index = 0; index < count; index += 1) { + const signs = Array.from( + (transforms[index]! as { readonly signs: readonly [1 | -1, 1 | -1, 1 | -1] }).signs, + ) as [1 | -1, 1 | -1, 1 | -1]; + const coordinate = plane === "x=0" ? 0 : 1; + signs[coordinate] = signs[coordinate] === 1 ? -1 : 1; + transforms.push(Object.freeze({ + kind: "cartesian-signs", + signs: Object.freeze(signs), + })); + } + } + return Object.freeze(transforms); +} + +function rotationTransforms(order: number): readonly SymmetryCopyTransform[] { + return Object.freeze(Array.from({ length: order }, (_, index) => Object.freeze({ + kind: "rotate-z" as const, + angleDeg: index * 360 / order, + }))); +} + +function candidateSpecs( + validated: ValidatedDescription, + options: ReturnType, +): readonly CandidateSpec[] { + const elements = validated.description.elements; + const suppliedCenter = options.center !== undefined && options.center !== "auto" + ? point(options.center[0], options.center[1]) + : undefined; + const reflectionCenter = suppliedCenter ?? bboxCenter(elements); + const rotationCenter = suppliedCenter ?? centroid(elements); + const result: CandidateSpec[] = []; + let ordinal = 0; + if (options.allowReflection) { + for (const planes of [ + ["x=0", "y=0"], + ["x=0"], + ["y=0"], + ] as const) { + result.push(Object.freeze({ + symmetry: Object.freeze({ + kind: "reflection" as const, + planes: Object.freeze([...planes]) as unknown as readonly ["x=0" | "y=0", ...(readonly ("x=0" | "y=0")[])], + tagIncrement: 1, + }), + centerM: reflectionCenter, + transforms: reflectionTransforms(planes), + ordinal, + })); + ordinal += 1; + } + } + if (options.allowRotation) { + const configured = options.preferredRotationOrders; + const orders = configured === undefined + ? Array.from({ length: Math.min(elements.length, AUTO_ROTATION_ORDER_CAP) - 1 }, (_, index) => index + 2) + .filter((order) => elements.length % order === 0) + .sort((left, right) => right - left) + : [...new Set(configured)]; + for (const order of orders) { + result.push(Object.freeze({ + symmetry: Object.freeze({ + kind: "rotational" as const, + axis: "z" as const, + order: order as RotationalOrder, + tagIncrement: 1, + }), + centerM: rotationCenter, + transforms: rotationTransforms(order), + ordinal, + })); + ordinal += 1; + } + } + return Object.freeze(result); +} + +function applyTransform(position: Point2, transform: SymmetryCopyTransform): Point2 { + if (transform.kind === "cartesian-signs") { + return point(position[0] * transform.signs[0], position[1] * transform.signs[1]); + } + const angle = transform.angleDeg * Math.PI / 180; + const snapUnit = (value: number): number => { + const nearest = Math.round(value); + return Math.abs(value - nearest) <= 8 * Number.EPSILON ? nearest : value; + }; + const cosine = snapUnit(Math.cos(angle)); + const sine = snapUnit(Math.sin(angle)); + return point( + cosine * position[0] - sine * position[1], + sine * position[0] + cosine * position[1], + ); +} + +function inverseTransform(position: Point2, transform: SymmetryCopyTransform): Point2 { + if (transform.kind === "cartesian-signs") { + return applyTransform(position, transform); + } + return applyTransform(position, Object.freeze({ + kind: "rotate-z", + angleDeg: -transform.angleDeg, + })); +} + +function matchCandidate( + validated: ValidatedDescription, + spec: CandidateSpec, + epsilon: number, +): MatchedCandidate | readonly SymmetrizationReason[] { + const elements = validated.description.elements; + // Build the hash locally so centered coordinates remain available for exact + // distance checks without mutating or normalizing caller input. + const centered = elements.map((element) => point( + element.positionM[0] - spec.centerM[0], + element.positionM[1] - spec.centerM[1], + )); + const cellSize = epsilon === 0 ? 0 : epsilon; + const cells = new Map(); + const key = (position: Point2): string => cellSize === 0 + ? `${Object.is(position[0], -0) ? 0 : position[0]}:${Object.is(position[1], -0) ? 0 : position[1]}` + : `${Math.floor(position[0] / cellSize)}:${Math.floor(position[1] / cellSize)}`; + centered.forEach((position, index) => { + const indices = cells.get(key(position)); + if (indices === undefined) { + cells.set(key(position), [index]); + } else { + indices.push(index); + } + }); + const near = (position: Point2): readonly number[] => { + if (epsilon === 0) { + return cells.get(key(position)) ?? []; + } + const x = Math.floor(position[0] / cellSize); + const y = Math.floor(position[1] / cellSize); + const found: number[] = []; + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + for (const index of cells.get(`${x + dx}:${y + dy}`) ?? []) { + const deltaX = centered[index]![0] - position[0]; + const deltaY = centered[index]![1] - position[1]; + if (Math.hypot(deltaX, deltaY) <= epsilon) { + found.push(index); + } + } + } + } + return found; + }; + + const mappingsByCopy: number[][] = []; + for (let copyIndex = 0; copyIndex < spec.transforms.length; copyIndex += 1) { + const transform = spec.transforms[copyIndex]!; + const mapping: number[] = []; + const targets = new Set(); + for (let sourceIndex = 0; sourceIndex < elements.length; sourceIndex += 1) { + const expected = applyTransform(centered[sourceIndex]!, transform); + const nearby = near(expected); + const compatible = nearby.filter((targetIndex) => { + const source = elements[sourceIndex]!; + const target = elements[targetIndex]!; + return source.patternId === target.patternId + && (source.rotationDeg ?? 0) === (target.rotationDeg ?? 0); + }); + if (compatible.length === 0) { + if (nearby.length > 0) { + return Object.freeze([reason( + "PATTERN_MISMATCH", + `Candidate transform maps element ${sourceIndex} only to an incompatible pattern`, + sourceIndex, + elements[sourceIndex]!.patternId, + )]); + } + return Object.freeze([reason( + "POSITION_OUTSIDE_EPSILON", + `Candidate transform has no unique position match within ${epsilon} metres`, + sourceIndex, + )]); + } + if (compatible.length > 1) { + return Object.freeze([reason( + "AMBIGUOUS_POSITION_MATCH", + `Candidate transform has ${compatible.length} position matches within epsilon`, + sourceIndex, + )]); + } + const targetIndex = compatible[0]!; + if (targets.has(targetIndex)) { + return Object.freeze([reason( + "AMBIGUOUS_POSITION_MATCH", + "Candidate transform is not a one-to-one permutation", + sourceIndex, + )]); + } + targets.add(targetIndex); + mapping.push(targetIndex); + } + mappingsByCopy.push(mapping); + } + + for (let sourceIndex = 0; sourceIndex < elements.length; sourceIndex += 1) { + const orbit = new Set(mappingsByCopy.map((mapping) => mapping[sourceIndex]!)); + if (orbit.size !== spec.transforms.length) { + const code = spec.symmetry.kind === "reflection" + ? "FIXED_ELEMENT_ON_REFLECTION_PLANE" + : "FIXED_ELEMENT_ON_ROTATION_AXIS"; + return Object.freeze([reason( + code, + spec.symmetry.kind === "reflection" + ? "An element is fixed by a generating reflection plane" + : "An element is fixed by the rotation axis or a lower-order subgroup", + sourceIndex, + )]); + } + } + return Object.freeze({ + spec, + mappingsByCopy: Object.freeze(mappingsByCopy.map((mapping) => Object.freeze(mapping))), + }); +} + +function representativeForOrbit( + orbit: readonly number[], + centered: readonly Point2[], + spec: CandidateSpec, + epsilon: number, +): number { + if (spec.symmetry.kind === "reflection") { + const planes = new Set(spec.symmetry.planes); + const inFundamentalSection = orbit.filter((index) => + (!planes.has("x=0") || centered[index]![0] > epsilon) + && (!planes.has("y=0") || centered[index]![1] > epsilon)); + if (inFundamentalSection.length === 1) { + return inFundamentalSection[0]!; + } + } else { + const step = 2 * Math.PI / spec.symmetry.order; + const inFundamentalSection = orbit.map((index) => { + const position = centered[index]!; + const angle = (Math.atan2(position[1], position[0]) + 2 * Math.PI) % (2 * Math.PI); + return { index, remainder: angle % step }; + }).sort((left, right) => left.remainder - right.remainder || left.index - right.index); + return inFundamentalSection[0]!.index; + } + return [...orbit].sort((left, right) => + centered[right]![1] - centered[left]![1] + || centered[right]![0] - centered[left]![0] + || String(spec.symmetry.kind).localeCompare(String(spec.symmetry.kind)))[0]!; +} + +function buildAcceptedCandidate( + validated: ValidatedDescription, + matched: MatchedCandidate, + epsilon: number, +): AcceptedCandidate | readonly SymmetrizationReason[] { + const { spec, mappingsByCopy } = matched; + const elements = validated.description.elements; + const centered = elements.map((element) => point( + element.positionM[0] - spec.centerM[0], + element.positionM[1] - spec.centerM[1], + )); + const visited = new Set(); + const orbits: CanonicalOrbit[] = []; + for (let seed = 0; seed < elements.length; seed += 1) { + if (visited.has(seed)) { + continue; + } + const initialOrbit = mappingsByCopy.map((mapping) => mapping[seed]!); + initialOrbit.forEach((index) => visited.add(index)); + const representativeIndex = representativeForOrbit(initialOrbit, centered, spec, epsilon); + const targetsByCopy = mappingsByCopy.map((mapping) => mapping[representativeIndex]!); + let sumX = 0; + let sumY = 0; + for (let copyIndex = 0; copyIndex < spec.transforms.length; copyIndex += 1) { + const inverse = inverseTransform(centered[targetsByCopy[copyIndex]!]!, spec.transforms[copyIndex]!); + sumX += inverse[0]; + sumY += inverse[1]; + } + const canonicalBase = point(sumX / spec.transforms.length, sumY / spec.transforms.length); + const canonicalByCaller = new Map(); + for (let copyIndex = 0; copyIndex < spec.transforms.length; copyIndex += 1) { + canonicalByCaller.set( + targetsByCopy[copyIndex]!, + applyTransform(canonicalBase, spec.transforms[copyIndex]!), + ); + } + orbits.push(Object.freeze({ + representativeIndex, + canonicalBase, + targetsByCopy: Object.freeze(targetsByCopy), + canonicalByCaller, + })); + } + orbits.sort((left, right) => + left.canonicalBase[1] - right.canonicalBase[1] + || left.canonicalBase[0] - right.canonicalBase[0] + || elements[left.representativeIndex]!.patternId.localeCompare(elements[right.representativeIndex]!.patternId) + || String(elements[left.representativeIndex]!.id).localeCompare(String(elements[right.representativeIndex]!.id))); + + const canonicalByCaller = new Map(); + for (const orbit of orbits) { + for (const [index, position] of orbit.canonicalByCaller) { + canonicalByCaller.set(index, position); + } + } + const canonicalizations: PositionCanonicalization[] = []; + let maxAdjustment = 0; + for (let index = 0; index < elements.length; index += 1) { + const canonicalRelative = canonicalByCaller.get(index)!; + const canonicalAbsolute = point( + canonicalRelative[0] + spec.centerM[0], + canonicalRelative[1] + spec.centerM[1], + ); + const original = elements[index]!.positionM; + const adjustment = point( + canonicalAbsolute[0] - original[0], + canonicalAbsolute[1] - original[1], + ); + const distance = Math.hypot(adjustment[0], adjustment[1]); + if (distance > epsilon) { + return Object.freeze([reason( + "POSITION_OUTSIDE_EPSILON", + `Canonicalization adjustment ${distance} exceeds ${epsilon} metres`, + index, + )]); + } + maxAdjustment = Math.max(maxAdjustment, distance); + canonicalizations.push(Object.freeze({ + callerElementIndex: index, + originalPositionM: point(original[0], original[1]), + canonicalPositionM: canonicalAbsolute, + adjustmentM: adjustment, + distanceM: distance, + })); + } + for (let left = 0; left < elements.length; left += 1) { + for (let right = left + 1; right < elements.length; right += 1) { + const a = canonicalByCaller.get(left)!; + const b = canonicalByCaller.get(right)!; + if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon) { + return Object.freeze([reason( + "AMBIGUOUS_POSITION_MATCH", + "Canonical symmetric positions collide within epsilon", + left, + )]); + } + } + } + + let tagIncrement = 0; + let fundamentalPortCount = 0; + const baseTags: number[] = []; + const basePortIndices: number[] = []; + for (const orbit of orbits) { + const pattern = validated.patterns.get(elements[orbit.representativeIndex]!.patternId)!; + baseTags.push(tagIncrement + 1); + basePortIndices.push(fundamentalPortCount); + tagIncrement += pattern.wires.length; + fundamentalPortCount += pattern.ports.length; + } + if (tagIncrement + (spec.transforms.length - 1) * tagIncrement > INT32_MAX) { + return Object.freeze([reason( + "TAG_SPACE_EXHAUSTED", + "Symmetry-generated wire tags exceed the signed 32-bit range", + )]); + } + const symmetry: GeometrySymmetry = spec.symmetry.kind === "reflection" + ? Object.freeze({ + kind: "reflection", + planes: spec.symmetry.planes, + tagIncrement, + }) + : Object.freeze({ + kind: "rotational", + axis: "z", + order: spec.symmetry.order, + tagIncrement, + }); + const mappings: ArrayElementMapping[] = new Array(elements.length); + const fundamentalElements: CanonicalArrayElement[] = []; + for (let fundamentalElementIndex = 0; fundamentalElementIndex < orbits.length; fundamentalElementIndex += 1) { + const orbit = orbits[fundamentalElementIndex]!; + const representative = elements[orbit.representativeIndex]!; + const pattern = validated.patterns.get(representative.patternId)!; + fundamentalElements.push(canonicalElement(representative, orbit.canonicalBase)); + for (let copyIndex = 0; copyIndex < orbit.targetsByCopy.length; copyIndex += 1) { + const callerElementIndex = orbit.targetsByCopy[copyIndex]!; + const callerPortStart = validated.callerPortStarts[callerElementIndex]!; + const generatedPortStart = copyIndex * fundamentalPortCount + basePortIndices[fundamentalElementIndex]!; + mappings[callerElementIndex] = Object.freeze({ + callerElementIndex, + callerElementId: elements[callerElementIndex]!.id, + fundamentalElementIndex, + copyIndex, + generatedTag: baseTags[fundamentalElementIndex]! + copyIndex * tagIncrement, + callerPortIndices: Object.freeze(Array.from( + { length: pattern.ports.length }, + (_, portIndex) => callerPortStart + portIndex, + )), + generatedPortIndices: Object.freeze(Array.from( + { length: pattern.ports.length }, + (_, portIndex) => generatedPortStart + portIndex, + )), + positionAdjustmentM: canonicalizations[callerElementIndex]!.adjustmentM, + }); + } + } + const validatedSymmetry = validateGeometrySymmetry(symmetry, tagIncrement); + const fullExpansion = createSymmetryExpansion(validatedSymmetry, 0, 0); + const expansion = Object.freeze({ + kind: fullExpansion.kind, + sectionCount: fullExpansion.sectionCount, + copies: fullExpansion.copies, + }); + const diagnostics: SymmetrizerDiagnostics = Object.freeze({ + representation: "symmetric", + exact: maxAdjustment === 0, + effectiveCenterM: spec.centerM, + maxPositionAdjustmentM: maxAdjustment, + canonicalizations: Object.freeze(canonicalizations), + candidates: Object.freeze([]), + reasons: Object.freeze([]), + }); + return Object.freeze({ + ordinal: spec.ordinal, + plan: Object.freeze({ + kind: "symmetric", + centerM: spec.centerM, + symmetry, + expansion, + fundamentalElements: Object.freeze(fundamentalElements), + mappings: Object.freeze(mappings), + maxPositionAdjustmentM: maxAdjustment, + diagnostics, + }), + }); +} + +function uniqueReasons(values: readonly SymmetrizationReason[]): readonly SymmetrizationReason[] { + const seen = new Set(); + const result: SymmetrizationReason[] = []; + for (const value of values) { + const key = `${value.code}:${value.callerElementIndex ?? ""}:${value.patternId ?? ""}`; + if (!seen.has(key)) { + seen.add(key); + result.push(value); + } + } + return Object.freeze(result); +} + +/** Analyze a complete positioned array without mutating it or constructing WASM state. */ +export function analyzeArraySymmetry( + description: FullArrayDescription, + options: SymmetrizerOptions, +): ArrayBuildPlan { + const validated = validateDescription(description); + const validatedOptions = validateOptions(options); + const unsupported = unsupportedPatternReasons(validated); + const fallbackCenter = validatedOptions.center !== undefined && validatedOptions.center !== "auto" + ? point(validatedOptions.center[0], validatedOptions.center[1]) + : bboxCenter(description.elements); + if (unsupported.length > 0) { + if (validatedOptions.onUnsupported === "error") { + throw new NecGeometryError(unsupported[0]!.message, { + details: { + symmetryFailure: "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM", + reason: unsupported[0], + }, + }); + } + return explicitPlan(validated, fallbackCenter, unsupported, Object.freeze([])); + } + + const specs = candidateSpecs(validated, validatedOptions); + const accepted: AcceptedCandidate[] = []; + const diagnostics: SymmetryCandidateDiagnostics[] = []; + const rejectedReasons: SymmetrizationReason[] = []; + for (const spec of specs) { + const matched = matchCandidate(validated, spec, validatedOptions.positionEpsilonM); + if (Array.isArray(matched)) { + const frozenReasons = Object.freeze([...matched]); + rejectedReasons.push(...frozenReasons); + diagnostics.push(Object.freeze({ + symmetry: spec.symmetry, + accepted: false, + reasons: frozenReasons, + })); + continue; + } + const built = buildAcceptedCandidate( + validated, + matched as MatchedCandidate, + validatedOptions.positionEpsilonM, + ); + if (Array.isArray(built)) { + const frozenReasons = Object.freeze([...built]); + rejectedReasons.push(...frozenReasons); + diagnostics.push(Object.freeze({ + symmetry: spec.symmetry, + accepted: false, + reasons: frozenReasons, + })); + continue; + } + const candidate = built as AcceptedCandidate; + accepted.push(candidate); + diagnostics.push(Object.freeze({ + symmetry: candidate.plan.symmetry, + accepted: true, + reasons: Object.freeze([]), + })); + } + if (accepted.length === 0) { + const fixed = rejectedReasons.filter((candidate) => + candidate.code === "FIXED_ELEMENT_ON_REFLECTION_PLANE" + || candidate.code === "FIXED_ELEMENT_ON_ROTATION_AXIS"); + const explanations = uniqueReasons(fixed.length > 0 + ? fixed + : [ + reason("NO_NONTRIVIAL_SYMMETRY", "No supported nontrivial array symmetry was proven"), + ...rejectedReasons, + ]); + return explicitPlan( + validated, + fallbackCenter, + explanations, + Object.freeze(diagnostics), + ); + } + accepted.sort((left, right) => + right.plan.expansion.sectionCount - left.plan.expansion.sectionCount + || (left.plan.symmetry.kind === right.plan.symmetry.kind + ? left.ordinal - right.ordinal + : left.plan.symmetry.kind === "reflection" ? -1 : 1)); + const selected = accepted[0]!.plan; + const plannerDiagnostics: SymmetrizerDiagnostics = Object.freeze({ + ...selected.diagnostics, + candidates: Object.freeze(diagnostics), + }); + return Object.freeze({ + ...selected, + diagnostics: plannerDiagnostics, + }); +} diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 4462f1c5..70795589 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -11,17 +11,34 @@ export { export { abiVersion, engineVersion, packageVersion } from "./versions.js"; export { rotationalOrder } from "./symmetry.js"; +export { analyzeArraySymmetry, createExplicitArrayBuildPlan } from "./array-symmetry.js"; +export { + applyArrayBuildPlan, + createNecArraySolver, + gatherComplexMatrix, + gatherComplexVector, + gatherEmbeddedBasis, + rephaseFarField, + scatterComplexVector, +} from "./array-solver.js"; +export type { AppliedArrayBuildPlan } from "./array-solver.js"; export type { NecErrorCode, NecErrorOptions } from "./errors.js"; export type { AngleSweep, + ArrayBuildPlan, + ArrayElementId, + ArrayElementMapping, + ArraySolverDiagnostics, CartesianSignsTransform, CartesianPointM, + CanonicalArrayElement, CompleteGeometryOptions, ComplexMatrix, ComplexVector, ConductivityLoad, + CreateArraySolverOptions, CreateNecModelOptions, CreateNecWorkerModelOptions, DeckResult, @@ -29,10 +46,12 @@ export type { DistributedSeriesRlcLoad, EmbeddedFarFieldResult, EmbeddedFieldNormalization, + ElementWirePattern, FarFieldRequest, FarFieldResult, FiniteGround, FreeSpaceGround, + FullArrayDescription, GeometryCompletionResult, GeometrySymmetry, GroundConnection, @@ -40,6 +59,7 @@ export type { ImpedanceLoad, ImpedanceResult, LoadDefinition, + NecArraySolver, NecModel, NecModelState, NecWorkerModel, @@ -50,9 +70,15 @@ export type { PerfectGround, PortDefinition, PortSolution, + PositionCanonicalization, + PositionedArrayElement, PrepareOptions, ReflectionPlane, ReflectionSymmetry, + RelativeLoadDefinition, + RelativePortDefinition, + RelativeSegmentSelection, + RelativeWireDefinition, RotationalOrder, RotationalSymmetry, RotateZTransform, @@ -64,6 +90,11 @@ export type { SymmetryExpansion, SymmetryFailureClassification, SymmetryFailureReason, + SymmetrizationReason, + SymmetrizationReasonCode, + SymmetrizerDiagnostics, + SymmetrizerOptions, + SymmetryCandidateDiagnostics, WireDefinition, } from "./types.js"; diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts index ead15a68..67ddca53 100644 --- a/packages/necpp-wasm/src/types.ts +++ b/packages/necpp-wasm/src/types.ts @@ -404,3 +404,176 @@ export interface NecWorkerModel { /** Register a progress listener. Returns an unsubscribe function. */ subscribeProgress(listener: NecWorkerProgressListener): () => void; } + +/** Stable caller identity for an element in a full array description. */ +export type ArrayElementId = string | number; + +/** A straight wire expressed in element-local metres. */ +export interface RelativeWireDefinition { + readonly id: string; + readonly segments: number; + readonly startM: CartesianPointM; + readonly endM: CartesianPointM; + readonly radiusM: number; +} + +/** A port targeting a one-based segment of an element-local wire. */ +export interface RelativePortDefinition { + readonly wireId: string; + readonly segment: number; + readonly name?: string; +} + +export interface RelativeSegmentSelection { + readonly wireId: string; + readonly firstSegment?: number; + readonly lastSegment?: number; +} + +type RetargetLoad = T extends LoadDefinition + ? Omit & { readonly target: RelativeSegmentSelection } + : never; + +export type RelativeLoadDefinition = RetargetLoad; + +export interface PositionedArrayElement { + readonly id: ArrayElementId; + readonly positionM: readonly [xM: number, yM: number]; + readonly patternId: string; + /** The first release accepts only zero or omitted rotation. */ + readonly rotationDeg?: number; +} + +export interface ElementWirePattern { + readonly id: string; + readonly kind: "straight-wire-pattern"; + readonly wires: readonly RelativeWireDefinition[]; + readonly ports: readonly RelativePortDefinition[]; + readonly loads?: readonly RelativeLoadDefinition[]; +} + +export interface FullArrayDescription { + readonly elements: readonly PositionedArrayElement[]; + readonly patterns: readonly ElementWirePattern[]; + readonly ground: GroundModel; +} + +export interface CanonicalArrayElement { + readonly id: ArrayElementId; + readonly positionM: readonly [xM: number, yM: number]; + readonly patternId: string; + readonly rotationDeg: 0; +} + +export interface SymmetrizerOptions { + /** Required; the planner never chooses an implicit geometry tolerance. */ + readonly positionEpsilonM: number; + readonly center?: "auto" | readonly [xM: number, yM: number]; + readonly allowReflection?: boolean; + readonly allowRotation?: boolean; + readonly preferredRotationOrders?: readonly RotationalOrder[]; + readonly onUnsupported?: "explicit-fallback" | "error"; +} + +export type SymmetrizationReasonCode = + | "NO_NONTRIVIAL_SYMMETRY" + | "FIXED_ELEMENT_ON_REFLECTION_PLANE" + | "FIXED_ELEMENT_ON_ROTATION_AXIS" + | "POSITION_OUTSIDE_EPSILON" + | "AMBIGUOUS_POSITION_MATCH" + | "PATTERN_MISMATCH" + | "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM" + | "UNSYMMETRIC_LOAD" + | "GROUND_BREAKS_SYMMETRY" + | "TAG_SPACE_EXHAUSTED"; + +export interface SymmetrizationReason { + readonly code: SymmetrizationReasonCode; + readonly message: string; + readonly callerElementIndex?: number; + readonly patternId?: string; +} + +export interface PositionCanonicalization { + readonly callerElementIndex: number; + readonly originalPositionM: readonly [xM: number, yM: number]; + readonly canonicalPositionM: readonly [xM: number, yM: number]; + readonly adjustmentM: readonly [dxM: number, dyM: number]; + readonly distanceM: number; +} + +export interface SymmetryCandidateDiagnostics { + readonly symmetry: GeometrySymmetry; + readonly accepted: boolean; + readonly reasons: readonly SymmetrizationReason[]; +} + +export interface SymmetrizerDiagnostics { + readonly representation: "explicit" | "symmetric"; + readonly exact: boolean; + readonly effectiveCenterM: readonly [xM: number, yM: number]; + readonly maxPositionAdjustmentM: number; + readonly canonicalizations: readonly PositionCanonicalization[]; + readonly candidates: readonly SymmetryCandidateDiagnostics[]; + readonly reasons: readonly SymmetrizationReason[]; +} + +export interface ArrayElementMapping { + readonly callerElementIndex: number; + readonly callerElementId: ArrayElementId; + readonly fundamentalElementIndex: number; + readonly copyIndex: number; + readonly generatedTag: number; + readonly callerPortIndices: readonly number[]; + readonly generatedPortIndices: readonly number[]; + readonly positionAdjustmentM: readonly [dxM: number, dyM: number]; +} + +export type ArrayBuildPlan = + | { + readonly kind: "symmetric"; + readonly centerM: readonly [xM: number, yM: number]; + readonly symmetry: GeometrySymmetry; + readonly expansion: Omit< + SymmetryExpansion, + "fundamentalSegmentCount" | "fullSegmentCount" + >; + readonly fundamentalElements: readonly CanonicalArrayElement[]; + readonly mappings: readonly ArrayElementMapping[]; + readonly maxPositionAdjustmentM: number; + readonly diagnostics: SymmetrizerDiagnostics; + } + | { + readonly kind: "explicit"; + readonly elements: readonly CanonicalArrayElement[]; + readonly reasons: readonly SymmetrizationReason[]; + readonly diagnostics: SymmetrizerDiagnostics; + }; + +export interface CreateArraySolverOptions { + /** Defaults to `"auto"`. */ + readonly symmetry?: "auto" | "off" | "require"; + readonly symmetrizer?: SymmetrizerOptions; +} + +export interface ArraySolverDiagnostics { + readonly representation: "explicit" | "symmetric"; + readonly planner: SymmetrizerDiagnostics; + readonly symmetry?: SymmetryExpansion; +} + +/** Representation-independent, worker-backed array solver. */ +export interface NecArraySolver { + readonly state: NecModelState; + prepare(options: PrepareOptions): Promise; + computeImpedanceMatrix(): Promise; + solveVoltages(voltages: ComplexVector): Promise; + solveCurrents(currents: ComplexVector): Promise; + computeFarField(request: FarFieldRequest): Promise; + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise; + dispose(): Promise; + getDiagnostics(): ArraySolverDiagnostics; +} diff --git a/packages/necpp-wasm/src/worker.ts b/packages/necpp-wasm/src/worker.ts index 305f60ce..530f08a3 100644 --- a/packages/necpp-wasm/src/worker.ts +++ b/packages/necpp-wasm/src/worker.ts @@ -11,6 +11,17 @@ export { export { abiVersion, engineVersion, packageVersion } from "./versions.js"; export { rotationalOrder } from "./symmetry.js"; +export { analyzeArraySymmetry, createExplicitArrayBuildPlan } from "./array-symmetry.js"; +export { + applyArrayBuildPlan, + createNecArraySolver, + gatherComplexMatrix, + gatherComplexVector, + gatherEmbeddedBasis, + rephaseFarField, + scatterComplexVector, +} from "./array-solver.js"; +export type { AppliedArrayBuildPlan } from "./array-solver.js"; export type { NecErrorCode, NecErrorOptions } from "./errors.js"; @@ -18,22 +29,30 @@ export { createNecWorkerModel } from "./worker-client.js"; export type { AngleSweep, + ArrayBuildPlan, + ArrayElementId, + ArrayElementMapping, + ArraySolverDiagnostics, CartesianSignsTransform, CartesianPointM, + CanonicalArrayElement, CompleteGeometryOptions, ComplexMatrix, ComplexVector, ConductivityLoad, + CreateArraySolverOptions, CreateNecModelOptions, CreateNecWorkerModelOptions, DistributedParallelRlcLoad, DistributedSeriesRlcLoad, EmbeddedFarFieldResult, EmbeddedFieldNormalization, + ElementWirePattern, FarFieldRequest, FarFieldResult, FiniteGround, FreeSpaceGround, + FullArrayDescription, GeometryCompletionResult, GeometrySymmetry, GroundConnection, @@ -41,6 +60,7 @@ export type { ImpedanceLoad, ImpedanceResult, LoadDefinition, + NecArraySolver, NecModelState, NecWorkerModel, NecWorkerOperation, @@ -50,9 +70,15 @@ export type { PerfectGround, PortDefinition, PortSolution, + PositionCanonicalization, + PositionedArrayElement, PrepareOptions, ReflectionPlane, ReflectionSymmetry, + RelativeLoadDefinition, + RelativePortDefinition, + RelativeSegmentSelection, + RelativeWireDefinition, RotationalOrder, RotationalSymmetry, RotateZTransform, @@ -63,5 +89,10 @@ export type { SymmetryExpansion, SymmetryFailureClassification, SymmetryFailureReason, + SymmetrizationReason, + SymmetrizationReasonCode, + SymmetrizerDiagnostics, + SymmetrizerOptions, + SymmetryCandidateDiagnostics, WireDefinition, } from "./types.js"; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index 382aaa4c..53b8ca5f 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -1,6 +1,8 @@ import { NecStateError, abiVersion, + analyzeArraySymmetry, + createNecArraySolver, createNecModel, engineVersion, packageVersion, @@ -12,9 +14,67 @@ import { type SymmetryFailureClassification, type ComplexMatrix, type FarFieldResult, + type FullArrayDescription, type PortSolution, } from "../src/index.js"; +const typedArrayDescription: FullArrayDescription = { + elements: [ + { id: "left", positionM: [-0.25, 0.25], patternId: "dipole" }, + { id: "right", positionM: [0.25, 0.25], patternId: "dipole" }, + ], + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "wire", + segments: 11, + startM: [0, 0, 0.1], + endM: [0, 0, 0.4], + radiusM: 0.001, + }], + ports: [{ wireId: "wire", segment: 6 }], + }], + ground: { kind: "perfect" }, +}; + +analyzeArraySymmetry(typedArrayDescription, { positionEpsilonM: 0 }); + +async function validUnbranchedArrayConsumer( + symmetry: "off" | "auto", +): Promise { + const solver = await createNecArraySolver( + typedArrayDescription, + symmetry === "off" + ? { symmetry } + : { symmetry, symmetrizer: { positionEpsilonM: 1e-9 } }, + ); + await solver.prepare({ frequencyMHz: 300 }); + await solver.computeImpedanceMatrix(); + await solver.solveCurrents({ + real: new Float64Array(2), + imag: new Float64Array(2), + }); + await solver.computeFarField({ + theta: { startDeg: 0, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + solver.getDiagnostics().planner.canonicalizations; + await solver.dispose(); +} + +void validUnbranchedArrayConsumer; + +const invalidPatternDescription: FullArrayDescription = { + ...typedArrayDescription, + patterns: [{ + ...typedArrayDescription.patterns[0]!, + // @ts-expect-error opaque/helix primitives are deliberately not in the first-release type. + kind: "helix-pattern", + }], +}; +void invalidPatternDescription; + async function validConsumer(): Promise { const model = await createNecModel(); model.addWire({ diff --git a/packages/necpp-wasm/test/array-solver.integration.test.mjs b/packages/necpp-wasm/test/array-solver.integration.test.mjs new file mode 100644 index 00000000..c13b8fe9 --- /dev/null +++ b/packages/necpp-wasm/test/array-solver.integration.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +import { + analyzeArraySymmetry, + applyArrayBuildPlan, + createNecArraySolver, + createNecModel, +} from "../.test-build/src/index.js"; +import { createReferenceArrayFixture } from "./fixtures/reference-array.mjs"; + +const hasWasm = existsSync(new URL("../.test-build/src/nec2pp.wasm", import.meta.url)); + +function arrayDescription({ side = 2, centerM = [0, 0] } = {}) { + const fixture = createReferenceArrayFixture({ side, centerM }); + return { + fixture, + description: { + elements: fixture.wires.map((wire, index) => ({ + id: `element-${index}`, + positionM: [wire.start[0], wire.start[1]], + patternId: "dipole", + })), + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: fixture.segments, + startM: [0, 0, fixture.lowerZM], + endM: [0, 0, fixture.upperZM], + radiusM: fixture.radiusM, + }], + ports: [{ wireId: "radiator", segment: fixture.feedSegment, name: "feed" }], + }], + ground: fixture.ground, + }, + }; +} + +function relativeError(left, right) { + let delta = 0; + let scale = 0; + for (let index = 0; index < left.length; index += 1) { + delta += (left[index] - right[index]) ** 2; + scale += Math.max(left[index] ** 2, right[index] ** 2); + } + return Math.sqrt(delta) / Math.max(1, Math.sqrt(scale)); +} + +async function exerciseUnbranched(description, fixture, symmetry) { + const solver = await createNecArraySolver(description, symmetry === "off" + ? { symmetry } + : { symmetry, symmetrizer: { positionEpsilonM: 1e-12 } }); + try { + await solver.prepare({ frequencyMHz: fixture.frequencyMHz }); + const matrices = await solver.computeImpedanceMatrix(); + const count = description.elements.length; + const currents = { + real: Float64Array.from({ length: count }, (_, index) => 0.2 + index * 0.07), + imag: Float64Array.from({ length: count }, (_, index) => -0.03 * index), + }; + const solution = await solver.solveCurrents(currents); + const request = { + radiusM: 1, + theta: { startDeg: 30, count: 3, stepDeg: 30 }, + phi: { startDeg: 0, count: 3, stepDeg: 60 }, + }; + const field = await solver.computeFarField(request); + const embedded = await solver.computeEmbeddedFarFields(request); + return { + diagnostics: solver.getDiagnostics(), + matrices, + solution, + field, + embedded, + }; + } finally { + await solver.dispose(); + } +} + +test("direct and worker application adapters consume the same public plan", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description } = arrayDescription(); + const plan = analyzeArraySymmetry(description, { positionEpsilonM: 0 }); + const direct = await createNecModel(); + try { + const applied = await applyArrayBuildPlan(direct, description, plan); + assert.equal(applied.completion.symmetry.sectionCount, 4); + assert.deepEqual(applied.scatterCallerToNative, [3, 1, 2, 0]); + assert.deepEqual(applied.callerPorts.map((port) => port.tag), [1, 2, 3, 4]); + } finally { + direct.dispose(); + } +}); + +test("one unbranched facade exposes identical ordinary result shapes", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = arrayDescription(); + const explicit = await exerciseUnbranched(description, fixture, "off"); + const symmetric = await exerciseUnbranched(description, fixture, "auto"); + assert.equal(explicit.diagnostics.representation, "explicit"); + assert.equal(symmetric.diagnostics.representation, "symmetric"); + for (const key of ["matrices", "solution", "field", "embedded"]) { + assert.deepEqual( + Object.keys(explicit[key]).sort(), + Object.keys(symmetric[key]).sort(), + key, + ); + } + assert.deepEqual(explicit.solution.ports, symmetric.solution.ports); + assert.deepEqual(explicit.embedded.ports, symmetric.embedded.ports); + assert.deepEqual(symmetric.solution.ports.map((port) => port.tag), [1, 2, 3, 4]); + for (const result of [symmetric.matrices, symmetric.solution, symmetric.field, symmetric.embedded]) { + assert.equal("generatedTag" in result, false); + assert.equal("copyIndex" in result, false); + assert.equal("symmetry" in result, false); + } +}); + +test("off-origin explicit and centered symmetric complex fields prove the phase sign", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = arrayDescription({ centerM: [0.173, -0.219] }); + const [explicit, symmetric] = await Promise.all([ + exerciseUnbranched(description, fixture, "off"), + exerciseUnbranched(description, fixture, "auto"), + ]); + for (const component of ["eThetaReal", "eThetaImag", "ePhiReal", "ePhiImag"]) { + assert.ok( + relativeError(explicit.field[component], symmetric.field[component]) <= 1e-8, + `${component} combined field`, + ); + assert.ok( + relativeError(explicit.embedded[component], symmetric.embedded[component]) <= 1e-8, + `${component} embedded bases`, + ); + } +}); diff --git a/packages/necpp-wasm/test/array-symmetry.test.mjs b/packages/necpp-wasm/test/array-symmetry.test.mjs new file mode 100644 index 00000000..35a0b623 --- /dev/null +++ b/packages/necpp-wasm/test/array-symmetry.test.mjs @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + NecGeometryError, + analyzeArraySymmetry, + applyArrayBuildPlan, + gatherComplexMatrix, + gatherComplexVector, + gatherEmbeddedBasis, + rephaseFarField, + scatterComplexVector, +} from "../.test-build/src/index.js"; +import { createReferenceArrayFixture } from "./fixtures/reference-array.mjs"; + +function arrayDescription({ side = 4, centerM = [0, 0] } = {}) { + const fixture = createReferenceArrayFixture({ side, centerM }); + return { + fixture, + description: { + elements: fixture.wires.map((wire, index) => ({ + id: `element-${index}`, + positionM: [wire.start[0], wire.start[1]], + patternId: "dipole", + })), + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: fixture.segments, + startM: [0, 0, fixture.lowerZM], + endM: [0, 0, fixture.upperZM], + radiusM: fixture.radiusM, + }], + ports: [{ wireId: "radiator", segment: fixture.feedSegment, name: "feed" }], + }], + ground: fixture.ground, + }, + }; +} + +function byId(plan) { + return Object.fromEntries(plan.mappings.map((mapping) => [ + mapping.callerElementId, + { + fundamentalElementIndex: mapping.fundamentalElementIndex, + copyIndex: mapping.copyIndex, + generatedTag: mapping.generatedTag, + generatedPortIndices: mapping.generatedPortIndices, + adjustment: mapping.positionAdjustmentM, + }, + ])); +} + +test("exact even reference grids choose deterministic four-section XY reflection", () => { + for (const side of [2, 4, 8]) { + const { fixture, description } = arrayDescription({ side }); + const plan = analyzeArraySymmetry(description, { positionEpsilonM: 0 }); + assert.equal(plan.kind, "symmetric"); + assert.deepEqual(plan.symmetry.planes, ["x=0", "y=0"]); + assert.equal(plan.expansion.sectionCount, 4); + assert.equal(plan.fundamentalElements.length, side * side / 4); + assert.equal(plan.diagnostics.exact, true); + assert.equal(plan.diagnostics.canonicalizations.length, side * side); + assert.equal(plan.mappings.length, side * side); + assert.equal(structuredClone(plan).kind, "symmetric"); + if (side === 4) { + assert.deepEqual( + plan.mappings.flatMap((mapping) => mapping.generatedPortIndices), + fixture.reflection.scatterCallerToGenerated, + ); + } + } +}); + +test("exact odd reference grids fall back with fixed-element diagnostics", () => { + for (const side of [3, 5]) { + const { description } = arrayDescription({ side }); + const plan = analyzeArraySymmetry(description, { positionEpsilonM: 0 }); + assert.equal(plan.kind, "explicit"); + assert.ok(plan.reasons.some((entry) => + entry.code === "FIXED_ELEMENT_ON_REFLECTION_PLANE" + || entry.code === "FIXED_ELEMENT_ON_ROTATION_AXIS")); + assert.equal(plan.elements.length, side * side); + } +}); + +test("exact cardinal rotational symmetry is detected without a hidden epsilon", () => { + const { description } = arrayDescription({ side: 2 }); + const plan = analyzeArraySymmetry(description, { + positionEpsilonM: 0, + allowReflection: false, + }); + assert.equal(plan.kind, "symmetric"); + assert.equal(plan.symmetry.kind, "rotational"); + assert.equal(plan.symmetry.order, 4); + assert.deepEqual( + plan.expansion.copies.map((copy) => copy.transform.angleDeg), + [0, 90, 180, 270], + ); +}); + +test("a one-element full description remains a valid explicit fallback", () => { + const { description } = arrayDescription({ side: 2 }); + const single = { ...description, elements: [description.elements[0]] }; + const plan = analyzeArraySymmetry(single, { positionEpsilonM: 0 }); + assert.equal(plan.kind, "explicit"); + assert.equal(plan.elements.length, 1); +}); + +test("input permutations retain canonical geometry and ID-based native mappings", () => { + const { description } = arrayDescription(); + const original = analyzeArraySymmetry(description, { positionEpsilonM: 0 }); + const permutedDescription = { + ...description, + elements: [...description.elements].reverse(), + }; + const permuted = analyzeArraySymmetry(permutedDescription, { positionEpsilonM: 0 }); + assert.equal(original.kind, "symmetric"); + assert.equal(permuted.kind, "symmetric"); + assert.deepEqual( + original.fundamentalElements.map((element) => element.positionM), + permuted.fundamentalElements.map((element) => element.positionM), + ); + assert.deepEqual(byId(original), byId(permuted)); +}); + +test("epsilon canonicalization reports all adjustments and rejects the first excess", () => { + const { description } = arrayDescription(); + const epsilon = 1e-5; + const jittered = { + ...description, + elements: description.elements.map((element, index) => ({ + ...element, + positionM: [ + element.positionM[0] + ((index % 3) - 1) * epsilon / 10, + element.positionM[1] + ((index % 5) - 2) * epsilon / 12, + ], + })), + }; + const accepted = analyzeArraySymmetry(jittered, { positionEpsilonM: epsilon }); + assert.equal(accepted.kind, "symmetric"); + assert.equal(accepted.diagnostics.exact, false); + assert.equal(accepted.diagnostics.canonicalizations.length, 16); + assert.ok(accepted.maxPositionAdjustmentM > 0); + assert.ok(accepted.maxPositionAdjustmentM <= epsilon); + for (const canonicalization of accepted.diagnostics.canonicalizations) { + assert.deepEqual( + canonicalization.adjustmentM, + accepted.mappings[canonicalization.callerElementIndex].positionAdjustmentM, + ); + } + + const outside = structuredClone(description); + outside.elements[0].positionM[0] += 4 * epsilon; + const rejected = analyzeArraySymmetry(outside, { positionEpsilonM: epsilon }); + assert.equal(rejected.kind, "explicit"); + assert.ok(rejected.reasons.some((entry) => entry.code === "POSITION_OUTSIDE_EPSILON")); +}); + +test("ambiguous positions and pattern mismatches reject candidates deterministically", () => { + const { description } = arrayDescription({ side: 2 }); + const ambiguous = structuredClone(description); + ambiguous.elements[1].positionM = [...ambiguous.elements[0].positionM]; + const ambiguousPlan = analyzeArraySymmetry(ambiguous, { positionEpsilonM: 1e-9 }); + assert.equal(ambiguousPlan.kind, "explicit"); + assert.ok(ambiguousPlan.diagnostics.candidates.some((candidate) => + candidate.reasons.some((entry) => entry.code === "AMBIGUOUS_POSITION_MATCH"))); + + const mismatch = structuredClone(description); + mismatch.patterns.push({ ...mismatch.patterns[0], id: "other" }); + mismatch.elements[3].patternId = "other"; + const mismatchPlan = analyzeArraySymmetry(mismatch, { positionEpsilonM: 0 }); + assert.equal(mismatchPlan.kind, "explicit"); + assert.ok(mismatchPlan.diagnostics.candidates.some((candidate) => + candidate.reasons.some((entry) => entry.code === "PATTERN_MISMATCH"))); +}); + +test("every prohibited first-release pattern capability stays explicit", () => { + const prohibited = [ + { + name: "helix or opaque primitive", + mutate(value) { value.patterns[0].kind = "helix-pattern"; }, + }, + { + name: "tilted wire", + mutate(value) { value.patterns[0].wires[0].endM[0] = 0.1; }, + }, + { + name: "horizontal wire", + mutate(value) { + value.patterns[0].wires[0].startM = [-0.1, 0, 0.2]; + value.patterns[0].wires[0].endM = [0.1, 0, 0.2]; + }, + }, + { + name: "off-axis wire", + mutate(value) { + value.patterns[0].wires[0].startM[1] = 0.01; + value.patterns[0].wires[0].endM[1] = 0.01; + }, + }, + { + name: "nonzero local rotation", + mutate(value) { value.elements[0].rotationDeg = 10; }, + }, + { + name: "arc", + mutate(value) { value.patterns[0].kind = "arc-pattern"; }, + }, + { + name: "patch", + mutate(value) { value.patterns[0].kind = "patch-pattern"; }, + }, + ]; + for (const entry of prohibited) { + const { description } = arrayDescription({ side: 2 }); + const candidate = structuredClone(description); + entry.mutate(candidate); + const plan = analyzeArraySymmetry(candidate, { positionEpsilonM: 0 }); + assert.equal(plan.kind, "explicit", entry.name); + assert.equal(plan.reasons[0].code, "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM"); + assert.throws( + () => analyzeArraySymmetry(candidate, { + positionEpsilonM: 0, + onUnsupported: "error", + }), + (error) => error instanceof NecGeometryError + && error.details?.symmetryFailure === "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM", + entry.name, + ); + } +}); + +test("explicit application preserves unsupported local wire rotation", async () => { + const { description } = arrayDescription({ side: 2 }); + const candidate = structuredClone(description); + candidate.elements = [{ + ...candidate.elements[0], + rotationDeg: 90, + }]; + candidate.patterns[0].wires[0].startM[0] = 0.1; + candidate.patterns[0].wires[0].endM[0] = 0.1; + const plan = analyzeArraySymmetry(candidate, { positionEpsilonM: 0 }); + assert.equal(plan.kind, "explicit"); + const wires = []; + const model = { + addWire(wire) { wires.push(wire); }, + completeGeometry() { return {}; }, + definePorts() {}, + addLoad() {}, + setGround() {}, + }; + await applyArrayBuildPlan(model, candidate, plan); + assert.equal(wires.length, 1); + assert.ok(Math.abs(wires[0].start[0] - candidate.elements[0].positionM[0]) < 1e-12); + assert.ok(Math.abs(wires[0].start[1] + - (candidate.elements[0].positionM[1] + 0.1)) < 1e-12); +}); + +test("synthetic vector, matrix, and embedded-basis mappings are exact", () => { + const scatter = [2, 0, 3, 1]; + const caller = { + real: Float64Array.of(10, 20, 30, 40), + imag: Float64Array.of(-1, -2, -3, -4), + }; + const native = scatterComplexVector(caller, scatter); + assert.deepEqual([...native.real], [20, 40, 10, 30]); + assert.deepEqual(gatherComplexVector(native, scatter), caller); + + const values = Float64Array.from({ length: 16 }, (_, index) => index); + const gathered = gatherComplexMatrix({ + rows: 4, + columns: 4, + order: "row-major", + real: values, + imag: Float64Array.from(values, (value) => -value), + }, scatter); + assert.equal(gathered.real[1], values[scatter[0] * 4 + scatter[1]]); + assert.equal(gathered.imag[14], -values[scatter[3] * 4 + scatter[2]]); + + const bases = Float64Array.from({ length: 8 }, (_, index) => index); + assert.deepEqual([...gatherEmbeddedBasis(bases, 2, scatter)], [4, 5, 0, 1, 6, 7, 2, 3]); +}); + +test("far-field phase restoration uses the positive propagation-convention sign", () => { + const speed = 1 / Math.sqrt(8.854e-12 * 4 * Math.PI * 1e-7); + const frequencyMHz = 300; + const wavelengthM = speed / (frequencyMHz * 1e6); + const result = rephaseFarField({ + radiusM: 1, + frequencyMHz, + thetaDeg: Float64Array.of(90), + phiDeg: Float64Array.of(0), + eThetaReal: Float64Array.of(1), + eThetaImag: Float64Array.of(0), + ePhiReal: Float64Array.of(0), + ePhiImag: Float64Array.of(1), + }, [wavelengthM / 4, 0]); + assert.ok(Math.abs(result.eThetaReal[0]) < 1e-12); + assert.ok(Math.abs(result.eThetaImag[0] - 1) < 1e-12); + assert.ok(Math.abs(result.ePhiReal[0] + 1) < 1e-12); + assert.ok(Math.abs(result.ePhiImag[0]) < 1e-12); +}); From 52194f5118689e7a2b46076150612c36761964de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 17:49:21 +0200 Subject: [PATCH 42/46] test: add end-to-end symmetry equivalence suite --- docs/01_symmetry_support.md | 48 +- .../test/symmetry-equivalence.test.mjs | 752 ++++++++++++++++++ 2 files changed, 799 insertions(+), 1 deletion(-) create mode 100644 packages/necpp-wasm/test/symmetry-equivalence.test.mjs diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index fcd63983..8eae78fb 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1236,7 +1236,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S3 Additive C/WASM ABI | complete | Codex | Native `[wp_s3]`: 8 assertions/2 cases plus pure-C contract; CTest 8/8; WASM smoke; npm 38/38; pack 5/5; browser 3/3 | WP-S4 should convert the private WASM `bigint` segment counts to checked safe numbers and derive copy transforms from the validated descriptor. | | WP-S4 Direct and worker TypeScript API | complete | Codex | npm/WASM: 46/46; pack: 5/5; browser: 3/3; native ABI: 8 assertions | WP-S5 may use only the exported descriptors, immutable completion metadata, typed failure details, and direct/worker methods; private ABI `bigint` values never escape. | | WP-S5 Transparent symmetrizer | complete | Codex | npm/WASM: 60/60; pack: 5/5; browser: 3/3; focused WP-S5: 14/14 | WP-S6 can consume the public facade without plan-kind branches; caller-order tags, ports, vectors, matrices, and field bases are representation-independent. | -| WP-S6 End-to-end equivalence suite | not started | — | — | — | +| WP-S6 End-to-end equivalence suite | complete | Codex | Focused WP-S6: 8/8; npm/WASM: 68/68 + typecheck; native `[wp_s2]` 10,599 and `[wp_s3]` 8 assertions | WP-S7 should reuse the reference fixture, caller-order checks, complex metrics, and ordinary 8 x 8 R3 gate before reporting performance. | | WP-S7 Benchmarks and performance gates | not started | — | — | — | | WP-S8 Public documentation, examples, and release hardening | not started | — | — | — | | WP-S9 Final version bump and release identity | not started | — | — | — | @@ -1769,6 +1769,52 @@ DoD: Handoff focus: WP-S7 must reuse these fixtures/checks as benchmark correctness guards. +Completion evidence (2026-08-30, Windows, Node 24.14.1, TypeScript 5.8.3, +and the WP-S3 Emscripten artifact): + +- Added `test/symmetry-equivalence.test.mjs`, which builds explicit, manually + applied, and transparent models from the shared reference description. Its + eight real-WASM cases cover R1-R4, T1-T3, G1, N1, O1, E1, and P1, plus a + complete structural load orbit. R3's complete 64-port 8 x 8 Z/Y comparison + runs in the ordinary tier rather than being deferred to a scheduled job. +- The suite rejects non-finite data and checks dimensions, frequency, port + metadata, generations, two-dimensional caller-order gathering, Z/Y relative + L2 and scaled-max error, Z*Y identity, reciprocity, named mutual entries, + port quantities, and all complex far-field components. Exact + full-versus-symmetric comparisons retain the `1e-8` gates; the separately + measured 11-segment explicit reciprocity residual is bounded at `2e-7` for + 4 x 4 and `3e-7` for 8 x 8. +- All five canonical 4 x 4 current cases compare requested/achieved currents, + voltages, active impedances, powers, complex combined fields, total + magnitudes, normalized cuts, stable peak samples, and intended-sample phase. + Gathered unit-current embedded bases reproduce each directly solved field by + JavaScript complex superposition. +- N1 and P1 execute the same prepare, Z, current-solve, combined-field, and + embedded-field calls as accepted symmetry while retaining the full caller + port count and exposing no native tags or copy indices. O1 compares complete + complex translated fields, and E1 reports every canonicalization before + applying its locked `1e-7` jittered-input bound. +- `npm --prefix packages/necpp-wasm run build:test` followed by the focused + Node command passed 8/8 in 2.63 seconds. `npm --prefix packages/necpp-wasm + test` passed 68/68 tests and strict typechecking. `git diff --check` passed. +- Proportional native regressions from the clean cached Release executable also + passed: `[wp_s2]` 10,599 assertions in five cases, `[wp_s3]` eight assertions + in two cases, and the aggregate `[symmetry]` selection 10,945 assertions in + 15 cases. The native WP-S2 run took 175 seconds on this Windows host; it was + CPU-active throughout and completed without a skipped assertion. + +Contract decisions for WP-S7 and later: + +- Performance checks must run only after the same caller-order complex Z/Y + equivalence gates; reporting magnitude-only agreement is insufficient. +- Broadside has physically degenerate azimuth samples. Peak comparison uses the + first sample within a `1e-10` relative tie band so representation roundoff + cannot manufacture an azimuth disagreement; steered cases additionally + check the requested azimuth cut and intended complex sample. +- Exact representation comparisons remain `1e-8`. The looser reciprocity + limits describe the independently measured explicit pulse/basis baseline and + must not be reused as full-versus-symmetric tolerances. + ### WP-S7 — Benchmarks and performance gates Dependencies: WP-S6. diff --git a/packages/necpp-wasm/test/symmetry-equivalence.test.mjs b/packages/necpp-wasm/test/symmetry-equivalence.test.mjs new file mode 100644 index 00000000..3b52847e --- /dev/null +++ b/packages/necpp-wasm/test/symmetry-equivalence.test.mjs @@ -0,0 +1,752 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +import { + analyzeArraySymmetry, + applyArrayBuildPlan, + createNecArraySolver, + createNecModel, + gatherComplexMatrix, +} from "../.test-build/src/index.js"; +import { createReferenceArrayFixture } from "./fixtures/reference-array.mjs"; + +const hasWasm = existsSync(new URL("../.test-build/src/nec2pp.wasm", import.meta.url)); +const exactTolerance = 1e-8; +const copyTolerance = 1e-12; + +function dipolePattern(fixture, id = "dipole", kind = "straight-wire-pattern") { + return { + id, + kind, + wires: [{ + id: "radiator", + segments: fixture.segments, + startM: [0, 0, fixture.lowerZM], + endM: [0, 0, fixture.upperZM], + radiusM: fixture.radiusM, + }], + ports: [{ wireId: "radiator", segment: fixture.feedSegment, name: "feed" }], + }; +} + +function gridDescription({ + side = 4, + centerM = [0, 0], + ground, + rowPatterns = false, + kind = "straight-wire-pattern", +} = {}) { + const fixture = createReferenceArrayFixture({ side, centerM }); + const selectedGround = ground ?? fixture.ground; + const patterns = rowPatterns + ? Array.from({ length: side }, (_, row) => dipolePattern(fixture, `dipole-row-${row}`)) + : [dipolePattern(fixture, "dipole", kind)]; + return { + fixture, + description: { + elements: fixture.wires.map((wire, index) => ({ + id: `element-${index}`, + positionM: [wire.start[0], wire.start[1]], + patternId: rowPatterns ? `dipole-row-${Math.floor(index / side)}` : "dipole", + })), + patterns, + ground: selectedGround, + }, + }; +} + +function ringDescription({ order, ground = { kind: "perfect" } }) { + const fixture = createReferenceArrayFixture({ side: 2 }); + const radiusM = fixture.wavelengthM * 0.61; + return { + fixture, + description: { + elements: Array.from({ length: order }, (_, index) => { + const angle = 2 * Math.PI * index / order; + return { + id: `ring-${index}`, + positionM: [radiusM * Math.cos(angle), radiusM * Math.sin(angle)], + patternId: "dipole", + }; + }), + patterns: [dipolePattern(fixture)], + ground, + }, + }; +} + +function allFinite(values, label) { + for (let index = 0; index < values.length; index += 1) { + assert.ok(Number.isFinite(values[index]), `${label}[${index}] is not finite`); + } +} + +function complexMetrics(left, right, absoluteFloor = 1e-30) { + assert.equal(left.real.length, right.real.length); + assert.equal(left.imag.length, right.imag.length); + allFinite(left.real, "left.real"); + allFinite(left.imag, "left.imag"); + allFinite(right.real, "right.real"); + allFinite(right.imag, "right.imag"); + let deltaSquared = 0; + let baselineSquared = 0; + let maxDelta = 0; + let maxBaseline = 0; + for (let index = 0; index < left.real.length; index += 1) { + const delta = Math.hypot( + left.real[index] - right.real[index], + left.imag[index] - right.imag[index], + ); + const baseline = Math.hypot(right.real[index], right.imag[index]); + deltaSquared += delta * delta; + baselineSquared += baseline * baseline; + maxDelta = Math.max(maxDelta, delta); + maxBaseline = Math.max(maxBaseline, baseline); + } + return { + relativeL2: Math.sqrt(deltaSquared) / Math.max(Math.sqrt(baselineSquared), absoluteFloor), + scaledMax: maxDelta / Math.max(maxBaseline, absoluteFloor), + }; +} + +function assertComplexClose(left, right, tolerance, label) { + const metrics = complexMetrics(left, right); + assert.ok(metrics.relativeL2 <= tolerance, + `${label} relativeL2 ${metrics.relativeL2} exceeds ${tolerance}`); + assert.ok(metrics.scaledMax <= tolerance, + `${label} scaledMax ${metrics.scaledMax} exceeds ${tolerance}`); +} + +function realMetrics(left, right, absoluteFloor = 1e-30) { + assert.equal(left.length, right.length); + allFinite(left, "left"); + allFinite(right, "right"); + let deltaSquared = 0; + let baselineSquared = 0; + let maxDelta = 0; + let maxBaseline = 0; + for (let index = 0; index < left.length; index += 1) { + const delta = Math.abs(left[index] - right[index]); + deltaSquared += delta * delta; + baselineSquared += right[index] * right[index]; + maxDelta = Math.max(maxDelta, delta); + maxBaseline = Math.max(maxBaseline, Math.abs(right[index])); + } + return { + relativeL2: Math.sqrt(deltaSquared) / Math.max(Math.sqrt(baselineSquared), absoluteFloor), + scaledMax: maxDelta / Math.max(maxBaseline, absoluteFloor), + }; +} + +function assertRealClose(left, right, tolerance, label) { + const metrics = realMetrics(left, right); + assert.ok(metrics.relativeL2 <= tolerance, + `${label} relativeL2 ${metrics.relativeL2} exceeds ${tolerance}`); + assert.ok(metrics.scaledMax <= tolerance, + `${label} scaledMax ${metrics.scaledMax} exceeds ${tolerance}`); +} + +function matrixProduct(left, right) { + const order = left.rows; + assert.equal(left.rows, left.columns); + assert.equal(right.rows, order); + assert.equal(right.columns, order); + const real = new Float64Array(order * order); + const imag = new Float64Array(order * order); + for (let row = 0; row < order; row += 1) { + for (let column = 0; column < order; column += 1) { + let sumReal = 0; + let sumImag = 0; + for (let inner = 0; inner < order; inner += 1) { + const leftIndex = row * order + inner; + const rightIndex = inner * order + column; + sumReal += left.real[leftIndex] * right.real[rightIndex] + - left.imag[leftIndex] * right.imag[rightIndex]; + sumImag += left.real[leftIndex] * right.imag[rightIndex] + + left.imag[leftIndex] * right.real[rightIndex]; + } + real[row * order + column] = sumReal; + imag[row * order + column] = sumImag; + } + } + return { real, imag }; +} + +function assertMatrixContract(result, expectedOrder, label) { + assert.equal(result.impedance.rows, expectedOrder, `${label} impedance rows`); + assert.equal(result.impedance.columns, expectedOrder, `${label} impedance columns`); + assert.equal(result.impedance.order, "row-major"); + assert.equal(result.admittance.rows, expectedOrder, `${label} admittance rows`); + assert.equal(result.admittance.columns, expectedOrder, `${label} admittance columns`); + allFinite(result.impedance.real, `${label}.Z.real`); + allFinite(result.impedance.imag, `${label}.Z.imag`); + allFinite(result.admittance.real, `${label}.Y.real`); + allFinite(result.admittance.imag, `${label}.Y.imag`); + + const product = matrixProduct(result.impedance, result.admittance); + const identity = { + real: Float64Array.from({ length: expectedOrder * expectedOrder }, (_, index) => + Math.floor(index / expectedOrder) === index % expectedOrder ? 1 : 0), + imag: new Float64Array(expectedOrder * expectedOrder), + }; + assertComplexClose(product, identity, 2e-8, `${label} Z*Y identity`); + + const transpose = { real: new Float64Array(expectedOrder * expectedOrder), imag: new Float64Array(expectedOrder * expectedOrder) }; + for (let row = 0; row < expectedOrder; row += 1) { + for (let column = 0; column < expectedOrder; column += 1) { + transpose.real[row * expectedOrder + column] = result.impedance.real[column * expectedOrder + row]; + transpose.imag[row * expectedOrder + column] = result.impedance.imag[column * expectedOrder + row]; + } + } + // The 11-segment pulse/basis discretization leaves the explicit 4x4 and 8x8 + // matrices reciprocal to about 1.37e-7 and 2.74e-7 respectively. This is an + // independent baseline property; representation comparisons retain 1e-8. + const reciprocityTolerance = expectedOrder >= 64 ? 3e-7 : 2e-7; + assertComplexClose(result.impedance, transpose, reciprocityTolerance, + `${label} reciprocal Z`); +} + +function currentVector(count, phaseStep = 0.37) { + return { + real: Float64Array.from({ length: count }, (_, index) => + (0.7 + 0.02 * index) * Math.cos(phaseStep * index)), + imag: Float64Array.from({ length: count }, (_, index) => + (0.7 + 0.02 * index) * Math.sin(phaseStep * index)), + }; +} + +const smokeFieldRequest = { + radiusM: 1, + theta: { startDeg: 20, count: 4, stepDeg: 20 }, + phi: { startDeg: 0, count: 4, stepDeg: 90 }, +}; + +async function createPair(description, fixture, symmetrizer) { + const explicit = await createNecArraySolver(description, { symmetry: "off" }); + let symmetric; + try { + symmetric = await createNecArraySolver(description, { symmetry: "require", symmetrizer }); + await Promise.all([ + explicit.prepare({ frequencyMHz: fixture.frequencyMHz }), + symmetric.prepare({ frequencyMHz: fixture.frequencyMHz }), + ]); + return { explicit, symmetric }; + } catch (error) { + await Promise.all([ + explicit.dispose(), + ...(symmetric === undefined ? [] : [symmetric.dispose()]), + ]); + throw error; + } +} + +async function compareRepresentationCase({ + name, + description, + fixture, + symmetrizer, + expectedSections, + tolerance = exactTolerance, + checkField = true, +}) { + const { explicit, symmetric } = await createPair(description, fixture, symmetrizer); + try { + const explicitDiagnostics = explicit.getDiagnostics(); + const symmetricDiagnostics = symmetric.getDiagnostics(); + assert.equal(explicitDiagnostics.representation, "explicit"); + assert.equal(explicitDiagnostics.symmetry, undefined); + assert.equal(symmetricDiagnostics.representation, "symmetric"); + assert.equal(symmetricDiagnostics.symmetry.sectionCount, expectedSections); + assert.equal(symmetricDiagnostics.symmetry.fullSegmentCount, + description.elements.length * fixture.segments); + assert.equal(symmetricDiagnostics.symmetry.fundamentalSegmentCount, + description.elements.length * fixture.segments / expectedSections); + + const [baseline, candidate] = await Promise.all([ + explicit.computeImpedanceMatrix(), + symmetric.computeImpedanceMatrix(), + ]); + assert.equal(baseline.frequencyMHz, fixture.frequencyMHz); + assert.equal(candidate.frequencyMHz, baseline.frequencyMHz); + assert.equal(candidate.factorizationGeneration, baseline.factorizationGeneration); + assertMatrixContract(baseline, description.elements.length, `${name} explicit`); + assertMatrixContract(candidate, description.elements.length, `${name} symmetric`); + assertComplexClose(candidate.impedance, baseline.impedance, tolerance, `${name} gathered Z`); + assertComplexClose(candidate.admittance, baseline.admittance, tolerance, `${name} gathered Y`); + + // Compare named mutual entries and their symmetry-related partners independently + // from the all-entry metrics so a row/column gather error is localized. + const order = description.elements.length; + const namedIndices = [1, order, order * order - 2]; + for (const index of namedIndices.filter((value) => value < order * order)) { + assertComplexClose( + { real: Float64Array.of(candidate.impedance.real[index]), imag: Float64Array.of(candidate.impedance.imag[index]) }, + { real: Float64Array.of(baseline.impedance.real[index]), imag: Float64Array.of(baseline.impedance.imag[index]) }, + tolerance, + `${name} mutual Z[${Math.floor(index / order)},${index % order}]`, + ); + } + + const currents = currentVector(order); + const [baselineSolution, candidateSolution] = await Promise.all([ + explicit.solveCurrents(currents), + symmetric.solveCurrents(currents), + ]); + compareSolutions(candidateSolution, baselineSolution, tolerance, `${name} solution`); + if (checkField) { + const [baselineField, candidateField] = await Promise.all([ + explicit.computeFarField(smokeFieldRequest), + symmetric.computeFarField(smokeFieldRequest), + ]); + compareFields(candidateField, baselineField, tolerance, `${name} far field`); + } + return { baseline, candidate }; + } finally { + await Promise.all([explicit.dispose(), symmetric.dispose()]); + } +} + +function compareSolutions(candidate, baseline, tolerance, label) { + assert.equal(candidate.drive, baseline.drive); + assert.equal(candidate.frequencyMHz, baseline.frequencyMHz); + assert.equal(candidate.factorizationGeneration, baseline.factorizationGeneration); + assert.equal(candidate.solveGeneration, baseline.solveGeneration); + assert.deepEqual(candidate.ports, baseline.ports); + assertComplexClose(candidate.requested, baseline.requested, copyTolerance, `${label} requested`); + assertComplexClose(candidate.voltages, baseline.voltages, tolerance, `${label} voltages`); + assertComplexClose(candidate.currents, baseline.currents, tolerance, `${label} currents`); + assertComplexClose(candidate.activeImpedances, baseline.activeImpedances, tolerance, + `${label} active impedances`); + assertRealClose(candidate.powersW, baseline.powersW, tolerance, `${label} powers`); +} + +function fieldComponents(field) { + return [ + [field.eThetaReal, field.eThetaImag, "E_theta"], + [field.ePhiReal, field.ePhiImag, "E_phi"], + ]; +} + +function compareFields(candidate, baseline, tolerance, label) { + assert.equal(candidate.radiusM, baseline.radiusM); + assert.equal(candidate.frequencyMHz, baseline.frequencyMHz); + assert.deepEqual(candidate.thetaDeg, baseline.thetaDeg); + assert.deepEqual(candidate.phiDeg, baseline.phiDeg); + for (const [real, imag, component] of fieldComponents(candidate)) { + const baselineComponent = component === "E_theta" + ? { real: baseline.eThetaReal, imag: baseline.eThetaImag } + : { real: baseline.ePhiReal, imag: baseline.ePhiImag }; + assertComplexClose({ real, imag }, baselineComponent, tolerance, `${label} ${component}`); + } +} + +function totalFieldMagnitude(field) { + return Float64Array.from({ length: field.eThetaReal.length }, (_, index) => Math.hypot( + field.eThetaReal[index], field.eThetaImag[index], + field.ePhiReal[index], field.ePhiImag[index], + )); +} + +function maximumIndex(values) { + let selected = 0; + for (let index = 1; index < values.length; index += 1) { + if (values[index] > values[selected]) { + selected = index; + } + } + return selected; +} + +function stablePeakIndex(values, relativeTie = 1e-10) { + const maximum = values[maximumIndex(values)]; + const floor = maximum * (1 - relativeTie); + return values.findIndex((value) => value >= floor); +} + +function superposeEmbedded(embedded, currents, component) { + const real = new Float64Array(embedded.samplesPerPort); + const imag = new Float64Array(embedded.samplesPerPort); + const basisReal = embedded[`${component}Real`]; + const basisImag = embedded[`${component}Imag`]; + for (let port = 0; port < embedded.ports.length; port += 1) { + for (let sample = 0; sample < embedded.samplesPerPort; sample += 1) { + const source = port * embedded.samplesPerPort + sample; + real[sample] += basisReal[source] * currents.real[port] + - basisImag[source] * currents.imag[port]; + imag[sample] += basisReal[source] * currents.imag[port] + + basisImag[source] * currents.real[port]; + } + } + return { real, imag }; +} + +function steeringCurrents(fixture, thetaDeg, phiDeg, amplitude) { + const theta = thetaDeg * Math.PI / 180; + const phi = phiDeg * Math.PI / 180; + const ux = Math.sin(theta) * Math.cos(phi); + const uy = Math.sin(theta) * Math.sin(phi); + const waveNumber = 2 * Math.PI / fixture.wavelengthM; + return { + real: Float64Array.from(fixture.wires, (wire, index) => { + const phase = -waveNumber * (ux * wire.start[0] + uy * wire.start[1]); + return amplitude(index) * Math.cos(phase); + }), + imag: Float64Array.from(fixture.wires, (wire, index) => { + const phase = -waveNumber * (ux * wire.start[0] + uy * wire.start[1]); + return amplitude(index) * Math.sin(phase); + }), + }; +} + +test("WP-S6 R1/R2 reflection matrices and manual/transparent gathering match explicit models", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + for (const side of [2, 4]) { + const { description, fixture } = gridDescription({ side }); + await compareRepresentationCase({ + name: `R${side === 2 ? 1 : 2}`, + description, + fixture, + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + expectedSections: 4, + }); + } + + // The low-level path deliberately exposes native copy-major port order. Apply + // the same transparent plan manually and prove the two-dimensional gather. + const { description, fixture } = gridDescription({ side: 4 }); + const plan = analyzeArraySymmetry(description, { + positionEpsilonM: 0, + allowRotation: false, + }); + assert.equal(plan.kind, "symmetric"); + const manual = await createNecModel(); + try { + const application = await applyArrayBuildPlan(manual, description, plan); + assert.notDeepEqual(application.scatterCallerToNative, + Array.from({ length: 16 }, (_, index) => index)); + manual.prepare({ frequencyMHz: fixture.frequencyMHz }); + const native = manual.computeImpedanceMatrix(); + const gathered = gatherComplexMatrix(native.impedance, application.scatterCallerToNative); + const transparent = await createNecArraySolver(description, { + symmetry: "require", + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + }); + try { + await transparent.prepare({ frequencyMHz: fixture.frequencyMHz }); + const transparentMatrices = await transparent.computeImpedanceMatrix(); + assertComplexClose(gathered, transparentMatrices.impedance, copyTolerance, + "R2 manual versus transparent gathered Z"); + } finally { + await transparent.dispose(); + } + } finally { + manual.dispose(); + } +}); + +test("WP-S6 R4, T1, T2, T3, and G1 cover two-section, rotational, and finite-ground modes", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const r4 = gridDescription({ side: 4, rowPatterns: true }); + const r4Plan = analyzeArraySymmetry(r4.description, { + positionEpsilonM: 0, + allowRotation: false, + }); + assert.equal(r4Plan.kind, "symmetric"); + assert.deepEqual(r4Plan.symmetry.planes, ["x=0"]); + await compareRepresentationCase({ + name: "R4", + ...r4, + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + expectedSections: 2, + }); + + for (const order of [2, 4, 6]) { + const ring = ringDescription({ order, ground: order === 6 ? { kind: "free-space" } : { kind: "perfect" } }); + await compareRepresentationCase({ + name: `T${order === 2 ? 1 : order === 4 ? 2 : 3}`, + ...ring, + symmetrizer: { + positionEpsilonM: 1e-14, + allowReflection: false, + preferredRotationOrders: [order], + }, + expectedSections: order, + }); + } + + const g1 = gridDescription({ + side: 4, + ground: { + kind: "finite", + method: "reflection-coefficient", + relativePermittivity: 13, + conductivitySPerM: 0.005, + }, + }); + await compareRepresentationCase({ + name: "G1", + ...g1, + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + expectedSections: 4, + }); +}); + +test("WP-S6 five canonical beam cases match ports, complex fields, peaks, and embedded superposition", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const { description, fixture } = gridDescription({ side: 4 }); + const { explicit, symmetric } = await createPair( + description, + fixture, + { positionEpsilonM: 0, allowRotation: false }, + ); + const request = { + radiusM: 2, + theta: { startDeg: 10, count: 8, stepDeg: 10 }, + phi: { startDeg: 0, count: 8, stepDeg: 45 }, + }; + try { + const [explicitEmbedded, symmetricEmbedded] = await Promise.all([ + explicit.computeEmbeddedFarFields(request, { kind: "unit-current", valueA: 1 }), + symmetric.computeEmbeddedFarFields(request, { kind: "unit-current", valueA: 1 }), + ]); + assert.deepEqual(symmetricEmbedded.ports, explicitEmbedded.ports); + assert.equal(symmetricEmbedded.samplesPerPort, explicitEmbedded.samplesPerPort); + compareFields(symmetricEmbedded, explicitEmbedded, exactTolerance, "R2 embedded bases"); + + const cases = [ + { name: "uniform", theta: 0, phi: 0, amplitude: () => 1, intended: undefined }, + { name: "+X", theta: 60, phi: 0, amplitude: () => 1, intended: [60, 0] }, + { name: "+Y", theta: 60, phi: 90, amplitude: () => 1, intended: [60, 90] }, + { name: "diagonal", theta: 50, phi: 45, amplitude: () => 1, intended: [50, 45] }, + { + name: "asymmetric taper", + theta: 50, + phi: 45, + amplitude: (index) => 0.55 + ((index * 7) % 16) / 30, + intended: [50, 45], + }, + ]; + for (const entry of cases) { + const currents = steeringCurrents(fixture, entry.theta, entry.phi, entry.amplitude); + const [explicitSolution, symmetricSolution] = await Promise.all([ + explicit.solveCurrents(currents), + symmetric.solveCurrents(currents), + ]); + compareSolutions(symmetricSolution, explicitSolution, exactTolerance, `R2 ${entry.name}`); + const [explicitField, symmetricField] = await Promise.all([ + explicit.computeFarField(request), + symmetric.computeFarField(request), + ]); + compareFields(symmetricField, explicitField, exactTolerance, `R2 ${entry.name}`); + + const explicitMagnitude = totalFieldMagnitude(explicitField); + const symmetricMagnitude = totalFieldMagnitude(symmetricField); + assertRealClose(symmetricMagnitude, explicitMagnitude, exactTolerance, + `R2 ${entry.name} total magnitude`); + // Broadside has physically degenerate azimuth samples. Select the first + // sample within a tight relative tie band so representation-level roundoff + // cannot turn that degeneracy into a false direction mismatch. + const explicitPeak = stablePeakIndex(explicitMagnitude); + const symmetricPeak = stablePeakIndex(symmetricMagnitude); + assert.equal(symmetricPeak, explicitPeak, `R2 ${entry.name} peak sample`); + assert.ok(Math.abs(symmetricMagnitude[symmetricPeak] - explicitMagnitude[explicitPeak]) + <= exactTolerance * Math.max(1, explicitMagnitude[explicitPeak])); + const explicitNormalized = Float64Array.from(explicitMagnitude, + (value) => value / explicitMagnitude[explicitPeak]); + const symmetricNormalized = Float64Array.from(symmetricMagnitude, + (value) => value / symmetricMagnitude[symmetricPeak]); + assertRealClose(symmetricNormalized, explicitNormalized, exactTolerance, + `R2 ${entry.name} normalized cuts`); + + for (const [component, fieldReal, fieldImag] of [ + ["eTheta", explicitField.eThetaReal, explicitField.eThetaImag], + ["ePhi", explicitField.ePhiReal, explicitField.ePhiImag], + ]) { + const explicitSuperposition = superposeEmbedded(explicitEmbedded, currents, component); + const symmetricSuperposition = superposeEmbedded(symmetricEmbedded, currents, component); + assertComplexClose(explicitSuperposition, { real: fieldReal, imag: fieldImag }, + exactTolerance, `R2 ${entry.name} explicit ${component} superposition`); + assertComplexClose(symmetricSuperposition, { real: fieldReal, imag: fieldImag }, + exactTolerance, `R2 ${entry.name} symmetric ${component} superposition`); + } + + if (entry.intended !== undefined) { + const thetaIndex = [...explicitField.thetaDeg].indexOf(entry.intended[0]); + const phiIndex = [...explicitField.phiDeg].indexOf(entry.intended[1]); + assert.ok(thetaIndex >= 0 && phiIndex >= 0); + const intendedIndex = phiIndex * explicitField.thetaDeg.length + thetaIndex; + assertComplexClose( + { + real: Float64Array.of(symmetricField.eThetaReal[intendedIndex]), + imag: Float64Array.of(symmetricField.eThetaImag[intendedIndex]), + }, + { + real: Float64Array.of(explicitField.eThetaReal[intendedIndex]), + imag: Float64Array.of(explicitField.eThetaImag[intendedIndex]), + }, + exactTolerance, + `R2 ${entry.name} intended-sample phase`, + ); + const azimuthMagnitudes = Float64Array.from(explicitField.phiDeg, (_, index) => + explicitMagnitude[index * explicitField.thetaDeg.length + thetaIndex]); + const bestAzimuth = explicitField.phiDeg[maximumIndex(azimuthMagnitudes)]; + assert.ok(Math.abs(bestAzimuth - entry.intended[1]) <= 45, + `R2 ${entry.name} baseline azimuth peak ${bestAzimuth}`); + } + } + } finally { + await Promise.all([explicit.dispose(), symmetric.dispose()]); + } +}); + +test("WP-S6 a complete structural load orbit remains symmetry-equivalent", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const loaded = gridDescription({ side: 2 }); + loaded.description.patterns[0].loads = [{ + kind: "impedance", + target: { wireId: "radiator", firstSegment: 6, lastSegment: 6 }, + resistanceOhm: 12.5, + reactanceOhm: -3.25, + }]; + await compareRepresentationCase({ + name: "loaded R1", + ...loaded, + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + expectedSections: 4, + }); +}); + +async function compareExplicitFallback(name, value) { + const [off, automatic] = await Promise.all([ + createNecArraySolver(value.description, { symmetry: "off" }), + createNecArraySolver(value.description, { + symmetry: "auto", + symmetrizer: { positionEpsilonM: 0 }, + }), + ]); + try { + await Promise.all([ + off.prepare({ frequencyMHz: value.fixture.frequencyMHz }), + automatic.prepare({ frequencyMHz: value.fixture.frequencyMHz }), + ]); + assert.equal(off.getDiagnostics().representation, "explicit"); + assert.equal(automatic.getDiagnostics().representation, "explicit"); + const [offMatrix, automaticMatrix] = await Promise.all([ + off.computeImpedanceMatrix(), + automatic.computeImpedanceMatrix(), + ]); + assertComplexClose(automaticMatrix.impedance, offMatrix.impedance, copyTolerance, + `${name} fallback Z`); + assert.equal(automaticMatrix.impedance.rows, value.description.elements.length); + const currents = currentVector(value.description.elements.length, 0.19); + const [offSolution, automaticSolution] = await Promise.all([ + off.solveCurrents(currents), + automatic.solveCurrents(currents), + ]); + compareSolutions(automaticSolution, offSolution, copyTolerance, `${name} fallback solution`); + const [offField, automaticField, offEmbedded, automaticEmbedded] = await Promise.all([ + off.computeFarField(smokeFieldRequest), + automatic.computeFarField(smokeFieldRequest), + off.computeEmbeddedFarFields(smokeFieldRequest, { kind: "unit-current", valueA: 1 }), + automatic.computeEmbeddedFarFields(smokeFieldRequest, { kind: "unit-current", valueA: 1 }), + ]); + compareFields(automaticField, offField, copyTolerance, `${name} fallback field`); + compareFields(automaticEmbedded, offEmbedded, copyTolerance, `${name} fallback embedded`); + assert.equal(automaticSolution.ports.length, value.description.elements.length); + assert.equal(automaticEmbedded.ports.length, value.description.elements.length); + for (const result of [automaticMatrix, automaticSolution, automaticField, automaticEmbedded]) { + assert.equal("generatedTag" in result, false); + assert.equal("copyIndex" in result, false); + assert.equal("symmetry" in result, false); + } + } finally { + await Promise.all([off.dispose(), automatic.dispose()]); + } +} + +test("WP-S6 N1 and P1 fallbacks retain the unbranched caller contract", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const n1 = gridDescription({ side: 3 }); + const n1Plan = analyzeArraySymmetry(n1.description, { positionEpsilonM: 0 }); + assert.equal(n1Plan.kind, "explicit"); + assert.ok(n1Plan.reasons.some((reason) => reason.code.startsWith("FIXED_ELEMENT"))); + + const p1 = gridDescription({ side: 4, kind: "helix-pattern" }); + const p1Plan = analyzeArraySymmetry(p1.description, { positionEpsilonM: 0 }); + assert.equal(p1Plan.kind, "explicit"); + assert.equal(p1Plan.reasons[0].code, "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM"); + + await compareExplicitFallback("N1", n1); + await compareExplicitFallback("P1", p1); +}); + +test("WP-S6 O1 restores off-origin complex phase", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const o1 = gridDescription({ side: 4, centerM: [0.173, -0.219] }); + await compareRepresentationCase({ + name: "O1", + ...o1, + symmetrizer: { positionEpsilonM: 1e-12, allowRotation: false }, + expectedSections: 4, + }); +}); + +test("WP-S6 E1 discloses canonicalization and stays within locked numerical bounds", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 180_000, +}, async () => { + const e1 = gridDescription({ side: 4 }); + const epsilon = 1e-10 * e1.fixture.wavelengthM; + const jittered = structuredClone(e1.description); + jittered.elements = jittered.elements.map((element, index) => ({ + ...element, + positionM: [ + element.positionM[0] + (((index * 5) % 7) - 3) * epsilon / 12, + element.positionM[1] + (((index * 3) % 5) - 2) * epsilon / 10, + ], + })); + const plan = analyzeArraySymmetry(jittered, { + positionEpsilonM: epsilon, + allowRotation: false, + }); + assert.equal(plan.kind, "symmetric"); + assert.equal(plan.diagnostics.exact, false); + assert.equal(plan.diagnostics.canonicalizations.length, 16); + assert.ok(plan.diagnostics.canonicalizations.every((entry) => entry.distanceM <= epsilon)); + assert.ok(plan.diagnostics.maxPositionAdjustmentM > 0); + await compareRepresentationCase({ + name: "E1", + description: jittered, + fixture: e1.fixture, + symmetrizer: { positionEpsilonM: epsilon, allowRotation: false }, + expectedSections: 4, + tolerance: 1e-7, + }); +}); + +test("WP-S6 R3 8x8 gathered matrix tier", { + skip: !hasWasm && "WASM artifacts have not been built", + timeout: 600_000, +}, async () => { + const r3 = gridDescription({ side: 8 }); + await compareRepresentationCase({ + name: "R3", + ...r3, + symmetrizer: { positionEpsilonM: 0, allowRotation: false }, + expectedSections: 4, + checkField: false, + }); +}); From 3ecc54b07c75cfbadf2b7844830c8f7ea756d8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 18:20:09 +0200 Subject: [PATCH 43/46] bench: add symmetry performance gates --- docs/01_symmetry_support.md | 65 ++- packages/necpp-wasm/bench/README.md | 134 +++-- packages/necpp-wasm/bench/RESULTS.md | 188 ++++--- packages/necpp-wasm/bench/array-benchmark.mjs | 518 +++++++++++++----- packages/necpp-wasm/bench/array-case.mjs | 458 ++++++++++++---- .../necpp-wasm/test/array-benchmark.test.mjs | 15 + 6 files changed, 1025 insertions(+), 353 deletions(-) diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 8eae78fb..54a9c32f 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1237,7 +1237,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S4 Direct and worker TypeScript API | complete | Codex | npm/WASM: 46/46; pack: 5/5; browser: 3/3; native ABI: 8 assertions | WP-S5 may use only the exported descriptors, immutable completion metadata, typed failure details, and direct/worker methods; private ABI `bigint` values never escape. | | WP-S5 Transparent symmetrizer | complete | Codex | npm/WASM: 60/60; pack: 5/5; browser: 3/3; focused WP-S5: 14/14 | WP-S6 can consume the public facade without plan-kind branches; caller-order tags, ports, vectors, matrices, and field bases are representation-independent. | | WP-S6 End-to-end equivalence suite | complete | Codex | Focused WP-S6: 8/8; npm/WASM: 68/68 + typecheck; native `[wp_s2]` 10,599 and `[wp_s3]` 8 assertions | WP-S7 should reuse the reference fixture, caller-order checks, complex metrics, and ordinary 8 x 8 R3 gate before reporting performance. | -| WP-S7 Benchmarks and performance gates | not started | — | — | — | +| WP-S7 Benchmarks and performance gates | complete | Codex | 45/45 isolated current cases; 30/30 binary64 comparisons; 16 x 16 prepare 11.55x; matrix 4.00x | WP-S8 may claim only the measured matrix-scale results; the explicit 2 x 2 pre-feature regression gate remains a documented miss. | | WP-S8 Public documentation, examples, and release hardening | not started | — | — | — | | WP-S9 Final version bump and release identity | not started | — | — | — | @@ -1844,6 +1844,69 @@ DoD: Handoff focus: WP-S8 uses measured facts, not theoretical estimates, in public documentation. +Completion evidence (2026-08-30, Windows, Node 24.14.1, Emscripten 4.0.7): + +- Replaced the historical stateful/report comparison as the default workload + with process-isolated `explicit`, `manual-reflection`, and + `auto-reflection` paths over the shared Section 7 fixture. The `deck` backend + remains available as historical formatted-report coverage. +- Schema-v2 NDJSON records analysis, module creation, construction, completion, + port/environment setup, prepare, first and retained changed-current solves, + combined far field, optional complete Z/Y extraction, sampled RSS, exact + primary interaction-matrix bytes, artifact/environment identity, classified + failures, and median/min/max summaries. CLI help and `bench/README.md` + document every option and record type. +- The three-round current-artifact run completed 45/45 cases at sides 2, 4, 8, + 12, and 16. All 30 manual/automatic comparisons passed requested/achieved + port quantities, powers, complete complex fields, and the 2 x 2 / 4 x 4 + caller-order complex Z/Y gates at `1e-8`; the largest scaled error was + `1.13e-13`. +- The pinned artifact SHA-256 was + `42d427c52b06792471d92e148cb0ed6ece33b4dabf1edabfe355ddcf4b1e0a28`. + The 16 x 16 manual prepare median was 1,142.14 ms versus 13,196.45 ms + explicit (11.55x). Auto/manual deltas at 8/12/16 were +2.32%, -1.18%, and + -0.18%; planner cold shares were 2.10%, 0.77%, and 0.26%. Exact wire-only + primary matrix allocation fell from 121.00 MiB to 30.25 MiB at 16 x 16, + with the same 4.00x reduction at every size. +- Built planning-only commit `78bdafe` with the same Docker toolchain and ran + its explicit facade through the identical protocol. Current explicit prepare + deltas at 4/8/12/16 were -6.34%, -2.12%, +0.21%, and +0.55%. The unqualified + 5% regression gate remains a transparent miss at 2 x 2: +19.41% in the + three-round run and +11.48% in a separate 15-round check with overlapping + ranges. `bench/RESULTS.md` records the bounded fixed-overhead profiling + follow-up; no numerical or performance tolerance was changed. +- Raw current/baseline NDJSON and summary JSON remain under ignored + `bench/results/`. `bench/RESULTS.md` records both artifact hashes, exact + commands, worktree state, tables, interpretations, and the historical deck + context. +- Final regression validation passed: `npm test` ran 69/69 tests plus strict + typechecking; packed-consumer tests passed 5/5; browser direct, worker, and + example integrations passed against one inspected tarball; native `[wp_s2]` + passed 10,599 assertions and `[wp_s3]` passed eight assertions. JavaScript + syntax checks, the three focused benchmark tests, the correctness-gated 2 x + 2 CLI smoke run, and `git diff --check` also passed. +- The first sandboxed pack/browser attempts could not write the user npm cache + (`EPERM`); an unparameterized browser invocation also printed its required + mode/tarball usage. They were rerun with npm-cache access, explicit + direct/worker/example modes, and the single inspected tarball with SHA-256 + `80dbd4c3ffb450cadef9c93909f0e1a99ae513ad14572570806f04c042718afa`. + `ctest` was not on this PowerShell PATH, so the cached Release Catch2 binary + was invoked directly for the native WP-S2/WP-S3 partitions. No test was + silently skipped. + +Contract decisions for WP-S8 and later: + +- Public documentation may state an 11.55x measured 16 x 16 preparation + speedup and fourfold primary-matrix allocation reduction only with this host, + model, artifact, and three-round context. It must not turn those measurements + into an unconditional speedup promise. +- Planner overhead is measured separately from construction and preparation. + Complete Z/Y extraction remains outside the ordinary 8 x 8 through 16 x 16 + preparation workload unless `--z-matrix-sides` explicitly opts in. +- The 2 x 2 explicit pre-feature regression miss must remain visible until a + later profiling change passes the same protocol without weakening structural + symmetry or load validation. + ### WP-S8 — Public documentation, examples, and release hardening Dependencies: WP-S7. diff --git a/packages/necpp-wasm/bench/README.md b/packages/necpp-wasm/bench/README.md index 07406ff5..8a01dd70 100644 --- a/packages/necpp-wasm/bench/README.md +++ b/packages/necpp-wasm/bench/README.md @@ -1,57 +1,109 @@ -# WASM array benchmark +# WASM array benchmarks -This benchmark compares two public `@necpp-engine/wasm` execution paths over -the same centred square array: +The reference-array benchmark compares three stateful representations of the +same full caller description: -- `stateful`: `createNecModel()`, wire/port construction, `prepare()`, and a - simultaneous 1 + j0 V solve at every centre-segment port; -- `deck`: `runDeck()` with equivalent `GW`, `GE`, `FR`, and `EX` cards followed - by `XQ` and `EN`, including parsing and copying the complete formatted NEC - report back to JavaScript. +- `explicit`: every dipole is added independently; +- `manual-reflection`: one positive-X/positive-Y quadrant is expanded across + `x=0` and `y=0`; and +- `auto-reflection`: the full position list is analyzed and canonicalized by + the transparent planner before the accepted quadrant is built. -The card layout follows the authoritative -[NEC-2 Part 3 manual](https://www.nec2.org/other/nec2prt3.pdf). Each -backend/size/round runs in a fresh Node process so a trap or timeout does not -erase earlier results. The runner emits newline-delimited JSON as cases finish -and can also retain a final JSON summary. +Every case uses the shared Section 7 fixture: 11-segment, centre-fed, +Z-directed lambda/3 dipoles at lambda/4 height, lambda/2 XY spacing, and a +perfect infinite ground. Sizes 2, 4, 8, 12, and 16 correspond to 44, 176, 704, +1,584, and 2,816 equations. A deterministic off-broadside current taper proves +that the excitation need not share the geometry symmetry. -Build the package and run the default 2 x 2 through 16 x 16 sweep with 11 -segments per dipole: +Each representation/size/round runs in a fresh Node process. The runner checks +requested and achieved port quantities, active impedances, powers, and complex +combined far fields against the explicit model. It additionally gathers and +checks complete caller-order complex Z and Y matrices at 2 x 2 and 4 x 4 by +default. No speed or allocation ratio is emitted if a case or numerical check +fails. The `deck` backend remains available as historical formatted-report +coverage, but it is not the binary64 symmetry oracle. + +## Run + +Build the package, then run the default three-round sweep: ```powershell npm --prefix packages/necpp-wasm run build npm --prefix packages/necpp-wasm run bench:array -- ` - --output packages/necpp-wasm/bench/results/array-2-16-11seg.ndjson + --output packages/necpp-wasm/bench/results/symmetry-reference.ndjson +``` + +The output directory is ignored by Git. NDJSON is appended as each child +finishes; an adjacent `*.summary.json` contains median/min/max statistics, +correctness metrics, ratios, and gate results. Use `--overwrite` to replace an +existing path. + +To compare the explicit path with a compatible pre-feature build, first run +that build through this same driver with `--backends explicit` and its `dist` +directory, then pass the resulting summary to the current run: + +```powershell +npm --prefix packages/necpp-wasm run bench:array -- ` + --backends explicit ` + --module-directory C:\path\to\pre-feature\dist ` + --output packages/necpp-wasm/bench/results/pre-feature.ndjson + +npm --prefix packages/necpp-wasm run bench:array -- ` + --baseline-summary packages/necpp-wasm/bench/results/pre-feature.summary.json ` + --output packages/necpp-wasm/bench/results/current.ndjson ``` -See [RESULTS.md](RESULTS.md) for a three-round 2 x 2 through 16 x 16 comparison -captured with the 4 MiB-stack build. +The runner rejects a baseline with a different schema, protocol ID, frequency, +segment count, side list, Node/OS/architecture/CPU identity, Emscripten version, +or WASM stack size. -Useful options: +## Options + +Run `npm --prefix packages/necpp-wasm run bench:array -- --help` for the +authoritative CLI. Important options are: ```text ---sides 2-16 Inclusive ranges and comma lists are accepted ---segments 11 Must be odd +--sides 2,4,8,12,16 +--segments 11 --frequency-mhz 300 ---backends stateful,deck ---rounds 1 Fresh processes per round ---retained-solves 10 Additional stateful solves after factorization ---timeout-seconds 600 Per backend/size/round ---equivalence-tolerance 1e-4 Relative L2 error after report rounding ---output PATH Optional NDJSON plus adjacent summary JSON ---overwrite Replace an existing output file ---fail-fast Stop after the first failure +--backends explicit,manual-reflection,auto-reflection +--rounds 3 +--retained-solves 10 +--z-matrix-sides 2,4 Add 8 for the optional 8 x 8 complete Z/Y workload +--timeout-seconds 600 +--equivalence-tolerance 1e-8 +--module-directory PATH +--baseline-summary PATH +--output PATH +--overwrite +--fail-fast ``` -The stateful `coldTotalMs` includes module instantiation, geometry/port calls, -preparation, the first solve, and result copying. Additional retained solves -are excluded from that comparable total and reported separately. The deck -`coldTotalMs` includes deck text generation, module instantiation inside -`runDeck()`, parsing, preparation, the solve, full report formatting, copying -that report, and parsing its source-current table. JavaScript module import -time is excluded from both. - -Source currents parsed from the deck's `ANTENNA INPUT PARAMETERS` table are -compared with the stateful port currents. The legacy report prints five-digit -scientific values, so comparison uses a relative tolerance rather than exact -equality. +## Schema version 2 + +The NDJSON stream contains four record types: + +- `metadata`: artifact hashes, versions, OS/CPU/Node/Emscripten information, + Git commit and exact worktree status, model dimensions, field grid, + tolerance, and command options; +- `case`: one isolated execution, classified failure or phase timings, + diagnostics, checksums, observed RSS, and exact primary interaction-matrix + allocation; +- `comparison`: binary64 relative-L2 and scaled-maximum metrics for each + manual/automatic result against the same-round explicit result; and +- `summary`: median/min/max case statistics, comparisons, correctness-gated + ratios, performance gates, and the optional baseline artifact identity. + +The measured phases are transparent analysis, module creation, wire +construction, geometry completion/expansion, port and environment setup, +`prepare()`, first current solve, retained changed-current solves, combined +far field, and optional complete Z/Y extraction. `coldTotalMs` includes every +phase through the optional matrix extraction and excludes retained solves. + +For this wire-only model, NEC allocates `n * np` complex-double entries for the +primary interaction matrix. The benchmark reports that exact allocation as +`primaryInteractionMatrixBytes`, plus sampled process RSS. It does not present +RSS as an exact WASM heap measurement. + +See [RESULTS.md](RESULTS.md) for the curated WP-S7 reference run and the older +stateful-versus-deck historical measurements. diff --git a/packages/necpp-wasm/bench/RESULTS.md b/packages/necpp-wasm/bench/RESULTS.md index 1a1199db..03aa4567 100644 --- a/packages/necpp-wasm/bench/RESULTS.md +++ b/packages/necpp-wasm/bench/RESULTS.md @@ -1,73 +1,129 @@ -# Array benchmark results - 2026-08-29 - -Three fresh-process rounds per backend on an AMD Ryzen 7 PRO 7840HS, Windows -10.0.26200, Node 24.14.1, Emscripten 4.0.7, and the 4 MiB-stack optimized WASM -artifact (`f6b681e1d94b3358ae6ddee0077b1ce40aab6b52dc9978e7a946e8c0c3c4e709`). -The worktree was dirty with the stack fix and benchmark implementation. - -Each element is a free-space, centre-fed, Z-directed lambda/4 dipole with 11 -segments. Every port is driven simultaneously at 1 + j0 V and 300 MHz. -`Stateful cold` covers module creation, geometry and port construction, -preparation, the first solve, and result copying. `Full deck cold` covers deck -generation, `runDeck()`, the complete formatted report, and source-current -parsing. Values are medians. - -| Array | Equations | Stateful cold | Full deck cold | Deck delta | Retained solve | -|---:|---:|---:|---:|---:|---:| -| 2 x 2 | 44 | 20.0 ms | 26.4 ms | 32.0% | 0.23 ms | -| 3 x 3 | 99 | 30.4 ms | 38.5 ms | 27.0% | 0.49 ms | -| 4 x 4 | 176 | 35.8 ms | 46.6 ms | 30.1% | 0.49 ms | -| 5 x 5 | 275 | 55.6 ms | 73.9 ms | 32.9% | 0.53 ms | -| 6 x 6 | 396 | 92.8 ms | 108.1 ms | 16.4% | 0.79 ms | -| 7 x 7 | 539 | 153.5 ms | 174.4 ms | 13.6% | 1.08 ms | -| 8 x 8 | 704 | 301.2 ms | 310.8 ms | 3.2% | 1.56 ms | -| 9 x 9 | 891 | 499.6 ms | 542.2 ms | 8.5% | 2.41 ms | -| 10 x 10 | 1,100 | 862.0 ms | 890.3 ms | 3.3% | 3.33 ms | -| 11 x 11 | 1,331 | 1,447.7 ms | 1,475.5 ms | 1.9% | 4.63 ms | -| 12 x 12 | 1,584 | 2,336.6 ms | 2,364.2 ms | 1.2% | 6.39 ms | -| 13 x 13 | 1,859 | 3,778.9 ms | 3,781.0 ms | 0.1% | 11.20 ms | -| 14 x 14 | 2,156 | 5,789.7 ms | 5,738.1 ms | -0.9% | 11.86 ms | -| 15 x 15 | 2,475 | 8,619.1 ms | 8,697.5 ms | 0.9% | 15.83 ms | -| 16 x 16 | 2,816 | 12,813.7 ms | 12,907.2 ms | 0.7% | 17.30 ms | - -All 90 backend cases completed. All 45 stateful/deck pairs passed the -source-current equivalence check. Relative L2 differences ranged from -7.54e-6 to 2.07e-5, consistent with the legacy report's five-digit printed -precision. - -At 16 x 16 the interaction matrix itself is 121 MiB. Median process RSS growth -was 138.0 MiB for stateful and 138.8 MiB for the deck path; the returned full -deck report was 640 KiB. Cold-path differences above roughly 700 equations are -small compared with run-to-run noise because both facades spend nearly all of -their time in the same matrix preparation. Stateful mode's material advantage -is reuse: another 256-port drive costs about 17 ms instead of repeating a -roughly 13-second deck execution and factorization. - -Command: +# Symmetry reference benchmark - 2026-08-30 -```powershell -npm --prefix packages/necpp-wasm run bench:array -- ` - --sides 2-16 --segments 11 --rounds 3 --retained-solves 10 ` - --timeout-seconds 600 ` - --output packages/necpp-wasm/bench/results/array-2-16-11seg-3round-20260829.ndjson +WP-S7 was measured on an AMD Ryzen 7 PRO 7840HS, Windows 10.0.26200, +Node 24.14.1, and Emscripten 4.0.7. Every representation/size/round used a +fresh process and the Section 7 lambda-scaled array over perfect ground. + +The current artifact was rebuilt with the pinned Docker image after the run; +its digest was unchanged: + +```text +WASM SHA-256 42d427c52b06792471d92e148cb0ed6ece33b4dabf1edabfe355ddcf4b1e0a28 +WASM bytes 733440 +engine 2.3.4 +package 0.1.1 +commit 52194f5118689e7a2b46076150612c36761964de ``` -The generated NDJSON and summary JSON remain local under `bench/results/`, -which is ignored by Git. This was a scaling comparison rather than a -laboratory-grade performance run: machine power state was not controlled and -backend order was fixed. +The worktree contained the WP-S7 implementation and status edits plus three +pre-existing unrelated untracked documents. The exact porcelain status is in +the ignored metadata record; no claim of a clean worktree is made. The +artifact itself was a clean pinned-toolchain rebuild of the current native and +TypeScript sources. + +## Correctness and performance + +All 45 current-artifact cases completed. All 30 manual/automatic comparisons +passed the `1e-8` relative-L2 and scaled-maximum gates for requested currents, +achieved currents, voltages, active impedances, powers, and complete complex +combined far fields. The 2 x 2 and 4 x 4 cases also passed complete +caller-order complex Z and Y comparison. The largest observed scaled error was +`1.13e-13` (16 x 16 powers). + +Medians from three rounds: -## Historical 19-segment endpoint +| Array | Equations | Explicit prepare | Manual prepare | Auto prepare | Manual speedup | Auto planner | Planner / auto cold | Auto vs manual | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 2 x 2 | 44 | 7.66 ms | 2.85 ms | 2.87 ms | 2.69x | 1.72 ms | 5.50% | +0.93% | +| 4 x 4 | 176 | 24.33 ms | 11.36 ms | 10.17 ms | 2.14x | 1.92 ms | 3.96% | -10.54% | +| 8 x 8 | 704 | 297.82 ms | 55.43 ms | 56.72 ms | 5.37x | 3.89 ms | 2.10% | +2.32% | +| 12 x 12 | 1,584 | 2,445.98 ms | 282.44 ms | 279.11 ms | 8.66x | 7.71 ms | 0.77% | -1.18% | +| 16 x 16 | 2,816 | 13,196.45 ms | 1,142.14 ms | 1,140.06 ms | 11.55x | 13.30 ms | 0.26% | -0.18% | -The earlier NEC++ performance workload used 19 rather than 11 segments per -dipole. A separate fresh-process run exercised that discretization at the -largest array size: +The 16 x 16 manual preparation target passes at 11.55x. Auto/manual preparation +parity passes at every gated size (8 x 8 and larger), and planner overhead is +below 5% of auto cold time at those sizes. The ungated 4 x 4 timing has a +10.54% auto/manual difference because both prepare measurements are about +10 ms; no large-array parity claim is derived from it. + +The primary interaction matrix uses `n * np` complex-double entries for this +wire-only model. Two reflection planes reduce `np` to `n / 4`, producing the +measured exact fourfold allocation ratio: + +| Array | Explicit matrix | Reflected matrix | Reduction | +|---:|---:|---:|---:| +| 2 x 2 | 0.03 MiB | 0.01 MiB | 4.00x | +| 4 x 4 | 0.47 MiB | 0.12 MiB | 4.00x | +| 8 x 8 | 7.56 MiB | 1.89 MiB | 4.00x | +| 12 x 12 | 38.29 MiB | 9.57 MiB | 4.00x | +| 16 x 16 | 121.00 MiB | 30.25 MiB | 4.00x | + +RSS samples, every individual phase, retained changed-current solves, and +median/min/max values remain in the ignored summary rather than being rounded +into this document. + +## Explicit pre-feature comparison + +Commit `78bdafe` is the planning-only revision before WP-S0 through WP-S6. It +was archived into an ignored directory, built with the same Emscripten 4.0.7 +Docker image, and run through the exact schema-v2 protocol using its public +explicit stateful API. + +```text +pre-feature WASM SHA-256 196fc36329aeae6c3e44d8da2ba560d316f4d4a5f3756b6d72f4212f2af511c3 +pre-feature WASM bytes 698667 +``` + +| Array | Current vs pre-feature explicit prepare | +|---:|---:| +| 2 x 2 | +19.41% (+1.25 ms in the three-round medians) | +| 4 x 4 | -6.34% | +| 8 x 8 | -2.12% | +| 12 x 12 | +0.21% | +| 16 x 16 | +0.55% | + +The matrix-scale explicit path (8 x 8 through 16 x 16) shows no material +regression. The unqualified 5% gate nevertheless records a **miss** at 2 x 2; +the benchmark does not hide it or turn it into a pass. A separate 15-round +2 x 2 check reproduced a smaller but still over-gate +11.48% median delta +(7.686 ms versus 6.894 ms), while the ranges overlapped. The bounded follow-up +is to profile the fixed explicit `completeGeometry()`/`prepare()` validation +and JS/WASM call overhead at 44 equations; it must preserve every symmetry and +load validation gate and must not delay the large-array feature. + +## Commands and retained data + +The raw files are ignored by Git: + +```text +packages/necpp-wasm/bench/results/symmetry-prefeature-78bdafe-3round-20260830.ndjson +packages/necpp-wasm/bench/results/symmetry-prefeature-78bdafe-3round-20260830.summary.json +packages/necpp-wasm/bench/results/symmetry-current-3round-20260830.ndjson +packages/necpp-wasm/bench/results/symmetry-current-3round-20260830.summary.json +``` + +Reference commands: + +```powershell +npm --prefix packages/necpp-wasm run bench:array -- ` + --sides 2,4,8,12,16 --backends explicit --rounds 3 ` + --retained-solves 10 --z-matrix-sides 2,4 --timeout-seconds 600 ` + --module-directory packages/necpp-wasm/bench/results/pre-feature-78bdafe/packages/necpp-wasm/dist ` + --output packages/necpp-wasm/bench/results/symmetry-prefeature-78bdafe-3round-20260830.ndjson + +npm --prefix packages/necpp-wasm run bench:array -- ` + --sides 2,4,8,12,16 ` + --backends explicit,manual-reflection,auto-reflection --rounds 3 ` + --retained-solves 10 --z-matrix-sides 2,4 --timeout-seconds 600 ` + --baseline-summary packages/necpp-wasm/bench/results/symmetry-prefeature-78bdafe-3round-20260830.summary.json ` + --output packages/necpp-wasm/bench/results/symmetry-current-3round-20260830.ndjson +``` -| Array | Equations | Stateful cold | Full deck cold | Deck delta | Retained solve | -|---:|---:|---:|---:|---:|---:| -| 16 x 16 | 4,864 | 64,070.6 ms | 64,492.8 ms | 0.7% | 49.86 ms | +## Historical stateful/deck comparison -Both paths completed with the 4 MiB stack. The interaction matrix alone is 361 -MiB; observed RSS growth was 387.0 MiB stateful and 386.4 MiB for the deck -path. The 1.25 MiB legacy report contained 15,457 lines. Its 256 source -currents agreed with the stateful result to 1.33e-5 relative L2 error. +The 2026-08-29 historical benchmark compared the pre-symmetry free-space +stateful facade with formatted `runDeck()` output. At 16 x 16 and 11 segments, +both paths took about 12.8 seconds because they shared the same full matrix; +a retained stateful solve took 17.3 ms. That result remains useful as evidence +for factorization reuse, but its rounded report currents and different geometry +are not an oracle for the symmetry measurements above. diff --git a/packages/necpp-wasm/bench/array-benchmark.mjs b/packages/necpp-wasm/bench/array-benchmark.mjs index 487f0aff..768cad43 100644 --- a/packages/necpp-wasm/bench/array-benchmark.mjs +++ b/packages/necpp-wasm/bench/array-benchmark.mjs @@ -11,11 +11,38 @@ import { import { cpus, platform, release, totalmem } from "node:os"; import { dirname, resolve } from "node:path"; +import { FIELD_REQUEST, REPRESENTATIONS } from "./array-case.mjs"; + +const SCHEMA_VERSION = 2; +const PROTOCOL_ID = "symmetry-reference-array-v1"; const packageDirectory = resolve(import.meta.dirname, ".."); const repositoryRoot = resolve(packageDirectory, "../.."); const caseScript = resolve(import.meta.dirname, "array-case.mjs"); const invocationDirectory = process.env.INIT_CWD ?? process.cwd(); +const HELP = `WASM reference-array symmetry benchmark + +Usage: + node bench/array-benchmark.mjs [options] + +Options: + --sides LIST Comma list/ranges (default: 2,4,8,12,16) + --segments N Odd segments per dipole (default: 11) + --frequency-mhz N Frequency in MHz (default: 300) + --backends LIST explicit,manual-reflection,auto-reflection,deck + --rounds N Fresh processes per case (default: 3) + --retained-solves N Changed-current solves after the cold path (default: 10) + --z-matrix-sides LIST Full caller-order Z/Y extraction (default: 2,4) + --timeout-seconds N Per child process (default: 600) + --equivalence-tolerance N Binary64 relative L2 and scaled-max gate (default: 1e-8) + --module-directory PATH Built package directory containing index.js and nec2pp.wasm + --baseline-summary PATH Compatible pre-feature explicit summary for regression ratios + --output PATH Incremental NDJSON plus adjacent .summary.json + --overwrite Replace an existing output + --fail-fast Stop after the first case or correctness failure + --help Show this help +`; + function parseInteger(value, name, minimum = 1) { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) { @@ -24,21 +51,18 @@ function parseInteger(value, name, minimum = 1) { return parsed; } -function parseSides(value) { +function parseSides(value, name = "side") { const sides = new Set(); + if (value === "") return []; for (const part of value.split(",")) { const range = part.match(/^(\d+)-(\d+)$/); if (range !== null) { - const start = parseInteger(range[1], "side"); - const end = parseInteger(range[2], "side"); - if (end < start) { - throw new Error(`side range ${part} is descending`); - } - for (let side = start; side <= end; side += 1) { - sides.add(side); - } + const start = parseInteger(range[1], name); + const end = parseInteger(range[2], name); + if (end < start) throw new Error(`${name} range ${part} is descending`); + for (let side = start; side <= end; side += 1) sides.add(side); } else { - sides.add(parseInteger(part, "side")); + sides.add(parseInteger(part, name)); } } return [...sides].sort((left, right) => left - right); @@ -46,32 +70,30 @@ function parseSides(value) { function parseArguments(argv) { const options = { - sides: "2-16", + sides: "2,4,8,12,16", segments: "11", frequencyMHz: "300", - backends: "stateful,deck", - rounds: "1", + backends: REPRESENTATIONS.join(","), + rounds: "3", retainedSolves: "10", + zMatrixSides: "2,4", timeoutSeconds: "600", - equivalenceTolerance: "0.0001", + equivalenceTolerance: "1e-8", + moduleDirectory: resolve(packageDirectory, "dist"), + baselineSummary: undefined, output: undefined, overwrite: false, failFast: false, }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; - if (argument === "--overwrite") { - options.overwrite = true; - continue; - } - if (argument === "--fail-fast") { - options.failFast = true; + if (argument === "--overwrite" || argument === "--fail-fast") { + options[argument === "--overwrite" ? "overwrite" : "failFast"] = true; continue; } if (!argument.startsWith("--") || argv[index + 1] === undefined) { throw new Error(`invalid argument near ${argument}`); } - const key = argument.slice(2); const mappings = { sides: "sides", segments: "segments", @@ -79,26 +101,40 @@ function parseArguments(argv) { backends: "backends", rounds: "rounds", "retained-solves": "retainedSolves", + "z-matrix-sides": "zMatrixSides", "timeout-seconds": "timeoutSeconds", "equivalence-tolerance": "equivalenceTolerance", + "module-directory": "moduleDirectory", + "baseline-summary": "baselineSummary", output: "output", }; - if (mappings[key] === undefined) { - throw new Error(`unknown option ${argument}`); - } - options[mappings[key]] = argv[index + 1]; + const key = mappings[argument.slice(2)]; + if (key === undefined) throw new Error(`unknown option ${argument}`); + options[key] = argv[index + 1]; index += 1; } + const segments = parseInteger(options.segments, "segments"); - if (segments % 2 === 0) { - throw new Error("segments must be odd"); + if (segments % 2 === 0) throw new Error("segments must be odd"); + const requestedBackends = options.backends.split(",").map((backend) => + backend === "stateful" ? "explicit" : backend); + if (requestedBackends.length === 0 + || requestedBackends.some((backend) => !REPRESENTATIONS.includes(backend) && backend !== "deck")) { + throw new Error(`backends must contain ${[...REPRESENTATIONS, "deck"].join(", ")}`); } - const backends = options.backends.split(","); - if ( - backends.length === 0 - || backends.some((backend) => backend !== "stateful" && backend !== "deck") - ) { - throw new Error("backends must contain stateful and/or deck"); + const backends = [...new Set(requestedBackends)].sort((left, right) => + [...REPRESENTATIONS, "deck"].indexOf(left) - [...REPRESENTATIONS, "deck"].indexOf(right)); + if (backends.some((backend) => backend.endsWith("reflection")) && !backends.includes("explicit")) { + throw new Error("reflection performance requires explicit in --backends as its correctness oracle"); + } + const sides = parseSides(options.sides); + if (backends.some((backend) => backend.endsWith("reflection")) + && sides.some((side) => side % 2 !== 0)) { + throw new Error("manual and automatic reflection require even --sides"); + } + const zMatrixSides = parseSides(options.zMatrixSides, "Z-matrix side"); + if (zMatrixSides.some((side) => !sides.includes(side))) { + throw new Error("z-matrix-sides must be a subset of sides"); } const frequencyMHz = Number(options.frequencyMHz); const equivalenceTolerance = Number(options.equivalenceTolerance); @@ -108,27 +144,29 @@ function parseArguments(argv) { if (!(equivalenceTolerance >= 0) || !Number.isFinite(equivalenceTolerance)) { throw new Error("equivalence-tolerance must be finite and nonnegative"); } + const resolveOptional = (value) => value === undefined + ? undefined + : resolve(invocationDirectory, value); return { - sides: parseSides(options.sides), + sides, segments, frequencyMHz, backends, rounds: parseInteger(options.rounds, "rounds"), retainedSolves: parseInteger(options.retainedSolves, "retained-solves", 0), + zMatrixSides, timeoutMs: parseInteger(options.timeoutSeconds, "timeout-seconds") * 1000, equivalenceTolerance, - output: options.output === undefined - ? undefined - : resolve(invocationDirectory, options.output), + moduleDirectory: resolve(invocationDirectory, options.moduleDirectory), + baselineSummary: resolveOptional(options.baselineSummary), + output: resolveOptional(options.output), overwrite: options.overwrite, failFast: options.failFast, }; } function median(values) { - if (values.length === 0) { - return null; - } + if (values.length === 0) return null; const sorted = [...values].sort((left, right) => left - right); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 @@ -136,6 +174,13 @@ function median(values) { : sorted[middle]; } +function stats(values) { + const finite = values.filter((value) => typeof value === "number" && Number.isFinite(value)); + return finite.length === 0 + ? { median: null, min: null, max: null } + : { median: median(finite), min: Math.min(...finite), max: Math.max(...finite) }; +} + function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } @@ -149,65 +194,69 @@ function commandOutput(command, args) { return result.status === 0 ? result.stdout.trim() : null; } +function packageVersionFor(moduleDirectory) { + const packageJsonPath = resolve(moduleDirectory, "../package.json"); + return existsSync(packageJsonPath) + ? JSON.parse(readFileSync(packageJsonPath, "utf8")).version + : null; +} + function buildMetadata(options) { - const packageJson = JSON.parse( - readFileSync(resolve(packageDirectory, "package.json"), "utf8"), - ); - const wasmPath = resolve(packageDirectory, "dist/nec2pp.wasm"); - if (!existsSync(wasmPath)) { - throw new Error("dist/nec2pp.wasm is missing; run npm run build first"); + const wasmPath = resolve(options.moduleDirectory, "nec2pp.wasm"); + const indexPath = resolve(options.moduleDirectory, "index.js"); + if (!existsSync(wasmPath) || !existsSync(indexPath)) { + throw new Error(`${options.moduleDirectory} must contain index.js and nec2pp.wasm`); } const rootCmake = readFileSync(resolve(repositoryRoot, "CMakeLists.txt"), "utf8"); const stackMatch = rootCmake.match(/set\(NECPP_WASM_STACK_SIZE\s+(\d+)/); - const dockerScript = readFileSync( - resolve(repositoryRoot, "scripts/build_wasm_docker.ps1"), - "utf8", - ); + const dockerScript = readFileSync(resolve(repositoryRoot, "scripts/build_wasm_docker.ps1"), "utf8"); const emscriptenMatch = dockerScript.match(/emscripten\/emsdk:([^"\s]+)/); + const gitStatus = (commandOutput("git", ["status", "--porcelain=v1"]) ?? "") + .split(/\r?\n/).filter(Boolean); return { type: "metadata", - schemaVersion: 1, + schemaVersion: SCHEMA_VERSION, + protocol: { + id: PROTOCOL_ID, + description: "lambda-scaled Z dipoles over perfect ground; binary64 caller-order checks", + }, startedAt: new Date().toISOString(), - packageVersion: packageJson.version, + packageVersion: packageVersionFor(options.moduleDirectory), nodeVersion: process.version, operatingSystem: `${platform()} ${release()}`, architecture: process.arch, - cpuModel: cpus()[0]?.model ?? "unknown", + cpuModel: cpus()[0]?.model?.trim() ?? "unknown", logicalCpuCount: cpus().length, physicalMemoryBytes: totalmem(), gitCommit: commandOutput("git", ["rev-parse", "HEAD"]), - gitDirty: (commandOutput("git", ["status", "--porcelain"]) ?? "").length > 0, + gitDirty: gitStatus.length > 0, + gitStatus, emscriptenVersion: emscriptenMatch?.[1] ?? null, wasmStackSizeBytes: stackMatch === null ? null : Number(stackMatch[1]), - wasmBytes: statSync(wasmPath).size, - wasmSha256: sha256(wasmPath), - options: { - ...options, - output: options.output, - timeoutMs: options.timeoutMs, + artifact: { + moduleDirectory: options.moduleDirectory, + wasmBytes: statSync(wasmPath).size, + wasmSha256: sha256(wasmPath), + indexSha256: sha256(indexPath), }, - }; -} - -function compareCurrents(stateful, deck) { - if (stateful.length !== deck.length) { - throw new Error("backend source-current counts differ"); - } - let deltaSquared = 0; - let referenceSquared = 0; - let maxAbsoluteDelta = 0; - for (let index = 0; index < stateful.length; index += 1) { - const realDelta = stateful[index].real - deck[index].real; - const imagDelta = stateful[index].imag - deck[index].imag; - const absoluteDelta = Math.hypot(realDelta, imagDelta); - deltaSquared += absoluteDelta ** 2; - referenceSquared += stateful[index].real ** 2 + stateful[index].imag ** 2; - maxAbsoluteDelta = Math.max(maxAbsoluteDelta, absoluteDelta); - } - return { - sourceCurrentCount: stateful.length, - relativeL2Error: Math.sqrt(deltaSquared) / Math.max(Math.sqrt(referenceSquared), 1e-300), - maxAbsoluteDelta, + model: { + frequencyMHz: options.frequencyMHz, + dipoleLengthWavelengths: 1 / 3, + centerHeightWavelengths: 1 / 4, + spacingWavelengths: 1 / 2, + radiusWavelengths: 1 / 1000, + segmentsPerDipole: options.segments, + feedSegment: (options.segments + 1) / 2, + ground: { kind: "perfect" }, + groundConnection: "none", + fieldRequest: FIELD_REQUEST, + currentWeights: "off-broadside deterministic amplitude/phase taper", + }, + tolerances: { + complexRelativeL2: options.equivalenceTolerance, + complexScaledMax: options.equivalenceTolerance, + }, + options: { ...options }, }; } @@ -219,61 +268,247 @@ function parseChildResult(result, identity) { try { record = JSON.parse(lines.at(-1) ?? ""); } catch { + const timedOut = result.error?.code === "ETIMEDOUT"; record = { type: "case", + schemaVersion: SCHEMA_VERSION, ok: false, ...identity, error: { + category: timedOut ? "timeout" : result.signal !== null ? "wasm-trap" : "runtime", name: result.error?.name ?? "ChildProcessError", message: result.error?.message ?? `benchmark child exited ${result.status}: ${stderr.trim()}`, + signal: result.signal, }, }; } if (result.status !== 0 && record.ok !== false) { record.ok = false; record.error = { + category: result.error?.code === "ETIMEDOUT" ? "timeout" : "wasm-trap", name: result.error?.name ?? "ChildProcessError", - message: result.error?.message - ?? `benchmark child exited ${result.status}: ${stderr.trim()}`, + message: result.error?.message ?? `benchmark child exited ${result.status}: ${stderr.trim()}`, + signal: result.signal, }; } return record; } -function caseSummary(records, backend, side) { - const matches = records.filter((record) => - record.backend === backend && record.side === side); +function complexMetrics(left, right) { + if (left.real.length !== right.real.length || left.imag.length !== right.imag.length + || left.real.length !== left.imag.length) { + throw new Error("complex result lengths differ"); + } + let deltaSquared = 0; + let baselineSquared = 0; + let maxDelta = 0; + let maxBaseline = 0; + for (let index = 0; index < left.real.length; index += 1) { + const values = [left.real[index], left.imag[index], right.real[index], right.imag[index]]; + if (!values.every(Number.isFinite)) throw new Error(`non-finite complex value at ${index}`); + const delta = Math.hypot(left.real[index] - right.real[index], left.imag[index] - right.imag[index]); + const baseline = Math.hypot(right.real[index], right.imag[index]); + deltaSquared += delta * delta; + baselineSquared += baseline * baseline; + maxDelta = Math.max(maxDelta, delta); + maxBaseline = Math.max(maxBaseline, baseline); + } + return { + relativeL2: Math.sqrt(deltaSquared) / Math.max(Math.sqrt(baselineSquared), 1e-300), + scaledMax: maxDelta / Math.max(maxBaseline, 1e-300), + }; +} + +function realMetrics(left, right) { + return complexMetrics({ real: left, imag: left.map(() => 0) }, { + real: right, + imag: right.map(() => 0), + }); +} + +function comparisonRecord(candidate, baseline, tolerance) { + const metrics = {}; + try { + for (const name of ["requested", "voltages", "currents", "activeImpedances"]) { + metrics[`solution.${name}`] = complexMetrics( + candidate.solution[name], + baseline.solution[name], + ); + } + metrics["solution.powersW"] = realMetrics(candidate.solution.powersW, baseline.solution.powersW); + if (JSON.stringify(candidate.field.thetaDeg) !== JSON.stringify(baseline.field.thetaDeg) + || JSON.stringify(candidate.field.phiDeg) !== JSON.stringify(baseline.field.phiDeg)) { + throw new Error("far-field angle grids differ"); + } + metrics["field.eTheta"] = complexMetrics(candidate.field.eTheta, baseline.field.eTheta); + metrics["field.ePhi"] = complexMetrics(candidate.field.ePhi, baseline.field.ePhi); + if ((candidate.matrices === undefined) !== (baseline.matrices === undefined)) { + throw new Error("matrix extraction presence differs"); + } + if (candidate.matrices !== undefined) { + if (candidate.matrices.order !== baseline.matrices.order) throw new Error("matrix orders differ"); + metrics["matrix.Z"] = complexMetrics(candidate.matrices.impedance, baseline.matrices.impedance); + metrics["matrix.Y"] = complexMetrics(candidate.matrices.admittance, baseline.matrices.admittance); + } + const withinTolerance = Object.values(metrics).every((metric) => + metric.relativeL2 <= tolerance && metric.scaledMax <= tolerance); + return { withinTolerance, metrics }; + } catch (error) { + return { withinTolerance: false, metrics, error: error.message }; + } +} + +function caseSummary(records, backend, side, segments) { + const matches = records.filter((record) => record.backend === backend && record.side === side); const successes = matches.filter((record) => record.ok); - const timingKeys = backend === "stateful" - ? [ - "instantiateMs", - "geometryMs", - "prepareMs", - "firstSolveMs", - "retainedSolveMedianMs", - "coldTotalMs", - "totalWithRetainedSolvesMs", - ] - : ["deckBuildMs", "runDeckMs", "coldTotalMs"]; + const timingKeys = backend === "deck" + ? ["deckBuildMs", "runDeckMs", "coldTotalMs"] + : [ + "analysisMs", "instantiateMs", "geometryConstructionMs", "geometryCompletionMs", + "portEnvironmentMs", "prepareMs", "firstSolveMs", "combinedFarFieldMs", + "impedanceMatrixMs", "retainedSolveMedianMs", "coldTotalMs", "totalWithRetainedSolvesMs", + ]; return { backend, side, - equations: side * side * records[0].segmentsPerDipole, + equations: side * side * segments, successCount: successes.length, failureCount: matches.length - successes.length, - medianTimingsMs: Object.fromEntries(timingKeys.map((key) => [ + failureCategories: matches.filter((record) => !record.ok).map((record) => record.error?.category), + timingStatsMs: Object.fromEntries(timingKeys.map((key) => [ key, - median(successes.map((record) => record.timings[key]).filter((value) => - typeof value === "number")), + stats(successes.map((record) => record.timings?.[key])), ])), - medianRssDeltaBytes: median(successes.map((record) => record.rssDeltaBytes)), - medianReportBytes: backend === "deck" - ? median(successes.map((record) => record.reportBytes)) - : undefined, + memoryStatsBytes: backend === "deck" ? undefined : { + peakObservedRssDelta: stats(successes.map((record) => record.memory?.peakObservedRssDeltaBytes)), + primaryInteractionMatrix: stats(successes.map((record) => record.memory?.primaryInteractionMatrixBytes)), + }, + }; +} + +function performanceRatios(cases, comparisons, options, baseline) { + const ratios = []; + const caseAt = (backend, side) => cases.find((value) => value.backend === backend && value.side === side); + for (const side of options.sides) { + const explicit = caseAt("explicit", side); + if (explicit === undefined || explicit.successCount !== options.rounds) continue; + const candidates = {}; + for (const backend of ["manual-reflection", "auto-reflection"]) { + const candidate = caseAt(backend, side); + const checks = comparisons.filter((value) => value.backend === backend && value.side === side); + if (candidate === undefined || candidate.successCount !== options.rounds + || checks.length !== options.rounds || checks.some((value) => !value.withinTolerance)) continue; + candidates[backend] = candidate; + } + const manual = candidates["manual-reflection"]; + const auto = candidates["auto-reflection"]; + const result = { side, correctnessPassed: true }; + if (manual !== undefined) { + result.manualPrepareSpeedup = explicit.timingStatsMs.prepareMs.median + / manual.timingStatsMs.prepareMs.median; + result.manualMatrixReduction = explicit.memoryStatsBytes.primaryInteractionMatrix.median + / manual.memoryStatsBytes.primaryInteractionMatrix.median; + } + if (auto !== undefined) { + result.autoPlannerMs = auto.timingStatsMs.analysisMs.median; + result.autoPlannerColdPercent = 100 * auto.timingStatsMs.analysisMs.median + / auto.timingStatsMs.coldTotalMs.median; + } + if (manual !== undefined && auto !== undefined) { + result.autoVsManualPrepareDeltaPercent = 100 + * (auto.timingStatsMs.prepareMs.median - manual.timingStatsMs.prepareMs.median) + / manual.timingStatsMs.prepareMs.median; + } + const baselineCase = baseline?.cases?.find((value) => + value.backend === "explicit" && value.side === side); + if (baselineCase?.timingStatsMs?.prepareMs?.median > 0) { + result.explicitVsBaselinePrepareDeltaPercent = 100 + * (explicit.timingStatsMs.prepareMs.median - baselineCase.timingStatsMs.prepareMs.median) + / baselineCase.timingStatsMs.prepareMs.median; + } + ratios.push(result); + } + return ratios; +} + +function evaluatePerformanceGates(ratios, baselineProvided) { + const at16 = ratios.find(({ side }) => side === 16); + const large = ratios.filter(({ side }) => side >= 8); + const parityValues = large.filter((value) => + typeof value.autoVsManualPrepareDeltaPercent === "number"); + const plannerValues = large.filter((value) => + typeof value.autoPlannerColdPercent === "number"); + const regressionValues = ratios.filter((value) => + typeof value.explicitVsBaselinePrepareDeltaPercent === "number"); + const gates = { + manualPrepare8xAt16: at16?.manualPrepareSpeedup === undefined + ? { status: "not-evaluated" } + : { status: at16.manualPrepareSpeedup >= 8 ? "pass" : "miss", value: at16.manualPrepareSpeedup }, + autoManualPrepareParity5Percent: parityValues.length === 0 + ? { status: "not-evaluated" } + : { + status: parityValues.every((value) => Math.abs(value.autoVsManualPrepareDeltaPercent) <= 5) + ? "pass" : "miss", + values: parityValues.map(({ side, autoVsManualPrepareDeltaPercent }) => + ({ side, value: autoVsManualPrepareDeltaPercent })), + }, + autoPlannerBelow5Percent: plannerValues.length === 0 + ? { status: "not-evaluated" } + : { + status: plannerValues.every((value) => value.autoPlannerColdPercent < 5) ? "pass" : "miss", + values: plannerValues.map(({ side, autoPlannerColdPercent }) => + ({ side, value: autoPlannerColdPercent })), + }, + explicitRegression5Percent: !baselineProvided + ? { status: "not-evaluated", reason: "no --baseline-summary" } + : { + status: regressionValues.length === ratios.length + && regressionValues.every((value) => value.explicitVsBaselinePrepareDeltaPercent <= 5) + ? "pass" : "miss", + values: regressionValues.map(({ side, explicitVsBaselinePrepareDeltaPercent }) => ({ + side, + value: explicitVsBaselinePrepareDeltaPercent, + })), + }, + }; + return { + ...gates, + hasMiss: Object.values(gates).some((gate) => gate.status === "miss"), }; } +function loadBaseline(path, options) { + if (path === undefined) return undefined; + const value = JSON.parse(readFileSync(path, "utf8")); + if (value.schemaVersion !== SCHEMA_VERSION || value.metadata?.protocol?.id !== PROTOCOL_ID) { + throw new Error("baseline summary uses an incompatible schema or protocol"); + } + const baselineOptions = value.metadata.options; + if (baselineOptions.segments !== options.segments + || baselineOptions.frequencyMHz !== options.frequencyMHz + || JSON.stringify(baselineOptions.sides) !== JSON.stringify(options.sides)) { + throw new Error("baseline summary geometry options do not match this run"); + } + return value; +} + +function validateBaselineEnvironment(baseline, metadata) { + if (baseline === undefined) return; + for (const key of [ + "nodeVersion", + "operatingSystem", + "architecture", + "cpuModel", + "emscriptenVersion", + "wasmStackSizeBytes", + ]) { + if (baseline.metadata[key] !== metadata[key]) { + throw new Error(`baseline ${key} does not match this run`); + } + } +} + function summaryPath(output) { return output.endsWith(".ndjson") ? `${output.slice(0, -".ndjson".length)}.summary.json` @@ -281,7 +516,12 @@ function summaryPath(output) { } function main() { + if (process.argv.slice(2).includes("--help")) { + process.stdout.write(HELP); + return; + } const options = parseArguments(process.argv.slice(2)); + const baseline = loadBaseline(options.baselineSummary, options); if (options.output !== undefined) { if (existsSync(options.output) && !options.overwrite) { throw new Error(`${options.output} already exists; pass --overwrite to replace it`); @@ -292,16 +532,15 @@ function main() { const emit = (record) => { const line = `${JSON.stringify(record)}\n`; process.stdout.write(line); - if (options.output !== undefined) { - appendFileSync(options.output, line); - } + if (options.output !== undefined) appendFileSync(options.output, line); }; const metadata = buildMetadata(options); + validateBaselineEnvironment(baseline, metadata); emit(metadata); const records = []; const comparisons = []; - const pairs = new Map(); + const correctnessPayloads = new Map(); let hasFailure = false; for (const side of options.sides) { for (let round = 1; round <= options.rounds; round += 1) { @@ -322,70 +561,79 @@ function main() { "--segments", String(options.segments), "--frequency-mhz", String(options.frequencyMHz), "--retained-solves", String(options.retainedSolves), + "--extract-matrix", String(options.zMatrixSides.includes(side)), + "--module-directory", options.moduleDirectory, "--round", String(round), ], { cwd: packageDirectory, encoding: "utf8", timeout: options.timeoutMs, - maxBuffer: 16 * 1024 * 1024, + maxBuffer: 64 * 1024 * 1024, windowsHide: true, }); const record = parseChildResult(child, identity); - const portCurrents = record.portCurrents; - delete record.portCurrents; + const correctness = record.correctness; + delete record.correctness; records.push(record); emit(record); if (!record.ok) { hasFailure = true; - if (options.failFast) { - process.exitCode = 1; - return; - } + if (options.failFast) break; continue; } - const pairKey = `${side}:${round}`; - const pair = pairs.get(pairKey) ?? {}; - pair[backend] = portCurrents; - pairs.set(pairKey, pair); - if (pair.stateful !== undefined && pair.deck !== undefined) { + if (correctness !== undefined) { + correctnessPayloads.set(`${side}:${round}:${backend}`, correctness); + } + if (backend.endsWith("reflection")) { + const explicit = correctnessPayloads.get(`${side}:${round}:explicit`); const comparison = { type: "comparison", + schemaVersion: SCHEMA_VERSION, + backend, side, round, equations: identity.equations, - ...compareCurrents(pair.stateful, pair.deck), + ...(explicit === undefined + ? { withinTolerance: false, error: "explicit correctness payload is unavailable" } + : comparisonRecord(correctness, explicit, options.equivalenceTolerance)), }; - comparison.withinTolerance = comparison.relativeL2Error - <= options.equivalenceTolerance; if (!comparison.withinTolerance) { + comparison.failureCategory = "correctness"; hasFailure = true; } comparisons.push(comparison); emit(comparison); + if (options.failFast && !comparison.withinTolerance) break; } } + if (options.failFast && hasFailure) break; } + if (options.failFast && hasFailure) break; } + const cases = options.sides.flatMap((side) => options.backends.map((backend) => + caseSummary(records, backend, side, options.segments))); + const ratios = performanceRatios(cases, comparisons, options, baseline); + const performanceGates = evaluatePerformanceGates(ratios, baseline !== undefined); const summary = { type: "summary", - schemaVersion: 1, + schemaVersion: SCHEMA_VERSION, completedAt: new Date().toISOString(), hasFailure, - cases: options.sides.flatMap((side) => options.backends.map((backend) => - caseSummary(records, backend, side))), + numericalChecksPassed: !hasFailure, + cases, comparisons, + performanceRatios: hasFailure ? [] : ratios, + performanceGates: hasFailure + ? { status: "suppressed", reason: "case or numerical correctness failure" } + : performanceGates, + baselineArtifact: baseline?.metadata?.artifact, }; emit(summary); if (options.output !== undefined) { - writeFileSync(summaryPath(options.output), `${JSON.stringify({ - metadata, - ...summary, - }, null, 2)}\n`); - } - if (hasFailure) { - process.exitCode = 1; + writeFileSync(summaryPath(options.output), `${JSON.stringify({ metadata, ...summary }, null, 2)}\n`); } + if (hasFailure) process.exitCode = 1; } main(); diff --git a/packages/necpp-wasm/bench/array-case.mjs b/packages/necpp-wasm/bench/array-case.mjs index 594b88c1..74ac7d05 100644 --- a/packages/necpp-wasm/bench/array-case.mjs +++ b/packages/necpp-wasm/bench/array-case.mjs @@ -1,8 +1,21 @@ import { performance } from "node:perf_hooks"; +import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { createReferenceArrayFixture } from "../test/fixtures/reference-array.mjs"; +export const REPRESENTATIONS = Object.freeze([ + "explicit", + "manual-reflection", + "auto-reflection", +]); + +export const FIELD_REQUEST = Object.freeze({ + radiusM: 100, + theta: Object.freeze({ startDeg: 10, count: 9, stepDeg: 10 }), + phi: Object.freeze({ startDeg: 0, count: 12, stepDeg: 30 }), +}); + function requireInteger(value, name, minimum = 1) { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) { @@ -30,8 +43,8 @@ function parseArguments(argv) { values.set(name.slice(2), value); } const backend = values.get("backend"); - if (backend !== "stateful" && backend !== "deck") { - throw new Error("--backend must be stateful or deck"); + if (!REPRESENTATIONS.includes(backend) && backend !== "deck") { + throw new Error(`--backend must be ${[...REPRESENTATIONS, "deck"].join(", ")}`); } const segments = requireInteger(values.get("segments"), "segments"); if (segments % 2 === 0) { @@ -41,43 +54,47 @@ function parseArguments(argv) { backend, side: requireInteger(values.get("side"), "side"), segments, - frequencyMHz: requirePositiveNumber( - values.get("frequency-mhz"), - "frequency-mhz", - ), - retainedSolves: requireInteger( - values.get("retained-solves"), - "retained-solves", - 0, - ), + frequencyMHz: requirePositiveNumber(values.get("frequency-mhz"), "frequency-mhz"), + retainedSolves: requireInteger(values.get("retained-solves"), "retained-solves", 0), round: requireInteger(values.get("round"), "round"), + extractMatrix: values.get("extract-matrix") === "true", + moduleDirectory: resolve(values.get("module-directory")), }; } export function createArrayDefinition({ side, segments, frequencyMHz }) { - return createReferenceArrayFixture({ side, segments, frequencyMHz }); + const fixture = createReferenceArrayFixture({ side, segments, frequencyMHz }); + return { + ...fixture, + description: { + elements: fixture.wires.map((wire, index) => ({ + id: `element-${index}`, + positionM: [wire.start[0], wire.start[1]], + patternId: "reference-dipole", + })), + patterns: [{ + id: "reference-dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: fixture.segments, + startM: [0, 0, fixture.lowerZM], + endM: [0, 0, fixture.upperZM], + radiusM: fixture.radiusM, + }], + ports: [{ wireId: "radiator", segment: fixture.feedSegment, name: "feed" }], + }], + ground: fixture.ground, + }, + }; } export function buildEquivalentDeck(definition) { - const lines = [ - `CM WASM ARRAY BENCHMARK ${definition.side} X ${definition.side}`, - "CE", - ]; + const lines = [`CM WASM ARRAY BENCHMARK ${definition.side} X ${definition.side}`, "CE"]; for (const wire of definition.wires) { - lines.push([ - "GW", - wire.tag, - wire.segments, - ...wire.start, - ...wire.end, - wire.radiusM, - ].join(" ")); + lines.push(["GW", wire.tag, wire.segments, ...wire.start, ...wire.end, wire.radiusM].join(" ")); } - lines.push( - "GE 0", - `FR 0 1 0 0 ${definition.frequencyMHz} 0`, - "GN 1", - ); + lines.push("GE 0", `FR 0 1 0 0 ${definition.frequencyMHz} 0`, "GN 1"); for (const port of definition.ports) { lines.push(`EX 0 ${port.tag} ${port.segment} 0 1 0`); } @@ -87,8 +104,7 @@ export function buildEquivalentDeck(definition) { export function parseDeckSourceCurrents(report, expectedCount) { const lines = report.split(/\r?\n/); - const start = lines.findIndex((line) => - line.includes("----- ANTENNA INPUT PARAMETERS -----")); + const start = lines.findIndex((line) => line.includes("----- ANTENNA INPUT PARAMETERS -----")); const end = lines.findIndex((line, index) => index > start && line.includes("----- CURRENTS AND LOCATION -----")); if (start < 0 || end < 0) { @@ -97,11 +113,7 @@ export function parseDeckSourceCurrents(report, expectedCount) { const currents = []; for (const line of lines.slice(start + 1, end)) { const fields = line.trim().split(/\s+/); - if ( - fields.length >= 11 - && /^\d+$/.test(fields[0]) - && /^\d+$/.test(fields[1]) - ) { + if (fields.length >= 11 && /^\d+$/.test(fields[0]) && /^\d+$/.test(fields[1])) { const real = Number(fields[4]); const imag = Number(fields[5]); if (Number.isFinite(real) && Number.isFinite(imag)) { @@ -110,17 +122,13 @@ export function parseDeckSourceCurrents(report, expectedCount) { } } if (currents.length !== expectedCount) { - throw new Error( - `legacy report has ${currents.length} source currents; expected ${expectedCount}`, - ); + throw new Error(`legacy report has ${currents.length} source currents; expected ${expectedCount}`); } return currents; } function median(values) { - if (values.length === 0) { - return null; - } + if (values.length === 0) return null; const sorted = [...values].sort((left, right) => left - right); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 @@ -128,78 +136,309 @@ function median(values) { : sorted[middle]; } -function currentChecksum(currents) { +function serializeComplex(value) { + return { real: Array.from(value.real), imag: Array.from(value.imag) }; +} + +function complexChecksum(value) { let sumReal = 0; let sumImag = 0; let normSquared = 0; - for (const current of currents) { - sumReal += current.real; - sumImag += current.imag; - normSquared += current.real ** 2 + current.imag ** 2; + for (let index = 0; index < value.real.length; index += 1) { + sumReal += value.real[index]; + sumImag += value.imag[index]; + normSquared += value.real[index] ** 2 + value.imag[index] ** 2; } return { sumReal, sumImag, l2Norm: Math.sqrt(normSquared) }; } -async function runStateful(api, definition, retainedSolves) { +function steeringCurrents(definition, solveIndex = 0) { + const theta = (31 + solveIndex * 3) * Math.PI / 180; + const phi = (47 + solveIndex * 7) * Math.PI / 180; + const ux = Math.sin(theta) * Math.cos(phi); + const uy = Math.sin(theta) * Math.sin(phi); + const waveNumber = 2 * Math.PI / definition.wavelengthM; + return { + real: Float64Array.from(definition.wires, (wire, index) => { + const phase = -waveNumber * (ux * wire.start[0] + uy * wire.start[1]); + const amplitude = 0.8 + 0.2 * Math.cos((index + solveIndex) * 0.23); + return amplitude * Math.cos(phase); + }), + imag: Float64Array.from(definition.wires, (wire, index) => { + const phase = -waveNumber * (ux * wire.start[0] + uy * wire.start[1]); + const amplitude = 0.8 + 0.2 * Math.cos((index + solveIndex) * 0.23); + return amplitude * Math.sin(phase); + }), + }; +} + +function scatterFromPlan(plan, portCount) { + const scatter = new Array(portCount); + for (const mapping of plan.mappings) { + if (mapping.callerPortIndices.length !== 1 || mapping.generatedPortIndices.length !== 1) { + throw new Error("reference benchmark expects one port per element"); + } + scatter[mapping.callerPortIndices[0]] = mapping.generatedPortIndices[0]; + } + if (scatter.some((value) => !Number.isSafeInteger(value))) { + throw new Error("automatic symmetry plan did not map every caller port"); + } + return scatter; +} + +function scatterComplexVector(value, scatter) { + const real = new Float64Array(scatter.length); + const imag = new Float64Array(scatter.length); + for (let caller = 0; caller < scatter.length; caller += 1) { + real[scatter[caller]] = value.real[caller]; + imag[scatter[caller]] = value.imag[caller]; + } + return { real, imag }; +} + +function gatherComplexVector(value, scatter) { + return { + real: Float64Array.from(scatter, (native) => value.real[native]), + imag: Float64Array.from(scatter, (native) => value.imag[native]), + }; +} + +function gatherComplexMatrix(value, scatter) { + const order = scatter.length; + const real = new Float64Array(order * order); + const imag = new Float64Array(order * order); + for (let row = 0; row < order; row += 1) { + for (let column = 0; column < order; column += 1) { + const source = scatter[row] * order + scatter[column]; + const target = row * order + column; + real[target] = value.real[source]; + imag[target] = value.imag[source]; + } + } + return { rows: order, columns: order, real, imag }; +} + +function representationPlan(api, definition, backend) { + if (backend === "explicit") { + return { + wires: definition.wires, + symmetry: undefined, + scatterCallerToNative: Array.from({ length: definition.ports.length }, (_, index) => index), + analysisMs: 0, + diagnostics: { representation: "explicit", exact: true }, + }; + } + if (definition.reflection === undefined) { + throw new Error(`${backend} requires an even-sided reference array`); + } + if (backend === "manual-reflection") { + return { + wires: definition.reflection.fundamentalWires, + symmetry: definition.reflection.symmetry, + scatterCallerToNative: definition.reflection.scatterCallerToGenerated, + analysisMs: 0, + diagnostics: { + representation: "symmetric", + exact: true, + sectionCount: definition.reflection.sectionCount, + }, + }; + } + + const analysisStart = performance.now(); + const plan = api.analyzeArraySymmetry(definition.description, { + positionEpsilonM: 0, + allowReflection: true, + allowRotation: false, + }); + const analysisMs = performance.now() - analysisStart; + if (plan.kind !== "symmetric" || plan.symmetry.kind !== "reflection" + || plan.expansion.sectionCount !== 4) { + throw new Error("automatic planner did not select four-section XY reflection"); + } + return { + wires: plan.fundamentalElements.map((element, index) => ({ + tag: index + 1, + segments: definition.segments, + start: [element.positionM[0], element.positionM[1], definition.lowerZM], + end: [element.positionM[0], element.positionM[1], definition.upperZM], + radiusM: definition.radiusM, + })), + symmetry: plan.symmetry, + scatterCallerToNative: scatterFromPlan(plan, definition.ports.length), + analysisMs, + diagnostics: { + representation: plan.diagnostics.representation, + exact: plan.diagnostics.exact, + sectionCount: plan.expansion.sectionCount, + maxPositionAdjustmentM: plan.maxPositionAdjustmentM, + }, + }; +} + +function gatherSolution(solution, scatter) { + const powersW = new Float64Array(scatter.length); + for (let caller = 0; caller < scatter.length; caller += 1) { + powersW[caller] = solution.powersW[scatter[caller]]; + } + return { + requested: gatherComplexVector(solution.requested, scatter), + voltages: gatherComplexVector(solution.voltages, scatter), + currents: gatherComplexVector(solution.currents, scatter), + activeImpedances: gatherComplexVector(solution.activeImpedances, scatter), + powersW, + }; +} + +function solutionPayload(solution) { + return { + requested: serializeComplex(solution.requested), + voltages: serializeComplex(solution.voltages), + currents: serializeComplex(solution.currents), + activeImpedances: serializeComplex(solution.activeImpedances), + powersW: Array.from(solution.powersW), + }; +} + +function farFieldPayload(field) { + return { + thetaDeg: Array.from(field.thetaDeg), + phiDeg: Array.from(field.phiDeg), + eTheta: { real: Array.from(field.eThetaReal), imag: Array.from(field.eThetaImag) }, + ePhi: { real: Array.from(field.ePhiReal), imag: Array.from(field.ePhiImag) }, + }; +} + +function matrixPayload(result, scatter) { + const impedance = gatherComplexMatrix(result.impedance, scatter); + const admittance = gatherComplexMatrix(result.admittance, scatter); + return { + order: impedance.rows, + impedance: serializeComplex(impedance), + admittance: serializeComplex(admittance), + }; +} + +export function primaryInteractionMatrixBytes(fullEquations, sectionCount = 1) { + if (!Number.isSafeInteger(fullEquations) || fullEquations < 1 + || !Number.isSafeInteger(sectionCount) || sectionCount < 1 + || fullEquations % sectionCount !== 0) { + throw new Error("matrix dimensions must be positive compatible integers"); + } + // nec_context::stateful_prepare_frequency allocates n * np complex + // entries for wire-only symmetry, where np = n / sectionCount. + return fullEquations * (fullEquations / sectionCount) * 16; +} + +async function runRepresentation(api, definition, backend, retainedSolves, extractMatrix) { const totalStart = performance.now(); + const plan = representationPlan(api, definition, backend); + const memorySamples = [process.memoryUsage().rss]; const instantiateStart = performance.now(); const model = await api.createNecModel(); const instantiateMs = performance.now() - instantiateStart; + memorySamples.push(process.memoryUsage().rss); try { - const geometryStart = performance.now(); - for (const wire of definition.wires) { - model.addWire(wire); - } - model.completeGeometry(); + const constructionStart = performance.now(); + for (const wire of plan.wires) model.addWire(wire); + const geometryConstructionMs = performance.now() - constructionStart; + memorySamples.push(process.memoryUsage().rss); + + const completionStart = performance.now(); + const completion = model.completeGeometry({ + groundConnection: definition.groundConnection, + ...(plan.symmetry === undefined ? {} : { symmetry: plan.symmetry }), + }); + const geometryCompletionMs = performance.now() - completionStart; + memorySamples.push(process.memoryUsage().rss); + + const portEnvironmentStart = performance.now(); model.definePorts(definition.ports); model.setGround(definition.ground); - const geometryMs = performance.now() - geometryStart; + const portEnvironmentMs = performance.now() - portEnvironmentStart; const prepareStart = performance.now(); model.prepare({ frequencyMHz: definition.frequencyMHz }); const prepareMs = performance.now() - prepareStart; + memorySamples.push(process.memoryUsage().rss); - const drive = { - real: new Float64Array(definition.ports.length).fill(1), - imag: new Float64Array(definition.ports.length), - }; + const callerCurrents = steeringCurrents(definition); + const nativeCurrents = scatterComplexVector(callerCurrents, plan.scatterCallerToNative); const solveStart = performance.now(); - const solution = model.solveVoltages(drive); + const nativeSolution = model.solveCurrents(nativeCurrents); const firstSolveMs = performance.now() - solveStart; + const solution = gatherSolution(nativeSolution, plan.scatterCallerToNative); + memorySamples.push(process.memoryUsage().rss); + + const fieldStart = performance.now(); + const field = model.computeFarField(FIELD_REQUEST); + const combinedFarFieldMs = performance.now() - fieldStart; + memorySamples.push(process.memoryUsage().rss); + + let matrices; + let impedanceMatrixMs = null; + if (extractMatrix) { + const matrixStart = performance.now(); + matrices = matrixPayload(model.computeImpedanceMatrix(), plan.scatterCallerToNative); + impedanceMatrixMs = performance.now() - matrixStart; + memorySamples.push(process.memoryUsage().rss); + } const coldTotalMs = performance.now() - totalStart; + const retainedSolveTimesMs = []; for (let index = 0; index < retainedSolves; index += 1) { + const retainedCaller = steeringCurrents(definition, index + 1); + const retainedNative = scatterComplexVector(retainedCaller, plan.scatterCallerToNative); const retainedStart = performance.now(); - model.solveVoltages(drive); + model.solveCurrents(retainedNative); retainedSolveTimesMs.push(performance.now() - retainedStart); } - const currents = Array.from(solution.currents.real, (real, index) => ({ - real, - imag: solution.currents.imag[index], - })); - if (!currents.every(({ real, imag }) => - Number.isFinite(real) && Number.isFinite(imag))) { - throw new Error("stateful solve returned a non-finite source current"); - } + memorySamples.push(process.memoryUsage().rss); + + const sectionCount = completion?.symmetry?.sectionCount ?? 1; + const peakObservedRssBytes = Math.max(...memorySamples); return { engineVersion: api.engineVersion, timings: { + analysisMs: plan.analysisMs, instantiateMs, - geometryMs, + geometryConstructionMs, + geometryCompletionMs, + portEnvironmentMs, prepareMs, firstSolveMs, + combinedFarFieldMs, + impedanceMatrixMs, coldTotalMs, retainedSolveCount: retainedSolves, retainedSolveMedianMs: median(retainedSolveTimesMs), - retainedSolveMinMs: retainedSolveTimesMs.length === 0 - ? null - : Math.min(...retainedSolveTimesMs), - retainedSolveMaxMs: retainedSolveTimesMs.length === 0 - ? null - : Math.max(...retainedSolveTimesMs), + retainedSolveMinMs: retainedSolveTimesMs.length === 0 ? null : Math.min(...retainedSolveTimesMs), + retainedSolveMaxMs: retainedSolveTimesMs.length === 0 ? null : Math.max(...retainedSolveTimesMs), totalWithRetainedSolvesMs: performance.now() - totalStart, }, - currents, + representation: { + ...plan.diagnostics, + sectionCount, + fundamentalSegmentCount: completion?.symmetry?.fundamentalSegmentCount ?? definition.equations, + fullSegmentCount: completion?.symmetry?.fullSegmentCount ?? definition.equations, + }, + memory: { + rssBeforeBytes: memorySamples[0], + peakObservedRssBytes, + peakObservedRssDeltaBytes: peakObservedRssBytes - memorySamples[0], + primaryInteractionMatrixBytes: primaryInteractionMatrixBytes(definition.equations, sectionCount), + explicitPrimaryInteractionMatrixBytes: primaryInteractionMatrixBytes(definition.equations), + }, + checksums: { + currents: complexChecksum(solution.currents), + eTheta: complexChecksum({ real: field.eThetaReal, imag: field.eThetaImag }), + ePhi: complexChecksum({ real: field.ePhiReal, imag: field.ePhiImag }), + }, + correctness: { + solution: solutionPayload(solution), + field: farFieldPayload(field), + ...(matrices === undefined ? {} : { matrices }), + }, }; } finally { model.dispose(); @@ -214,54 +453,61 @@ async function runDeck(api, definition) { const runStart = performance.now(); const result = await api.runDeck(deck); const runDeckMs = performance.now() - runStart; - const currents = parseDeckSourceCurrents( - result.report, - definition.ports.length, - ); + const currents = parseDeckSourceCurrents(result.report, definition.ports.length); return { engineVersion: result.engineVersion, - timings: { - deckBuildMs, - runDeckMs, - coldTotalMs: performance.now() - totalStart, - }, + timings: { deckBuildMs, runDeckMs, coldTotalMs: performance.now() - totalStart }, reportBytes: Buffer.byteLength(result.report), reportLines: result.report.split(/\r?\n/).length, deckBytes: Buffer.byteLength(deck), - currents, + checksums: { + sourceCurrents: complexChecksum({ + real: Float64Array.from(currents, ({ real }) => real), + imag: Float64Array.from(currents, ({ imag }) => imag), + }), + }, }; } +function classifyError(error) { + if (error?.code === "NEC_CONDITIONING") return "numerical-conditioning"; + if (/alloc|memory|out of bounds/i.test(error?.message ?? "")) return "allocation"; + if (/wasm|trap|unreachable/i.test(error?.message ?? "")) return "wasm-trap"; + return "runtime"; +} + function serializeError(error) { return { + category: classifyError(error), name: error?.name ?? "Error", message: error?.message ?? String(error), code: error?.code, stack: error?.stack, cause: error?.cause === undefined ? undefined - : { - name: error.cause?.name, - message: error.cause?.message ?? String(error.cause), - }, + : { name: error.cause?.name, message: error.cause?.message ?? String(error.cause) }, }; } async function main() { const options = parseArguments(process.argv.slice(2)); - const api = await import("../dist/index.js"); - const rssBeforeBytes = process.memoryUsage().rss; + const api = await import(pathToFileURL(resolve(options.moduleDirectory, "index.js")).href); const definitionStart = performance.now(); const definition = createArrayDefinition(options); const definitionMs = performance.now() - definitionStart; try { - const backendResult = options.backend === "stateful" - ? await runStateful(api, definition, options.retainedSolves) - : await runDeck(api, definition); - const rssAfterBytes = process.memoryUsage().rss; - const currents = backendResult.currents; + const backendResult = options.backend === "deck" + ? await runDeck(api, definition) + : await runRepresentation( + api, + definition, + options.backend, + options.retainedSolves, + options.extractMatrix, + ); process.stdout.write(`${JSON.stringify({ type: "case", + schemaVersion: 2, ok: true, backend: options.backend, side: options.side, @@ -271,18 +517,13 @@ async function main() { ports: definition.ports.length, frequencyMHz: definition.frequencyMHz, definitionMs, - rssBeforeBytes, - rssAfterBytes, - rssDeltaBytes: rssAfterBytes - rssBeforeBytes, - currentChecksum: currentChecksum(currents), - firstCurrent: currents[0], - portCurrents: currents, + matrixExtracted: options.extractMatrix, ...backendResult, - currents: undefined, })}\n`); } catch (error) { process.stdout.write(`${JSON.stringify({ type: "case", + schemaVersion: 2, ok: false, backend: options.backend, side: options.side, @@ -292,15 +533,12 @@ async function main() { ports: definition.ports.length, frequencyMHz: definition.frequencyMHz, definitionMs, + matrixExtracted: options.extractMatrix, error: serializeError(error), })}\n`); process.exitCode = 1; } } -const entryPoint = process.argv[1] === undefined - ? undefined - : pathToFileURL(process.argv[1]).href; -if (entryPoint === import.meta.url) { - await main(); -} +const entryPoint = process.argv[1] === undefined ? undefined : pathToFileURL(process.argv[1]).href; +if (entryPoint === import.meta.url) await main(); diff --git a/packages/necpp-wasm/test/array-benchmark.test.mjs b/packages/necpp-wasm/test/array-benchmark.test.mjs index 9009adc6..fce91416 100644 --- a/packages/necpp-wasm/test/array-benchmark.test.mjs +++ b/packages/necpp-wasm/test/array-benchmark.test.mjs @@ -5,6 +5,7 @@ import { buildEquivalentDeck, createArrayDefinition, parseDeckSourceCurrents, + primaryInteractionMatrixBytes, } from "../bench/array-case.mjs"; test("array benchmark emits equivalent NEC geometry and excitation cards", () => { @@ -16,6 +17,10 @@ test("array benchmark emits equivalent NEC geometry and excitation cards", () => const deck = buildEquivalentDeck(definition); assert.equal(definition.equations, 44); assert.equal(definition.ports.length, 4); + assert.equal(definition.description.elements.length, 4); + assert.equal(definition.description.patterns[0].wires[0].startM[2], definition.lowerZM); + assert.deepEqual(definition.description.ground, { kind: "perfect" }); + assert.equal(definition.upperZM - definition.lowerZM, definition.wavelengthM / 3); assert.equal(deck.match(/^GW /gm)?.length, 4); assert.equal(deck.match(/^EX 0 /gm)?.length, 4); assert.match(deck, /^GE 0$/m); @@ -25,6 +30,16 @@ test("array benchmark emits equivalent NEC geometry and excitation cards", () => assert.match(deck, /^EN$/m); }); +test("symmetry benchmark reports the native wire-only matrix allocation", () => { + assert.equal(primaryInteractionMatrixBytes(44), 44 * 44 * 16); + assert.equal(primaryInteractionMatrixBytes(44, 4), 44 * 11 * 16); + assert.equal( + primaryInteractionMatrixBytes(44) / primaryInteractionMatrixBytes(44, 4), + 4, + ); + assert.throws(() => primaryInteractionMatrixBytes(44, 3), /compatible integers/); +}); + test("array benchmark parses legacy source currents", () => { const report = ` ----- ANTENNA INPUT PARAMETERS ----- From a1e9663bc0d3e8b912f3567e4abe122b8d373659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 21:53:13 +0200 Subject: [PATCH 44/46] docs: complete symmetry release hardening --- CHANGELOG.md | 24 ++ README.md | 6 +- docs/01_symmetry_support.md | 51 +++- docs/wasm-api.md | 199 +++++++++++++++- examples/wasm-array-vite/README.md | 12 +- examples/wasm-array-vite/src/main.ts | 69 ++++-- examples/wasm-symmetry/README.md | 16 ++ examples/wasm-symmetry/manual-direct.mjs | 41 ++++ examples/wasm-symmetry/manual-worker.mjs | 41 ++++ packages/necpp-wasm/README.md | 224 +++++++++++++++++- packages/necpp-wasm/package.json | 4 +- .../necpp-wasm/test/browser-integration.mjs | 3 + .../necpp-wasm/test/pack/consumer.test.mjs | 44 +++- 13 files changed, 695 insertions(+), 39 deletions(-) create mode 100644 examples/wasm-symmetry/README.md create mode 100644 examples/wasm-symmetry/manual-direct.mjs create mode 100644 examples/wasm-symmetry/manual-worker.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9226cfe9..332cc029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased + +### Added + +* **Stateful geometry symmetry across native, WASM, direct, and worker APIs:** + coordinate-plane reflection and N-fold global-Z rotation preserve immutable + section/copy metadata through the additive version-1 ABI. +* **Transparent full-array symmetry planning:** `createNecArraySolver()` accepts + one complete positioned-element description, conservatively selects an exact + reflection or rotation when eligible, and otherwise builds the unchanged + explicit model with stable diagnostics. Z/Y matrices, requested and achieved + port quantities, combined fields, and embedded bases stay in caller order in + all representations; arbitrary off-broadside excitation weights are covered. +* **Documented first-release safety boundary:** automatic symmetry accepts only + pointwise invariant straight Z-wire patterns. Helices, tilted/off-axis wires, + rotated patterns, arcs, patches, fixed elements, asymmetric loads, and + incompatible ground fall back or raise a controlled policy error. Epsilon + acceptance reports every coordinate canonicalization and the maximum change. +* **Symmetry equivalence and performance gates:** native/ABI/package/worker/ + browser tests compare complete complex matrices, port solutions, and fields. + The process-isolated 16 x 16 reference run on an AMD Ryzen 7 PRO 7840HS + measured 11.55x manual-reflection preparation speedup and a fourfold primary + matrix-allocation reduction; these are host- and model-specific measurements. + ## 0.1.1 - 2026-08-29 ### Added diff --git a/README.md b/README.md index 1c1016a0..82007e40 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,12 @@ A guide to [using nec2++ from python](http://astroelec.blogspot.co.nz/2015/05/mo For Node and browser applications, the versioned `@necpp-engine/wasm` npm package provides a high-level TypeScript API with multi-port matrices, complex -far fields, and an optional Web Worker facade. See the +far fields, an optional Web Worker facade, explicit NEC reflection/rotation, +and a transparent full-array solver that conservatively selects symmetry or +falls back to the unchanged explicit model. Ordinary Z/Y, solve, and field +results stay in caller port order. See the [package guide](packages/necpp-wasm/README.md) and the +[detailed WASM API and symmetry contract](docs/wasm-api.md), plus the [four-element Vite example](examples/wasm-array-vite/README.md). ## Installation diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index 54a9c32f..be91b4dc 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1238,7 +1238,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S5 Transparent symmetrizer | complete | Codex | npm/WASM: 60/60; pack: 5/5; browser: 3/3; focused WP-S5: 14/14 | WP-S6 can consume the public facade without plan-kind branches; caller-order tags, ports, vectors, matrices, and field bases are representation-independent. | | WP-S6 End-to-end equivalence suite | complete | Codex | Focused WP-S6: 8/8; npm/WASM: 68/68 + typecheck; native `[wp_s2]` 10,599 and `[wp_s3]` 8 assertions | WP-S7 should reuse the reference fixture, caller-order checks, complex metrics, and ordinary 8 x 8 R3 gate before reporting performance. | | WP-S7 Benchmarks and performance gates | complete | Codex | 45/45 isolated current cases; 30/30 binary64 comparisons; 16 x 16 prepare 11.55x; matrix 4.00x | WP-S8 may claim only the measured matrix-scale results; the explicit 2 x 2 pre-feature regression gate remains a documented miss. | -| WP-S8 Public documentation, examples, and release hardening | not started | — | — | — | +| WP-S8 Public documentation, examples, and release hardening | complete | Codex | npm/WASM 69/69 + typecheck; pack 5/5; browser 3/3; native Release partitions pass | WP-S9 should only assign versions/date, rebuild identities, rerun this checklist, and inspect the final tarball. | | WP-S9 Final version bump and release identity | not started | — | — | — | Allowed states are `not started`, `in progress`, `blocked`, and `complete`. @@ -1980,6 +1980,55 @@ DoD: - the global DoD below is checked and the status table contains complete evidence for every WP. +Completion evidence (2026-08-30, Windows, Node 24.14.1): + +- Expanded `docs/wasm-api.md` with the complete transparent-facade contract, + lifecycle/ownership, mode and retry policy, caller/native mappings, exact and + epsilon matching, canonicalization disclosure, translation phase equation, + structured reason catalogue, support matrix, tolerances, worker parity, and + the deferred helix orientation/polarity requirements. +- The npm-rendered README now independently documents full NxN input, + `"auto"`/`"off"`/`"require"`, accepted/fallback diagnostics, arbitrary + excitations, ground/loads, copy/tag order, unsupported patterns, and the + contextual WP-S7 measurements. The root README links the detailed contract; + the unreleased changelog entry is drafted without preempting WP-S9 versions. +- Converted the downstream Vite beam-steering app to the complete-description + facade. It displays accepted/fallback reason codes and maximum coordinate + adjustment while its prepare, Z, current solve, and field flow remains + representation-independent. Added standalone direct and worker manual + reflection examples; clean packed consumers execute both. +- `npm run test:wasm` passed 69/69 runtime tests, strict typechecking, five + packed-consumer tests, every TypeScript block extracted from the installed + npm README, both manual examples, the quick start, CDN loading, and a clean + Vite direct/worker bundle. The first sandboxed pack attempt failed with the + expected npm-cache `EPERM`; the identical command passed with npm-cache + access. `npm pack --dry-run --json` listed the README, symmetry declarations, + implementation, worker files, and WASM artifact with no source/debug or + benchmark output. +- One inspected candidate tarball, + `necpp-engine-wasm-0.1.1.tgz` (329,889 bytes, SHA-256 + `b94fa05457cd7201754631cd6de06a70a3ad961546722008c49d1f6a8d8037f6`), + passed Chromium direct, worker, and transparent-example modes. The example + returned four caller ports, 361 finite field samples, symmetric + representation, zero exact-coordinate adjustment, and no fallback reasons. +- The cached native Release build completed. CTest passed seven partitions; + its `[wp1]` aggregate alone exceeded the configured 180-second wrapper limit + because the 1,000-solve stress case writes extensive diagnostics. Direct + execution split that partition into `[wp1]~[stress]` (49 assertions in six + cases) and `[stress]` with output suppressed; both exited zero. No native + source changed in WP-S8. `git diff --check` passed. + +Contract decisions for WP-S9: + +- Preserve the README/API behavior and examples; WP-S9 changes only the + package/native identities, release date, pinned assertions/CDN examples, and + rebuilt artifacts required by its mechanical version checklist. +- Recreate rather than reuse the WP-S8 candidate tarball after the version + bump, then run all three browser modes against that one inspected final + artifact. ABI version and `necpp_wasm_v1_*` names remain `1`. +- Keep the explicit 2 x 2 pre-feature benchmark miss and all first-release + pattern prohibitions visible; neither is a version-finalization fix. + Handoff focus: WP-S9 is a deliberately mechanical finalization step. Do not bump a version in WP-S8 merely to make documentation examples appear final. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 846931b2..82f1a436 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,6 +1,6 @@ # `@necpp-engine/wasm` API and numerical contract -Status: normative specification, updated through WP9 on 2026-08-28. The +Status: normative specification, updated through symmetry WP-S8 on 2026-08-30. 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). @@ -20,7 +20,8 @@ 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 initial public TypeScript API release is `0.1.0`. +The current package identity is `0.1.1`; WP-S9 assigns the documented symmetry +release its final `0.2.0` identity after all release gates pass. The packed package exports three version identifiers that can be imported without constructing a model: @@ -197,6 +198,7 @@ enumerates every operation/state pair plus both `prepare()` branches. |---|---|---|---| | `createNecModel(options?)` | Optional `wasmUrl` or caller-owned WASM bytes | Promise of an `empty` model | `NecRuntimeError` for load/instantiate/version failure; `NecInputError` if both overrides are supplied | | `createNecWorkerModel(options?)` | Same loading overrides plus optional `onProgress` | Promise of an `empty` worker model | Same loading failures; `NecRuntimeError` if the worker cannot start or is terminated | +| `createNecArraySolver(description, options?)` | Complete positioned-element description plus `"auto"`, `"off"`, or `"require"` policy | Promise of one worker-backed, representation-independent array solver | Input/planner errors below; ordinary native failures retain their normal taxonomy | | `addWire(wire)` | Positive integer tag/count; distinct finite endpoints and positive finite radius, all in m | `void`; copies the definition | `NecInputError` for shape/range errors; `NecGeometryError` for engine geometry limits | | `completeGeometry(options?)` | Ground connection plus optional finalized reflection/rotation descriptor | `GeometryCompletionResult`; `symmetry` is absent for ordinary completion | `NecInputError` for an invalid descriptor; `NecGeometryError` for intersections, invalid junctions, symmetry/ground conflicts, or a ground-incompatible structure | | `definePorts(ports)` | Nonempty ordered tag and one-based segment pairs | `void`; copies and freezes order | `NecPortError` for missing/duplicate ports or non-source-capable segments; `NecInputError` for malformed integers | @@ -301,6 +303,184 @@ example. Direct and worker callers pass the same descriptor; only the worker's method call is awaited. Returned metadata is deeply frozen after direct construction or worker structured-clone revival. +## Transparent symmetric array solver + +`createNecArraySolver()` is the application-level boundary for callers that +already have the complete positioned array. Detection is a pure TypeScript +planning step; `NecModel`, native geometry, and matrix preparation never infer +symmetry from floating-point geometry. The factory accepts: + +```ts +interface FullArrayDescription { + readonly elements: readonly { + readonly id: string | number; + readonly positionM: readonly [xM: number, yM: number]; + readonly patternId: string; + readonly rotationDeg?: number; + }[]; + readonly patterns: readonly ElementWirePattern[]; + readonly ground: GroundModel; +} + +interface CreateArraySolverOptions { + readonly symmetry?: "auto" | "off" | "require"; + readonly symmetrizer?: { + readonly positionEpsilonM: number; + readonly center?: "auto" | readonly [xM: number, yM: number]; + readonly allowReflection?: boolean; + readonly allowRotation?: boolean; + readonly preferredRotationOrders?: readonly RotationalOrder[]; + readonly onUnsupported?: "explicit-fallback" | "error"; + }; +} +``` + +The mode defaults to `"auto"`, but automatic analysis deliberately has no +default epsilon: omitting `symmetrizer.positionEpsilonM` is `NEC_INPUT`. +`"off"` builds the exact full description and ignores planner options. +`"require"` analyzes normally and raises `NecGeometryError` with the planner +reasons if it cannot prove an eligible nontrivial symmetry. `"auto"` returns +the same facade after deterministic explicit fallback. It retries an already +selected symmetric build at most once, and only for the representation +eligibility refinements `INCOMPATIBLE_GROUND`, `INCOMPLETE_LOAD_ORBIT`, or +`UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM`. Allocation, cancellation, +conditioning, solver, and invalid full-geometry failures are never hidden. + +The returned `NecArraySolver` is worker-backed and asynchronous: + +```ts +interface NecArraySolver { + readonly state: NecModelState; + prepare(options: PrepareOptions): Promise; + computeImpedanceMatrix(): Promise; + solveVoltages(value: ComplexVector): Promise; + solveCurrents(value: ComplexVector): Promise; + computeFarField(request: FarFieldRequest): Promise; + computeEmbeddedFarFields( + request: FarFieldRequest, + normalization?: EmbeddedFieldNormalization, + ): Promise; + getDiagnostics(): ArraySolverDiagnostics; + dispose(): Promise; +} +``` + +Its lifecycle from creation is `geometry-complete -> prepared -> solved`; the +factory owns construction, port definition, structural-load expansion, ground +selection, and any eligible explicit retry. Disposal is deterministic and +idempotent. 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. + +### 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. + +An accepted reflection candidate canonicalizes centered positions with sign +transforms. An accepted rotational candidate uses + +```text +angle(copyIndex) = copyIndex * 2*pi/order, copyIndex = 0..order-1 +``` + +and the native rotation center remains global Z through `(0,0)`. The planner +may translate a homogeneous-ground/free-space description by an effective XY +center before construction. Translation does not change Z, Y, port quantities, +or powers. Because fields are referenced to the caller's original origin, the +facade restores every combined and embedded complex field sample with + +```text +E_caller(u) = E_centered(u) * exp(+j*k*u dot center) +u = (sin(theta) cos(phi), sin(theta) sin(phi), cos(theta)) +``` + +which is the required correction under the package's `e^(+j omega t)` and +outgoing `e^(-jkR)` convention. + +### Planner acceptance and diagnostics + +All element IDs must be unique and every element must reference a known +pattern. Candidate matching is one-to-one: every transformed position must +have exactly one same-pattern counterpart within the caller's epsilon, every +orbit must contain the full section count, and no element may be fixed on a +generating plane or rotation axis. Exact matching is the special +`positionEpsilonM: 0` case; it does not use rounded keys or an implicit +tolerance. Candidate selection is deterministic across caller permutations. + +For positive epsilon, an accepted orbit is replaced by exact group-related +coordinates. Every replacement is disclosed as a `PositionCanonicalization` +with caller index, original/canonical coordinates, XY adjustment vector, and +Euclidean distance. `maxPositionAdjustmentM` is the maximum of those distances +and `exact` is false whenever any nonzero adjustment occurs. Canonicalization +must remain one-to-one; a collision or ambiguous match falls back rather than +silently merging elements. + +`getDiagnostics()` returns: + +```ts +interface ArraySolverDiagnostics { + readonly representation: "explicit" | "symmetric"; + readonly planner: { + readonly representation: "explicit" | "symmetric"; + readonly exact: boolean; + readonly effectiveCenterM: readonly [number, number]; + readonly maxPositionAdjustmentM: number; + readonly canonicalizations: readonly PositionCanonicalization[]; + readonly candidates: readonly SymmetryCandidateDiagnostics[]; + readonly reasons: readonly SymmetrizationReason[]; + }; + readonly symmetry?: SymmetryExpansion; +} +``` + +The data is immutable and structured-cloneable. Candidate records include the +descriptor tested, acceptance flag, and reasons. Fallback reasons have stable +codes: + +| Code | Meaning | +|---|---| +| `NO_NONTRIVIAL_SYMMETRY` | No supported candidate produced more than one section | +| `FIXED_ELEMENT_ON_REFLECTION_PLANE` | A reflection would duplicate an element on its generating plane | +| `FIXED_ELEMENT_ON_ROTATION_AXIS` | A rotation would duplicate an element on global Z | +| `POSITION_OUTSIDE_EPSILON` | A required counterpart is farther away than the explicit tolerance | +| `AMBIGUOUS_POSITION_MATCH` | A transformed point has zero or multiple admissible one-to-one matches | +| `PATTERN_MISMATCH` | The geometric counterpart uses a different reusable element pattern | +| `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM` | Pattern geometry/orientation cannot enter the first-release symmetry path | +| `UNSYMMETRIC_LOAD` | Structural loads do not form equal complete orbits | +| `GROUND_BREAKS_SYMMETRY` | The radiating environment is not invariant under the candidate | +| `TAG_SPACE_EXHAUSTED` | Generated positive tags would exceed the native signed-32-bit range | + +### Supported pattern and environment matrix + +| Feature | Explicit model | Transparent reflection | Transparent rotation | +|---|---:|---:|---:| +| Straight Z wire(s), local X=Y=0 | Yes | Yes | Yes | +| Zero/omitted element rotation | Yes | Required | Required | +| Arbitrary current/voltage weights | Yes | Yes | Yes | +| Pattern-relative equal load orbit | Yes | Yes, expanded atomically | Yes, expanded atomically | +| Free space | Yes | X/Y/Z planes | Global-Z rotation | +| Homogeneous perfect/finite horizontal ground | Yes | X/Y planes only | Global-Z rotation | +| Odd centered square with fixed elements | Yes | Fallback | Fallback | +| Tilted/off-axis wire, rotated pattern, helix, arc, or patch | Yes where low-level geometry supports it | Fallback/error | Fallback/error | + +The structural geometry, material/load distribution, and radiating environment +must be invariant. Sources, requested port currents or voltages, and +non-radiating networks do not need to be symmetric. The planner accepts neither +general dihedral composition nor explicit `GM`-style copies as a substitute for +one supported group. + +The helix/transform prohibition is intentional. Future acceptance requires an +explicit contract and executable mapping for handedness under reflection, +endpoint direction, segment-number reversal, current/voltage port polarity, +local orientation under rotation, and the gather mapping for every exposed +quantity. Until all of those agree, such patterns stay explicit or fail under +the caller's requested policy. + ## Worker facade `createNecWorkerModel()` is imported from `@necpp-engine/wasm/worker`. The package @@ -315,17 +495,17 @@ Input arrays remain caller-owned. outstanding operations with `NecRuntimeError`, and leaves the model `disposed`. `dispose()` destroys the native handle first, then terminates. The direct `createNecModel()` entry point is unchanged for Node, tests, and -small models. Browser integration of this subpath after bundling is a WP7/WP8 -packaging concern; the worker client constructs +small models. Browser integration tests exercise direct, worker, and +transparent example paths from the inspected release tarball. The worker client constructs ```ts new Worker(new URL("./worker-entry.js", import.meta.url), { type: "module" }) ``` so bundlers can rewrite the worker URL without extra consumer configuration. -WP7 packs this subpath. Direct mode needs no bundler config. Vite apps that +Direct mode needs no bundler config. Vite apps that import the worker set `worker: { format: "es" }` because the package ships a -module worker. Browser CI for the worker subpath is WP8. +module worker. ## Canonical test models @@ -356,8 +536,11 @@ scale-aware relative error {\max(1,\lVert a\rVert_2,\lVert b\rVert_2)}. \] -The normal limit is `1e-7`. Algebraic identities using results from the same -factorization (for example \(ZY\approx I\)) also use `1e-7`. Exact metadata, +The normal limit is `1e-7`. Full explicit versus manual/automatic symmetric +representations use stricter `1e-8` relative-L2 and scaled-maximum gates for +complete complex Z/Y, requested and achieved port quantities, powers, and +complex fields. Algebraic identities using results from the same +factorization (for example \(ZY\approx I\)) use `1e-7`. Exact metadata, array sizes/order, generations, state transitions, zero-excitation output, and ownership behavior have zero tolerance. diff --git a/examples/wasm-array-vite/README.md b/examples/wasm-array-vite/README.md index df1c4555..2b8dce04 100644 --- a/examples/wasm-array-vite/README.md +++ b/examples/wasm-array-vite/README.md @@ -1,10 +1,14 @@ # Four-element array Vite example This is a downstream application for `@necpp-engine/wasm`, not a monorepo -source import. It creates four parallel half-wave dipoles in a package-supplied -Web Worker, computes the complex 4 x 4 impedance matrix, applies progressive -complex current weights, shows achieved port quantities, and plots the -combined azimuth far-field cut at 1 m. +source import. It supplies the complete list of four parallel half-wave +dipoles to the representation-independent array solver. The transparent +planner recognizes the exact reflection, builds a reduced model in a +package-supplied Web Worker, and reports its decision and maximum coordinate +adjustment. The application then uses the same matrix, solve, and field calls +that it would use after an explicit fallback: it computes the complex 4 x 4 +impedance matrix, applies progressive complex current weights, shows achieved +port quantities, and plots the combined azimuth far-field cut at 1 m. ## Run with the published package diff --git a/examples/wasm-array-vite/src/main.ts b/examples/wasm-array-vite/src/main.ts index da13938c..bcc49866 100644 --- a/examples/wasm-array-vite/src/main.ts +++ b/examples/wasm-array-vite/src/main.ts @@ -1,11 +1,12 @@ import { NecError, + createNecArraySolver, packageVersion, type ComplexMatrix, type FarFieldResult, + type FullArrayDescription, type PortSolution, } from "@necpp-engine/wasm"; -import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; import "./style.css"; @@ -15,6 +16,9 @@ interface ExampleResult { readonly portCount: number; readonly fieldSamples: number; readonly finite: boolean; + readonly representation: "explicit" | "symmetric"; + readonly maxPositionAdjustmentM: number; + readonly reasonCodes: readonly string[]; readonly wasmResponsesExpected: true; } @@ -161,28 +165,43 @@ function renderPlot(field: FarFieldResult): void { } async function run(): Promise { - const model = await createNecWorkerModel({ - onProgress: ({ operation, phase }) => { - status.textContent = `${phase === "start" ? "Running" : "Completed"} ${operation}…`; + // This is always the caller's complete array. The solver decides whether to + // construct it explicitly or reduce it internally, without changing the + // prepare/matrix/solve/field calls below. + const description = { + elements: elementPositionsM.map((xM, index) => ({ + id: `element-${index + 1}`, + positionM: [xM, 0] as const, + patternId: "dipole", + })), + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: 11, + startM: [0, 0, -0.25], + endM: [0, 0, 0.25], + radiusM: 0.001, + }], + ports: [{ wireId: "radiator", segment: 6 }], + }], + ground: { kind: "free-space" }, + } satisfies FullArrayDescription; + const model = await createNecArraySolver(description, { + symmetry: "auto", + symmetrizer: { + positionEpsilonM: 0, + allowRotation: false, }, }); try { - for (const [index, xM] of elementPositionsM.entries()) { - await model.addWire({ - tag: index + 1, - segments: 11, - start: [xM, 0, -0.25], - end: [xM, 0, 0.25], - radiusM: 0.001, - }); - } - await model.completeGeometry(); - await model.definePorts(elementPositionsM.map((_, index) => ({ - tag: index + 1, - segment: 6, - name: `Element ${index + 1}`, - }))); + const initialDiagnostics = model.getDiagnostics(); + status.textContent = initialDiagnostics.representation === "symmetric" + ? `Accepted ${initialDiagnostics.symmetry?.sectionCount}-section symmetry; preparing…` + : `Using explicit geometry: ${initialDiagnostics.planner.reasons + .map(({ code }) => code).join(", ")}; preparing…`; await model.prepare({ frequencyMHz: 300 }); const matrices = await model.computeImpedanceMatrix(); @@ -211,12 +230,16 @@ async function run(): Promise { ...field.ePhiReal, ...field.ePhiImag, ].every(Number.isFinite); + const diagnostics = model.getDiagnostics(); return { ready: true, packageVersion, portCount: solution.ports.length, fieldSamples: field.eThetaReal.length, finite, + representation: diagnostics.representation, + maxPositionAdjustmentM: diagnostics.planner.maxPositionAdjustmentM, + reasonCodes: diagnostics.planner.reasons.map(({ code }) => code), wasmResponsesExpected: true, }; } finally { @@ -225,8 +248,12 @@ async function run(): Promise { } try { - window.__NECPP_EXAMPLE_RESULT__ = await run(); - status.textContent = `Ready · @necpp-engine/wasm ${packageVersion}`; + const result = await run(); + window.__NECPP_EXAMPLE_RESULT__ = result; + const decision = result.representation === "symmetric" + ? "symmetry accepted" + : `explicit fallback (${result.reasonCodes.join(", ")})`; + status.textContent = `Ready · ${decision} · max adjustment ${result.maxPositionAdjustmentM} m · @necpp-engine/wasm ${packageVersion}`; status.classList.add("ready"); } catch (error: unknown) { const message = error instanceof NecError diff --git a/examples/wasm-symmetry/README.md b/examples/wasm-symmetry/README.md new file mode 100644 index 00000000..20c04a6c --- /dev/null +++ b/examples/wasm-symmetry/README.md @@ -0,0 +1,16 @@ +# Manual symmetry examples + +These two standalone examples construct one fundamental element and ask NEC2++ +to generate the other three elements by reflection across `x=0` and `y=0`: + +- `manual-direct.mjs` uses the synchronous model after asynchronous creation. +- `manual-worker.mjs` uses the package-supplied module worker and awaits every + operation. + +From this directory, install `@necpp-engine/wasm` and run either file with +Node 24 or newer. Release tests instead copy both files into a clean consumer, +install the exact candidate tarball, and require four finite ports and four +symmetry sections from each. + +The transparent full-description example is the neighboring +[`wasm-array-vite`](../wasm-array-vite/README.md) application. diff --git a/examples/wasm-symmetry/manual-direct.mjs b/examples/wasm-symmetry/manual-direct.mjs new file mode 100644 index 00000000..c371791f --- /dev/null +++ b/examples/wasm-symmetry/manual-direct.mjs @@ -0,0 +1,41 @@ +import { createNecModel } from "@necpp-engine/wasm"; + +const frequencyMHz = 300; +const wavelengthM = (1 / Math.sqrt(8.854e-12 * 4 * Math.PI * 1e-7)) + / (frequencyMHz * 1e6); +const model = await createNecModel(); + +try { + // One positive-X/positive-Y element is the fundamental section of this 2 x 2 array. + model.addWire({ + tag: 1, + segments: 11, + start: [wavelengthM / 4, wavelengthM / 4, wavelengthM / 12], + end: [wavelengthM / 4, wavelengthM / 4, 5 * wavelengthM / 12], + radiusM: wavelengthM / 1000, + }); + const completion = model.completeGeometry({ + groundConnection: "none", + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + model.definePorts(Array.from( + { length: 4 }, + (_, index) => ({ tag: index + 1, segment: 6 }), + )); + model.setGround({ kind: "perfect" }); + model.prepare({ frequencyMHz }); + const { impedance } = model.computeImpedanceMatrix(); + + console.log(JSON.stringify({ + mode: "direct", + sectionCount: completion.symmetry?.sectionCount, + portCount: impedance.rows, + finite: [...impedance.real, ...impedance.imag].every(Number.isFinite), + })); +} finally { + model.dispose(); +} diff --git a/examples/wasm-symmetry/manual-worker.mjs b/examples/wasm-symmetry/manual-worker.mjs new file mode 100644 index 00000000..faed65dd --- /dev/null +++ b/examples/wasm-symmetry/manual-worker.mjs @@ -0,0 +1,41 @@ +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; + +const frequencyMHz = 300; +const wavelengthM = (1 / Math.sqrt(8.854e-12 * 4 * Math.PI * 1e-7)) + / (frequencyMHz * 1e6); +const model = await createNecWorkerModel(); + +try { + // Worker methods have the same manual descriptor and metadata, but are asynchronous. + await model.addWire({ + tag: 1, + segments: 11, + start: [wavelengthM / 4, wavelengthM / 4, wavelengthM / 12], + end: [wavelengthM / 4, wavelengthM / 4, 5 * wavelengthM / 12], + radiusM: wavelengthM / 1000, + }); + const completion = await model.completeGeometry({ + groundConnection: "none", + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + await model.definePorts(Array.from( + { length: 4 }, + (_, index) => ({ tag: index + 1, segment: 6 }), + )); + await model.setGround({ kind: "perfect" }); + await model.prepare({ frequencyMHz }); + const { impedance } = await model.computeImpedanceMatrix(); + + console.log(JSON.stringify({ + mode: "worker", + sectionCount: completion.symmetry?.sectionCount, + portCount: impedance.rows, + finite: [...impedance.real, ...impedance.imag].every(Number.isFinite), + })); +} finally { + await model.dispose(); +} diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 9179c429..2aaa2555 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -56,7 +56,188 @@ try { This complete example is executed from the packed npm tarball in CI. -## Manual symmetry from one quadrant +## Symmetric arrays and automatic optimization + +For array applications, `createNecArraySolver()` accepts the caller's complete +element list and keeps one contract whether it builds that geometry explicitly +or proves that NEC can use a smaller fundamental section. `prepare()`, complete +Z/Y matrices, current- and voltage-driven solves, combined fields, and embedded +fields always use the full caller element and port order. Generated tags, copy +indices, and native copy-major order do not leak into ordinary results. + +The facade defaults to `symmetry: "auto"`. Automatic analysis never assumes a +hidden tolerance, so callers using the default must still provide +`symmetrizer.positionEpsilonM`. Use `"off"` to force the unchanged explicit +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. + +### Full NxN input with automatic selection + +This runnable 4 x 4 example supplies all 16 XY positions in row-major order. +The only reusable pattern is a Z-directed straight wire on its element-local Z +axis. The planner recognizes reflection across `x=0` and `y=0`, constructs one +quadrant, and gathers every result back into the 16-port caller order. The +current phases are deliberately progressive: excitation weights do not need to +share the structural symmetry. + +```ts +import { + createNecArraySolver, + type FullArrayDescription, +} from "@necpp-engine/wasm"; + +const frequencyMHz = 300; +const wavelengthM = (1 / Math.sqrt(8.854e-12 * 4 * Math.PI * 1e-7)) + / (frequencyMHz * 1e6); +const side = 4; +const description: FullArrayDescription = { + elements: Array.from({ length: side * side }, (_, index) => { + const x = index % side; + const y = Math.floor(index / side); + return { + id: `element-${index}`, + positionM: [ + (x - (side - 1) / 2) * wavelengthM / 2, + (y - (side - 1) / 2) * wavelengthM / 2, + ] as const, + patternId: "dipole", + }; + }), + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: 11, + startM: [0, 0, wavelengthM / 12], + endM: [0, 0, 5 * wavelengthM / 12], + radiusM: wavelengthM / 1000, + }], + ports: [{ wireId: "radiator", segment: 6, name: "feed" }], + }], + ground: { kind: "perfect" }, +}; + +const solver = await createNecArraySolver(description, { + symmetry: "auto", + symmetrizer: { positionEpsilonM: 1e-9 }, +}); + +try { + await solver.prepare({ frequencyMHz }); + const { impedance, admittance } = await solver.computeImpedanceMatrix(); + const currents = { + real: Float64Array.from( + description.elements, + (_, index) => Math.cos(index * Math.PI / 12), + ), + imag: Float64Array.from( + description.elements, + (_, index) => Math.sin(index * Math.PI / 12), + ), + }; + const solution = await solver.solveCurrents(currents); + const request = { + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, + } as const; + const field = await solver.computeFarField(request); + const embedded = await solver.computeEmbeddedFarFields( + request, + { kind: "unit-current", valueA: 1 }, + ); + const diagnostics = solver.getDiagnostics(); + + console.log({ + representation: diagnostics.representation, + sections: diagnostics.symmetry?.sectionCount, + exact: diagnostics.planner.exact, + maxAdjustmentM: diagnostics.planner.maxPositionAdjustmentM, + warnings: diagnostics.planner.reasons, + zOrder: impedance.rows, + yOrder: admittance.rows, + solvedPorts: solution.ports.length, + combinedSamples: field.eThetaReal.length, + embeddedPorts: embedded.ports.length, + }); +} finally { + await solver.dispose(); +} +``` + +`positionEpsilonM` is an acceptance and canonicalization tolerance, not a claim +that the input was exact. Every accepted coordinate replacement appears in +`diagnostics.planner.canonicalizations`; `exact` and +`maxPositionAdjustmentM` summarize the adjustment. If an off-origin array is +recentered for NEC symmetry, combined and embedded complex fields are restored +to the caller's origin automatically with the package's `e^(+j k u·center)` +phase correction. + +Inspect diagnostics when optimization information matters; ordinary solver +code does not branch on it. An accepted plan reports `representation: +"symmetric"`, its reduction metadata, candidate decisions, and coordinate +adjustments. A fallback reports `representation: "explicit"` and stable reason +codes such as `FIXED_ELEMENT_ON_REFLECTION_PLANE`, +`FIXED_ELEMENT_ON_ROTATION_AXIS`, `POSITION_OUTSIDE_EPSILON`, +`PATTERN_MISMATCH`, `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM`, +`UNSYMMETRIC_LOAD`, or `GROUND_BREAKS_SYMMETRY`. Centered odd-sided square +arrays therefore remain explicit because elements lie on reflection planes and +the rotation axis; no element is dropped. + +The modes are explicit application policy: + +```ts +import { + createNecArraySolver, + type FullArrayDescription, +} from "@necpp-engine/wasm"; + +declare const description: FullArrayDescription; + +const automatic = await createNecArraySolver(description, { + symmetry: "auto", + symmetrizer: { positionEpsilonM: 1e-9 }, +}); +const explicit = await createNecArraySolver(description, { symmetry: "off" }); +const required = await createNecArraySolver(description, { + symmetry: "require", + symmetrizer: { positionEpsilonM: 0 }, +}); + +await Promise.all([ + automatic.dispose(), + explicit.dispose(), + required.dispose(), +]); +``` + +The first release accepts only pointwise transform-invariant element patterns: +straight Z-directed wires whose local X and Y coordinates are zero, with zero +or omitted element rotation. Helices, tilted or off-axis wires, multiple +off-axis wire sets, arcs, patches, rotated patterns, and transforms whose +handedness, endpoint direction, segment mapping, or port polarity is unresolved +fall back under `"auto"`. `"require"` or `onUnsupported: "error"` turns that +condition into a controlled error. This is structural symmetry: sources, +requested currents/voltages, and non-radiating networks may be arbitrary, but +geometry, loads, and the radiating environment must form complete equal orbits. + +Supported structural operations are: + +| Operation | Free space | Homogeneous horizontal ground | Important restriction | +|---|---:|---:|---| +| Reflection in `x=0` and/or `y=0` | Yes | Yes | No wire may lie in or cross a generating plane | +| Reflection in `z=0` | Yes | No | Ground and structural `z=0` reflection are incompatible | +| N-fold rotation about global Z | Yes | Yes | Order is at least 2; no element may be fixed on the axis | + +The automatic planner may translate the XY origin before applying one of these +groups. It does not combine reflection and rotation into a general dihedral +optimization. Loads attached to a reusable pattern are expanded over the +complete orbit atomically; low-level manual users must supply equal complete +load orbits before `prepare()`. + +### Manual symmetry from one quadrant When the geometry is known to be symmetric, build only its fundamental section and make symmetry the final geometry operation. This 300 MHz reference model @@ -119,12 +300,53 @@ section count, fundamental/full segment counts, transforms, and offsets. It is deeply immutable and has the same shape when returned by the worker API. Use `rotationalOrder(n)` for N-fold rotation about global Z. +The worker API uses the same descriptor and returns the same immutable +metadata; only the operation calls are awaited: + +```ts +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; + +const model = await createNecWorkerModel(); +try { + await model.addWire({ + tag: 1, + segments: 11, + start: [0.25, 0.25, 0.1], + end: [0.25, 0.25, 0.4], + radiusM: 0.001, + }); + const completion = await model.completeGeometry({ + symmetry: { + kind: "reflection", + planes: ["x=0", "y=0"], + tagIncrement: 1, + }, + }); + await model.definePorts(Array.from( + { length: 4 }, + (_, index) => ({ tag: index + 1, segment: 6 }), + )); + await model.prepare({ frequencyMHz: 300 }); + console.log(completion.symmetry, await model.computeImpedanceMatrix()); +} finally { + await model.dispose(); +} +``` + Plane reflection rejects wires that lie in or cross a generating plane. Structural `z=0` reflection is incompatible with ground, while the vertical planes used above remain valid over homogeneous horizontal ground. Geometry cannot be added after symmetric completion, and structural loads must cover complete symmetry orbits before `prepare()`. +On an AMD Ryzen 7 PRO 7840HS running Windows, Node 24.14.1, and the Emscripten +4.0.7 artifact, the three-round 16 x 16 perfect-ground reference benchmark +measured 13,196.45 ms explicit preparation versus 1,142.14 ms manual +two-plane-reflection preparation (11.55x), while the primary interaction +matrix allocation fell from 121.00 MiB to 30.25 MiB (4.00x). These are +model- and host-specific measurements, not a universal speedup promise. See +the [benchmark method and full results](https://github.com/andrekuehne/necpp/blob/master/packages/necpp-wasm/bench/RESULTS.md). + ## Numerical conventions - Coordinates, wire radius, and field radius are metres. Public frequencies diff --git a/packages/necpp-wasm/package.json b/packages/necpp-wasm/package.json index 9e12fc0d..d352b132 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -3,12 +3,14 @@ "version": "0.1.1", "private": false, "type": "module", - "description": "Stateful NEC2++ electromagnetic solver for Node and the browser", + "description": "Stateful NEC2++ electromagnetic solver and symmetric-array API for Node and browsers", "keywords": [ "antenna", + "antenna-array", "electromagnetics", "nec2", "simulation", + "symmetry", "typescript", "wasm", "webassembly" diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index a72f1f6c..90dc479f 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -263,6 +263,9 @@ try { assert.equal(result.portCount, 4); assert.equal(result.fieldSamples, 361); assert.equal(result.finite, true); + assert.equal(result.representation, "symmetric"); + assert.equal(result.maxPositionAdjustmentM, 0); + assert.deepEqual(result.reasonCodes, []); assert.equal(await page.locator("#ports tbody tr").count(), 4); assert.equal(await page.locator("#matrix tbody tr").count(), 5); assert.equal(await page.locator("#plot .pattern").count(), 1); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index ba19add7..0c2d017c 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -111,6 +111,13 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { installFixture(fixture.root); writeFixtureFile(fixture.root, "dipole.mjs", dipoleScript); writeFixtureFile(fixture.root, "worker-dipole.mjs", workerDipoleScript); + for (const name of ["manual-direct.mjs", "manual-worker.mjs"]) { + writeFixtureFile( + fixture.root, + name, + readFileSync(new URL(`../../../../examples/wasm-symmetry/${name}`, import.meta.url), "utf8"), + ); + } const direct = parseJsonLine(run("node", ["dipole.mjs"], { cwd: fixture.root, @@ -144,6 +151,20 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.equal(worker.packageVersion, packageJson.version); assert.equal(worker.sectionCount, 4); assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); + + for (const [name, mode] of [ + ["manual-direct.mjs", "direct"], + ["manual-worker.mjs", "worker"], + ]) { + const example = parseJsonLine(run("node", [name], { + cwd: fixture.root, + stdio: ["ignore", "pipe", "inherit"], + }).stdout); + assert.equal(example.mode, mode); + assert.equal(example.sectionCount, 4); + assert.equal(example.portCount, 4); + assert.equal(example.finite, true); + } }); test("every package README TypeScript example compiles and the quick start executes", { @@ -156,10 +177,29 @@ test("every package README TypeScript example compiles and the quick start execu `vite@${VITE_VERSION}`, ]); - const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8"); + const readme = readFileSync(join( + fixture.root, + "node_modules", + "@necpp-engine", + "wasm", + "README.md", + ), "utf8"); + for (const requiredText of [ + "Symmetric arrays and automatic optimization", + "Full NxN input with automatic selection", + 'symmetry: "auto"', + 'symmetry: "off"', + 'symmetry: "require"', + "maxPositionAdjustmentM", + "UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM", + "11.55x", + "https://github.com/andrekuehne/necpp/blob/master/docs/wasm-api.md", + ]) { + assert.ok(readme.includes(requiredText), `packed README is missing ${requiredText}`); + } const examples = [...readme.matchAll(/```ts\r?\n([\s\S]*?)```/g)] .map((match) => match[1]); - assert.ok(examples.length >= 7, "expected all documented TypeScript examples"); + assert.ok(examples.length >= 10, "expected all documented TypeScript examples"); const paths = examples.map((source, index) => { const path = `readme-example-${index + 1}.ts`; From b5375f7a3fcc22e551c77492e523b18b2b3da0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 30 Aug 2026 22:34:25 +0200 Subject: [PATCH 45/46] release: finalize symmetry support 0.2.0 --- CHANGELOG.md | 2 +- CMakeLists.txt | 2 +- docs/01_symmetry_support.md | 42 ++++++++++++++++++- docs/wasm-api.md | 4 +- examples/wasm-array-vite/README.md | 2 +- packages/necpp-wasm/README.md | 2 +- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 2 +- packages/necpp-wasm/src/versions.ts | 4 +- .../necpp-wasm/test/browser-integration.mjs | 2 +- .../necpp-wasm/test/facade-runtime.test.mjs | 4 +- .../necpp-wasm/test/pack/consumer.test.mjs | 2 +- .../necpp-wasm/test/pack/manifest.test.mjs | 2 +- 13 files changed, 57 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 332cc029..81f60177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.2.0 - 2026-08-30 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 742efa1e..b1d2cb4b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.16) project(necpp - VERSION 2.3.4 + VERSION 2.4.0 DESCRIPTION "NEC2++ antenna modelling library and tools" HOMEPAGE_URL "https://github.com/tmolteno/necpp" LANGUAGES C CXX) diff --git a/docs/01_symmetry_support.md b/docs/01_symmetry_support.md index be91b4dc..0a75f232 100644 --- a/docs/01_symmetry_support.md +++ b/docs/01_symmetry_support.md @@ -1239,7 +1239,7 @@ Every agent updates this table and the detailed WP section before handing off. | WP-S6 End-to-end equivalence suite | complete | Codex | Focused WP-S6: 8/8; npm/WASM: 68/68 + typecheck; native `[wp_s2]` 10,599 and `[wp_s3]` 8 assertions | WP-S7 should reuse the reference fixture, caller-order checks, complex metrics, and ordinary 8 x 8 R3 gate before reporting performance. | | WP-S7 Benchmarks and performance gates | complete | Codex | 45/45 isolated current cases; 30/30 binary64 comparisons; 16 x 16 prepare 11.55x; matrix 4.00x | WP-S8 may claim only the measured matrix-scale results; the explicit 2 x 2 pre-feature regression gate remains a documented miss. | | WP-S8 Public documentation, examples, and release hardening | complete | Codex | npm/WASM 69/69 + typecheck; pack 5/5; browser 3/3; native Release partitions pass | WP-S9 should only assign versions/date, rebuild identities, rerun this checklist, and inspect the final tarball. | -| WP-S9 Final version bump and release identity | not started | — | — | — | +| WP-S9 Final version bump and release identity | complete | Codex | native Release + 8 partitions; npm/WASM 69/69 + pack 5/5; browser 3/3; final tarball inspected | Symmetry support is release-ready at package 0.2.0, engine 2.4.0, and ABI 1; publishing/tagging remains a separately authorized action. | Allowed states are `not started`, `in progress`, `blocked`, and `complete`. `complete` requires the WP's DoD, not merely code that compiles. @@ -2089,6 +2089,46 @@ DoD: final validation is followed by rebuilding, retesting, and rechecking every release identity before WP-S9 is marked complete. +Completion evidence (2026-08-30, Windows, Node 24.14.1): + +- Finalized `@necpp-engine/wasm` as `0.2.0`, NEC2++ as `2.4.0`, and retained + WASM ABI `1`. Package metadata, both lockfile entries, TypeScript exports, + CMake metadata, runtime assertions, user-facing versioned examples, and the + dated changelog section agree. A repository search found no stale expected + `0.1.1` or `2.3.4` identity in active source, tests, generated package + metadata, or versioned examples; historical results, changelog entries, and + before/after planning text remain intentionally unchanged. +- The cached native Release tree regenerated after the CMake change and built + successfully. The executable reports `nec2++ 2.4.0 [2026-08-30]`; generated + `config.h` and `necpp.pc` also report `2.4.0`. Seven non-WP1 CTest partitions + passed. The long-output WP1 partition passed as `[wp1]~[stress]` with 49 + assertions in six cases, and `[stress]` exited zero with output suppressed. +- `scripts/build_wasm_docker.ps1` rebuilt the module with pinned + `emscripten/emsdk:4.0.7`. The ABI smoke test passed; `test:wasm` passed 69/69 + runtime tests plus strict typechecking and the five packed-package tests. + These runs exercised embedded engine `2.4.0`, exported package `0.2.0`, and + unchanged ABI `1` in direct and worker modes. +- The final artifact is `necpp-engine-wasm-0.2.0.tgz` (329,882 bytes, SHA-256 + `a436b7fc831b942be7156a6df5e99d4d4f40bdcc6955ce24527f5779b37c5d15`). + Its manifest contains 39 intended files, including the README, symmetry + declarations and implementation, worker entry points, and the 733,440-byte + WASM artifact, with no source, debug, test, or benchmark output. The exact + tarball passed four clean Node/README/Vite consumer tests and Chromium direct, + worker, and transparent-example integration. `npm pack --dry-run --json` + reported the identical filename, sizes, hashes, and contents. +- CMake/CTest were not on this PowerShell PATH, so the cached CMake 3.31.6 tools + were invoked by their recorded absolute path. Initial sandboxed Docker and + final-pack attempts were denied access to the Docker API and user npm cache; + the identical commands passed with the required access. No test was silently + skipped. No tag, GitHub release, or npm publication was created. + +Final contract decision: + +- Package `0.2.0`, engine `2.4.0`, and ABI `1` are the completed symmetry + release identities. Any later source correction requires rebuilding and + rerunning the release checklist; external tagging and publication require + separate user authorization. + ## 14. Global Definition of Done Symmetry support is ready to merge/release only when: diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 82f1a436..92704e0e 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -20,8 +20,8 @@ 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 current package identity is `0.1.1`; WP-S9 assigns the documented symmetry -release its final `0.2.0` identity after all release gates pass. +The symmetry release package identity is `0.2.0`; it embeds NEC2++ `2.4.0` +while preserving WASM ABI version `1`. The packed package exports three version identifiers that can be imported without constructing a model: diff --git a/examples/wasm-array-vite/README.md b/examples/wasm-array-vite/README.md index 2b8dce04..917d1007 100644 --- a/examples/wasm-array-vite/README.md +++ b/examples/wasm-array-vite/README.md @@ -13,7 +13,7 @@ port quantities, and plots the combined azimuth far-field cut at 1 m. ## Run with the published package From this directory, run `npm install`, then -`npm install @necpp-engine/wasm@0.1.1`, followed by `npm run dev`. Open the URL +`npm install @necpp-engine/wasm@0.2.0`, followed by `npm run dev`. Open the URL printed by Vite. Use `npm run build` and `npm run preview` to inspect the production bundle. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 2aaa2555..1b9cc629 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -598,7 +598,7 @@ appropriate CORS header. import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel({ - wasmUrl: new URL("https://cdn.example.test/necpp/0.1.1/nec2pp.wasm"), + wasmUrl: new URL("https://cdn.example.test/necpp/0.2.0/nec2pp.wasm"), }); model.dispose(); ``` diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 6bc88cad..935fc11e 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.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.1.1", + "version": "0.2.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 d352b132..52f89fff 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@necpp-engine/wasm", - "version": "0.1.1", + "version": "0.2.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/versions.ts b/packages/necpp-wasm/src/versions.ts index 93c0f8e1..fbe71285 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.1.1"; +export const packageVersion = "0.2.0"; export const abiVersion = 1; -export const engineVersion = "2.3.4"; +export const engineVersion = "2.4.0"; diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index 90dc479f..7d0b36b7 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -272,7 +272,7 @@ try { } else { assert.equal(result.mode, mode); assert.equal(result.abiVersion, 1); - assert.equal(result.engineVersion, "2.3.4"); + assert.equal(result.engineVersion, "2.4.0"); assert.ok(result.resistanceOhm > 0); assert.equal(result.fieldSamples, 3); assert.equal(result.fieldFinite, true); diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 02be5964..52ee826d 100644 --- a/packages/necpp-wasm/test/facade-runtime.test.mjs +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -54,8 +54,8 @@ function addDipole(model) { } test("package, engine, and ABI versions are exported", () => { - assert.equal(packageVersion, "0.1.1"); - assert.equal(engineVersion, "2.3.4"); + assert.equal(packageVersion, "0.2.0"); + assert.equal(engineVersion, "2.4.0"); assert.equal(abiVersion, 1); }); diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 0c2d017c..7da9c772 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -124,7 +124,7 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { stdio: ["ignore", "pipe", "inherit"], }).stdout); assert.equal(direct.packageVersion, packageJson.version); - assert.equal(direct.engineVersion, "2.3.4"); + assert.equal(direct.engineVersion, "2.4.0"); assert.equal(direct.abiVersion, 1); assert.equal(direct.sectionCount, 4); assert.ok(direct.resistanceOhm > 0); diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index 100390fb..bb6c2dd1 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -17,7 +17,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.1.1"); + assert.equal(packageJson.version, "0.2.0"); assert.equal(packageJson.engines.node, ">=24"); assert.deepEqual(packageJson.publishConfig, { access: "public", From 57324fabfb02922f03a04ce4aefac7464c9024ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Mon, 31 Aug 2026 13:10:48 +0200 Subject: [PATCH 46/46] feat: add power budgets and ground connections --- CHANGELOG.md | 20 + CMakeLists.txt | 2 +- docs/wasm-api.md | 44 +- docs/wasm-visualizer-agent-onboarding.md | 585 ++++++++++++++++++ examples/wasm-array-vite/README.md | 2 +- packages/necpp-wasm/README.md | 86 ++- packages/necpp-wasm/package-lock.json | 4 +- packages/necpp-wasm/package.json | 2 +- packages/necpp-wasm/src/array-solver.ts | 7 +- packages/necpp-wasm/src/array-symmetry.ts | 16 +- packages/necpp-wasm/src/index.ts | 1 + packages/necpp-wasm/src/model.ts | 22 + packages/necpp-wasm/src/types.ts | 17 + packages/necpp-wasm/src/versions.ts | 4 +- packages/necpp-wasm/src/wasm-internal.ts | 4 + packages/necpp-wasm/src/worker-protocol.ts | 37 ++ packages/necpp-wasm/test-d/public-api.test.ts | 4 + packages/necpp-wasm/test-d/worker-api.test.ts | 2 + .../test/array-solver.integration.test.mjs | 55 ++ .../necpp-wasm/test/array-symmetry.test.mjs | 56 ++ .../necpp-wasm/test/browser-integration.mjs | 9 +- .../necpp-wasm/test/facade-runtime.test.mjs | 24 +- .../necpp-wasm/test/pack/consumer.test.mjs | 16 +- packages/necpp-wasm/test/pack/helpers.mjs | 53 ++ .../necpp-wasm/test/pack/manifest.test.mjs | 2 +- .../test/symmetry-equivalence.test.mjs | 12 + .../necpp-wasm/test/worker-client.test.mjs | 15 + .../test/worker-integration.test.mjs | 6 +- .../necpp-wasm/test/worker-protocol.test.mjs | 56 ++ src/CMakeLists.txt | 3 +- src/c_geometry.cpp | 18 +- src/nec_context.cpp | 17 +- src/nec_context.h | 4 + src/nec_power_budget.h | 19 + src/nec_stateful_model.cpp | 21 +- src/nec_stateful_model.h | 3 + src/nec_stateful_model_wp2_tb.cpp | 67 ++ src/nec_stateful_model_wp3_tb.cpp | 125 ++++ src/necpp_wasm_v1.cpp | 40 ++ src/necpp_wasm_v1.h | 8 + src/necpp_wasm_v1_c_tb.c | 12 + src/necpp_wasm_v1_tb.cpp | 12 + 42 files changed, 1479 insertions(+), 33 deletions(-) create mode 100644 docs/wasm-visualizer-agent-onboarding.md create mode 100644 src/nec_power_budget.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f60177..873acfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.3.0 - 2026-08-31 + +### Added + +* **Native simultaneous-solve power budgets:** every direct, worker, and array + `PortSolution` now includes immutable input, radiated, structure-loss, and + network-loss watts plus nullable efficiency. The additive ABI-v1 accessors + copy NEC's native balance at solve completion so later internal work cannot + overwrite the public result. +* **Array ground-connection support:** `FullArrayDescription.groundConnection` + reaches both explicit and symmetric geometry builders and defaults to + `"none"`. + +### Bug Fixes + +* **`"zero-current"` now maps to signed NEC `GE -1`:** the stable public ABI + value remains `2`, while native geometry receives `-1`. Both signed ground + modes now reject below-plane and in-plane segments, and a non-none + connection without a ground model fails during preparation. + ## 0.2.0 - 2026-08-30 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index b1d2cb4b..22ddd02e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.16) project(necpp - VERSION 2.4.0 + VERSION 2.5.0 DESCRIPTION "NEC2++ antenna modelling library and tools" HOMEPAGE_URL "https://github.com/tmolteno/necpp" LANGUAGES C CXX) diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 92704e0e..3eb761c6 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -1,6 +1,7 @@ # `@necpp-engine/wasm` API and numerical contract -Status: normative specification, updated through symmetry WP-S8 on 2026-08-30. The +Status: normative specification, updated through power/ground support on +2026-08-31. 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). @@ -20,7 +21,7 @@ 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 symmetry release package identity is `0.2.0`; it embeds NEC2++ `2.4.0` +The power/ground release package identity is `0.3.0`; it embeds NEC2++ `2.5.0` while preserving WASM ABI version `1`. The packed package exports three version identifiers that can be imported @@ -82,6 +83,29 @@ These conventions apply to every public method and returned value. - All computations use IEEE-754 binary64 values. Inputs must be finite except where a result explicitly permits NaN active impedance for zero current. +Every `PortSolution` includes both caller-order `powersW` and an aggregate +native balance: + +```ts +interface PowerBudget { + readonly inputPowerW: number; + readonly radiatedPowerW: number; + readonly structureLossW: number; + readonly networkLossW: number; + readonly efficiencyPercent: number | null; +} +``` + +`inputPowerW` agrees with the sum of the simultaneous per-port powers within +numerical tolerance. Individual ports may be negative in a coupled active +array. NEC defines `radiatedPowerW = inputPowerW - structureLossW - +networkLossW`; efficiency is `null` only when captured input is exactly zero. +The object is frozen and remains tied to the surrounding `solveGeneration` +after later solves and worker transfer. Native radiated power is not an +angular quadrature or polarization-resolved measurement. In particular, the +NEC balance has no separate finite-ground-loss field, so upper-hemisphere +flux equality is not asserted for finite lossy ground. + The phase, range, and angle definitions follow the NEC-2 Part 3 [RP card](https://www.nec2.org/part_3/cards/rp.html); voltage-source fields and tag-relative segment addressing follow the @@ -166,6 +190,12 @@ Additional lifecycle rules: mutation and returns immutable copy/count metadata. No wire, patch, or other geometry primitive may be added after generation. Plane-list order never changes NEC's fixed Z-then-Y-then-X copy order. +- `groundConnection` defaults to `"none"` (`GE 0`). `"interpolate"` maps to + `GE +1` and interpolates a touching wire end to its image; `"zero-current"` + maps to signed `GE -1` and leaves the expansion unmodified. Either non-none + mode requires perfect or finite ground by `prepare()`, rejects wires below + or lying in `z=0`, and is incompatible with structural reflection through + `z=0`. The connection flag does not install ground. - `definePorts()` replaces the complete port list. It requires a nonempty list of unique, valid tag/segment pairs and is deliberately frozen before preparation so all later results have stable ordering. @@ -320,6 +350,7 @@ interface FullArrayDescription { }[]; readonly patterns: readonly ElementWirePattern[]; readonly ground: GroundModel; + readonly groundConnection?: "none" | "interpolate" | "zero-current"; } interface CreateArraySolverOptions { @@ -346,6 +377,14 @@ eligibility refinements `INCOMPATIBLE_GROUND`, `INCOMPLETE_LOAD_ORBIT`, or `UNSUPPORTED_ELEMENT_PATTERN_TRANSFORM`. Allocation, cancellation, conditioning, solver, and invalid full-geometry failures are never hidden. +The omitted array `groundConnection` default is `"none"`. Explicit and +symmetric builders pass the same validated value to geometry completion. A +non-none value with `ground.kind === "free-space"` fails with `NecInputError` +before worker construction. A connection to finite ground is not an accurate +ground-stake model in NEC-2 and can make impedance strongly dependent on the +source-segment length; changing the connection requires reconstructing the +solver. + The returned `NecArraySolver` is worker-backed and asynchronous: ```ts @@ -380,6 +419,7 @@ 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. +The aggregate `powerBudget` has no port order and passes through unchanged. An accepted reflection candidate canonicalizes centered positions with sign transforms. An accepted rotational candidate uses diff --git a/docs/wasm-visualizer-agent-onboarding.md b/docs/wasm-visualizer-agent-onboarding.md new file mode 100644 index 00000000..9b253bd8 --- /dev/null +++ b/docs/wasm-visualizer-agent-onboarding.md @@ -0,0 +1,585 @@ +# External implementation guide: NEC2++ TypeScript/WASM visualizer + +This handoff targets `@necpp-engine/wasm` **0.3.0**, containing NEC2++ 2.5.0 +and stable WASM ABI 1. + +This document is the handoff for the agent implementing a browser visualizer +with `@necpp-engine/wasm`. The implementation must consume the published npm +package only. Do not assume access to the NEC2++ source repository, import an +internal file, copy a WebAssembly artifact, or parse a formatted NEC report for +features that the typed API already exposes. + +## Start with the package contract + +The primary consumer documentation is the +[`@necpp-engine/wasm` npm README](https://www.npmjs.com/package/@necpp-engine/wasm?activeTab=readme). +Read it before implementing the simulation layer. It contains complete direct, +worker, array, matrix, excitation, and far-field examples. The README and TypeScript +declarations installed with the selected package version are the authority for +that version; do not copy API details from an unversioned third-party example. + +The same README can be read without a browser: + +```sh +npm view @necpp-engine/wasm readme +``` + +Inspect the version and runtime boundary before starting: + +```sh +npm view @necpp-engine/wasm version engines exports +node --version +``` + +The package is ESM-only and requires Node 24 or newer for the build and Node +runtime. The engine itself runs in current browsers as WebAssembly. + +## Install + +In the visualizer application, install the public package from npm: + +```sh +npm install @necpp-engine/wasm +``` + +The package must be published and visible through the application's configured +npm registry. If `npm view @necpp-engine/wasm version` returns `E404`, stop and +ask the package owner for the published version or registry access. Do not work +around it with a repository path, an internal build, or an untracked tarball; +those would violate this handoff's external-consumer constraint. + +Commit the application's `package-lock.json` so CI, reviewers, and deployments +use the same engine version. To deliberately pin an exact version rather than +accept the project's normal semver range, use: + +```sh +npm install --save-exact @necpp-engine/wasm +``` + +For a new Vite TypeScript application, one possible starting point is: + +```sh +npm create vite@latest nec-visualizer -- --template vanilla-ts +cd nec-visualizer +npm install +npm install @necpp-engine/wasm +``` + +Use this Vite configuration when any package worker API is used: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { target: "es2024" }, + worker: { format: "es" }, +}); +``` + +The package resolves `nec2pp.wasm` and its module worker relative to its +installed JavaScript. Do not add a worker bootstrap and do not manually copy +`nec2pp.wasm` into `public/`. A production server must return the emitted +`.wasm` asset with `Content-Type: application/wasm`. + +## Choose the correct API + +Use the highest-level API that represents the visualizer's model: + +| Application data | API | Import | Recommendation | +|---|---|---|---| +| A complete list of repeated array elements and their reusable wire patterns | `createNecArraySolver()` | `@necpp-engine/wasm` | Preferred for an array visualizer. It is asynchronous and worker-backed. | +| Arbitrary wires, tags, ports, loads, and ground | `createNecWorkerModel()` | `@necpp-engine/wasm/worker` | Preferred low-level browser API. It keeps native work off the UI thread. | +| Small models, Node tools, or tests where blocking is acceptable | `createNecModel()` | `@necpp-engine/wasm` | Direct calls after creation are synchronous and can block a browser tab. | +| An existing complete NEC text deck | `runDeck()` | `@necpp-engine/wasm` | Compatibility escape hatch, not the default architecture for a new visualizer. | + +`createNecArraySolver()` accepts the caller's full ordered array. It can reduce +a supported symmetric layout internally, but all matrices, port vectors, and +embedded patterns remain in the caller's original element/port order. UI code +must not depend on whether diagnostics report `"explicit"` or `"symmetric"`. + +Use `symmetry: "auto"` with an explicit `positionEpsilonM`. A value of `0` +accepts exact symmetry only. A positive value permits the planner to +canonicalize positions within that many metres; expose or log +`getDiagnostics()` so adjustments and fallback reasons remain visible. Use +`symmetry: "off"` if the application requires the exact supplied geometry and +does not want symmetry analysis. + +## Copy-ready array simulation + +The example below models four parallel, centre-fed dipoles, drives progressive +complex currents, and obtains a horizontal far-field cut. It uses only the +public npm API. + +```ts +import { + NecError, + createNecArraySolver, + type FarFieldRequest, + type FarFieldResult, + type FullArrayDescription, + type ImpedanceResult, + type NecArraySolver, + type PortSolution, +} from "@necpp-engine/wasm"; + +export interface SimulationResult { + readonly matrices: ImpedanceResult; + readonly solution: PortSolution; + readonly field: FarFieldResult; + readonly representation: "explicit" | "symmetric"; +} + +const description = { + elements: [-0.45, -0.15, 0.15, 0.45].map((xM, index) => ({ + id: `element-${index + 1}`, + positionM: [xM, 0] as const, + patternId: "dipole", + })), + patterns: [{ + id: "dipole", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: 11, + startM: [0, 0, -0.25], + endM: [0, 0, 0.25], + radiusM: 0.001, + }], + ports: [{ wireId: "radiator", segment: 6, name: "feed" }], + }], + ground: { kind: "free-space" }, +} satisfies FullArrayDescription; + +const fieldRequest = { + radiusM: 1, + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 361, stepDeg: 1 }, +} satisfies FarFieldRequest; + +export async function createArraySession(): Promise { + return createNecArraySolver(description, { + symmetry: "auto", + symmetrizer: { + positionEpsilonM: 0, + allowRotation: false, + }, + }); +} + +export async function simulate( + solver: NecArraySolver, + frequencyMHz: number, + phaseStepDeg: number, +): Promise { + await solver.prepare({ frequencyMHz }); + const matrices = await solver.computeImpedanceMatrix(); + const phaseStepRad = phaseStepDeg * Math.PI / 180; + const portCount = description.elements.length; + const currents = { + real: Float64Array.from( + { length: portCount }, + (_, port) => Math.cos(port * phaseStepRad), + ), + imag: Float64Array.from( + { length: portCount }, + (_, port) => Math.sin(port * phaseStepRad), + ), + }; + const solution = await solver.solveCurrents(currents); + const field = await solver.computeFarField(fieldRequest); + + return { + matrices, + solution, + field, + representation: solver.getDiagnostics().representation, + }; +} + +async function run(): Promise { + const solver = await createArraySession(); + try { + const result = await simulate(solver, 300, -60); + console.log(result); + } finally { + await solver.dispose(); + } +} + +run().catch((error: unknown) => { + if (error instanceof NecError) { + console.error(error.code, error.message, error.details); + return; + } + throw error; +}); +``` + +In the production app, keep the solver alive while frequency, excitation, or +field-grid controls change. Dispose and recreate it only when geometry, ports, +pattern loads, or ground change. Returned typed arrays are JavaScript-owned +copies, so results remain valid after later solves and after disposal. + +For a rooted monopole, put the wire end exactly at `z=0`, select a real ground +model, and opt into the NEC geometry connection explicitly: + +```ts +const rootedArray: FullArrayDescription = { + elements: [{ id: "m0", positionM: [0, 0], patternId: "monopole" }], + patterns: [{ + id: "monopole", + kind: "straight-wire-pattern", + wires: [{ + id: "wire", + segments: 11, + startM: [0, 0, 0], + endM: [0, 0, 0.25], + radiusM: 0.001, + }], + ports: [{ wireId: "wire", segment: 2 }], + }], + ground: { kind: "perfect" }, + groundConnection: "interpolate", +}; +``` + +`"interpolate"` is NEC `GE +1`; `"zero-current"` is signed `GE -1`; omission +is `GE 0`. A non-none connection requires perfect or finite ground and changing +it requires solver reconstruction. NEC does not model a finite-ground stake +accurately; base impedance can be strongly source-segment-length dependent. + +## Obtain combined far fields without JavaScript superposition + +The `simulate()` example above already uses the preferred combined-field path. +`solveCurrents()` first calculates the source voltages required for the whole +requested current vector and executes one simultaneous NEC excitation. +`computeFarField()` then evaluates the latest combined native current state. +Mutual coupling and complex interference are therefore included by NEC; the +application must not separately solve each port and add embedded fields in +JavaScript. + +For each steering update, use this sequence: + +```ts +const solution = await solver.solveCurrents(currents); +const field = await solver.computeFarField(fieldRequest); +``` + +Use `solveVoltages(voltages)` instead when voltages are the application's +independent drive values. Both methods leave one latest consumer solution for +`computeFarField()`. A second `computeFarField()` call with another regular +theta/phi request reuses that solution, so display and integration grids can +be sampled without another port solve: + +```ts +const solution = await solver.solveCurrents(currents); +const displayField = await solver.computeFarField(displayRequest); +const integrationField = await solver.computeFarField(integrationRequest); +``` + +The returned solution carries NEC's simultaneous native power balance: + +```ts +console.log(solution.powerBudget); +// { +// inputPowerW, radiatedPowerW, structureLossW, networkLossW, +// efficiencyPercent // null only for exact zero input +// } +``` + +The sum of `solution.powersW` is aggregate source input, while +`powerBudget.radiatedPowerW` subtracts structure and network loss. It is total +native radiated power, not power in a selected polarization. Finite lossy +ground has no separate ground-loss field in this balance. + +`computeEmbeddedFarFields()` remains available for specialized basis analysis, +but it is not required for ordinary beam steering. Do not transfer its +port-major basis merely to run a `ports * samples` complex sum in application +JavaScript. The array facade may apply its documented caller-origin rephasing; +that coordinate correction is not per-port field superposition. + +For polarization-resolved measurements, project and integrate in the existing +Rust/WASM postprocessor: + +```text +E_p = conjugate(p_theta) E_theta + conjugate(p_phi) E_phi +P_p = r^2 / (2 eta_0) * sum_i solidAngleWeight_i * |E_p,i|^2 +``` + +Use midpoint theta rings, omit a duplicate `phi=360 degrees` endpoint, and use +exact ring weights: + +```text +deltaTheta = thetaMaximum / nTheta +deltaPhi = 2 pi / nPhi +theta_i = (i + 1/2) deltaTheta +phi_j = j deltaPhi +w_ij = deltaPhi * [cos(i deltaTheta) - cos((i+1) deltaTheta)] +``` + +Use `thetaMaximum = pi` in free space and `pi/2` over an infinite ground +plane. For an orthonormal co/cross pair, `P_co + P_cross` must agree with total +integrated power within quadrature error. Define RHCP/LHCP for the package's +`e^(+j omega t)` convention and verify the sign with a known circular fixture. + +## Convert a field result for plotting + +Far-field components are complex V/m values. The total field magnitude for a +sample is + +`sqrt(|E_theta|^2 + |E_phi|^2)`. + +Samples are theta-fast: + +`sampleIndex = phiIndex * thetaCount + thetaIndex`. + +This adapter produces normalized dB values suitable for a 2D or 3D pattern +plot: + +```ts +import type { FarFieldResult } from "@necpp-engine/wasm"; + +export interface PatternSample { + readonly thetaDeg: number; + readonly phiDeg: number; + readonly magnitudeVPerM: number; + readonly normalizedDb: number; +} + +export function toNormalizedPattern( + field: FarFieldResult, + floorDb = -60, +): readonly PatternSample[] { + const thetaCount = field.thetaDeg.length; + const magnitudes = Float64Array.from( + field.eThetaReal, + (eThetaReal, index) => Math.hypot( + eThetaReal, + field.eThetaImag[index]!, + field.ePhiReal[index]!, + field.ePhiImag[index]!, + ), + ); + let peak = 0; + for (const magnitude of magnitudes) { + peak = Math.max(peak, magnitude); + } + if (!Number.isFinite(peak) || peak <= 0) { + throw new Error(`Cannot normalize a field with peak ${peak}`); + } + + return Array.from(magnitudes, (magnitudeVPerM, index) => { + const phiIndex = Math.floor(index / thetaCount); + const thetaIndex = index % thetaCount; + const normalizedDb = Math.max( + floorDb, + 20 * Math.log10(Math.max(magnitudeVPerM / peak, Number.EPSILON)), + ); + return { + thetaDeg: field.thetaDeg[thetaIndex]!, + phiDeg: field.phiDeg[phiIndex]!, + magnitudeVPerM, + normalizedDb, + }; + }); +} +``` + +Label that visualization **normalized field pattern** or **dB relative to +peak**. Field magnitude by itself is not antenna gain or directivity; do not +label it as either unless the application also performs the required power +normalization. + +For a 3D surface, use the package's spherical convention: + +```text +x = r * sin(theta) * cos(phi) +y = r * sin(theta) * sin(phi) +z = r * cos(theta) +``` + +Theta is measured down from +Z. Phi increases in the XY plane from +X toward ++Y. Convert degrees to radians before applying the formulas. Use normalized +linear magnitude, not a negative dB number, as `r`. + +## Low-level worker model + +Use the worker entry point when the visualizer edits arbitrary individual +wires instead of reusable array patterns: + +```ts +import { createNecWorkerModel } from "@necpp-engine/wasm/worker"; + +const model = await createNecWorkerModel({ + onProgress: ({ operation, phase }) => { + console.log(operation, phase); + }, +}); + +try { + await model.addWire({ + tag: 1, + segments: 11, + start: [0, 0, -0.25], + end: [0, 0, 0.25], + radiusM: 0.001, + }); + await model.completeGeometry(); + await model.definePorts([{ tag: 1, segment: 6, name: "feed" }]); + await model.prepare({ frequencyMHz: 300 }); + const solution = await model.solveVoltages({ + real: new Float64Array([1]), + imag: new Float64Array([0]), + }); + console.log(solution); +} finally { + await model.dispose(); +} +``` + +Worker method calls are asynchronous and serialized for each model. If the +user cancels a long low-level worker calculation, call `model.terminate()`; +this immediately kills that worker and rejects outstanding operations. A +terminated model cannot be reused, so create a new one. The higher-level +`NecArraySolver` exposes `dispose()`, but not `terminate()`. + +## Numerical and ordering contract + +These details must be reflected in the UI and its data adapters: + +- Coordinates, wire radii, and field radius are metres. Frequency is MHz. +- A wire tag is a positive integer. Port segment indices are one-based. For an + 11-segment wire, the centre segment is `6`. +- Port definition order determines all vector indices, matrix rows/columns, + power values, and embedded-pattern bases. Preserve a stable app-level ID for + every port and never re-sort result arrays independently. +- Complex matrices are row-major. Entry `(row, column)` is at + `row * matrix.columns + column`. +- `solveVoltages()` drives complex volts. `solveCurrents()` drives complex + amperes and returns the voltages required to achieve them. Current is + positive into the modeled antenna. +- The package uses `V = Z I`, `I = Y V`, `e^(+j omega t)` phasors, and + `e^(-jkR)` outgoing propagation. +- `PortSolution.activeImpedances[i]` is `V[i] / I[i]` for the current + simultaneous excitation. It is not the same quantity as matrix entry + `Z[i,i]`, and is `NaN + jNaN` when achieved current is exactly zero. +- `PortSolution.powersW[i]` is time-average input power + `0.5 * Re(V * conjugate(I))`. An individual coupled port can absorb or + deliver power; inspect the total as well as each port. +- `computeFarField()` requires a successful preceding excitation solve. + `computeEmbeddedFarFields()` can run from the prepared state. +- Fields are far-field approximations referenced to the model origin, even if + a small `radiusM` is requested. + +The engine does not need to be the visualizer's geometry store. Keep the +editable geometry in application state and use the same domain objects both +to render wire segments and to construct a new solver when geometry changes. + +## Lifecycle and UI behavior + +The normal low-level lifecycle is: + +```text +empty -> geometry-building -> geometry-complete -> prepared -> solved + | + +-> new solve/field +``` + +Use these invalidation rules in the app: + +| User change | Engine action | +|---|---| +| Camera, color scale, clipping floor, or plot style | Re-render existing results only. | +| Current/voltage amplitude or phase | Reuse the prepared model and solve again. | +| Angular resolution or requested cut | Reuse the latest solution and compute a new far field. | +| Frequency | Call `prepare()` at the new frequency, then solve again. | +| Geometry, ports, structural loads, or ground | Dispose the old model and construct a new one. | + +Protect the UI from stale asynchronous results. Increment a request generation +when controls change and commit a result only if its generation is still +current. Disable impossible actions based on application state rather than +allowing a `computeFarField()` call before a solve. + +For interactive beam steering, keep the prepared solver alive and call +`solveCurrents()` or `solveVoltages()` for each new weight vector, followed by +`computeFarField()`. This uses NEC's retained factorization and native combined +current state. Measure this direct path before considering any response cache; +do not implement ordinary steering as JavaScript embedded-field +superposition. + +## Performance and memory + +- Factorization cost and memory grow quickly with segment count. Start with + modest odd segment counts and add a convergence workflow rather than + defaulting every model to extreme resolution. +- Keep a prepared model alive. Recreating it for every weight or field-grid + change discards the most expensive reusable work. +- Start with angular cuts. A `181 x 361` combined field stores about 2 MiB + across its four `Float64Array` components. Embedded fields multiply that by + port count and should not be requested by the normal steering path. +- Release references to obsolete field arrays. Each worker model owns a + separate WebAssembly instance and memory, so dispose unused models instead + of accumulating them. +- Large result buffers from the worker API are transferred back to the app. + There is no shared-memory or cross-origin-isolation requirement. + +## Errors and diagnostics + +All package operational errors derive from `NecError` and expose a stable +`code`: + +| Code | UI meaning | +|---|---| +| `NEC_INPUT` | Invalid values, dimensions, units, or request grid. Highlight editable input. | +| `NEC_GEOMETRY` | Invalid/intersecting geometry, load target, or ground compatibility. | +| `NEC_PORT` | Missing, duplicate, or invalid port. | +| `NEC_CONDITIONING` | Port matrix is singular or too ill-conditioned for a reliable result. | +| `NEC_SOLVER` | Native fill, factorization, solve, or field operation failed. | +| `NEC_RUNTIME` | Worker, WebAssembly loading, ABI, allocation, or runtime failure. | +| `NEC_STATE` | The app called an operation in the wrong lifecycle state. This usually indicates an integration bug. | + +Use `error.code` for program flow and show `error.message` for diagnostics; do +not parse message text. Log `packageVersion`, `engineVersion`, and `abiVersion` +with bug reports. Array applications should also log +`solver.getDiagnostics()`, especially symmetry representation, planner reason +codes, and `maxPositionAdjustmentM`. + +## Deployment checks + +Before declaring the integration complete, verify all of the following in a +clean checkout of the visualizer application: + +- `npm ci`, TypeScript checking, tests, and the production bundle succeed with + no path to the NEC2++ source repository. +- Imports use only `@necpp-engine/wasm` or + `@necpp-engine/wasm/worker`; there are no imports from package internals. +- The production browser loads a `.wasm` response successfully with + `application/wasm` content type and the worker API does not block normal UI + interaction. +- A known dipole or array produces finite port values and the expected field + sample count. Reject `NaN` or infinity before plotting. +- Matrix, vector, and field indexing tests use at least two ports and a grid + with both theta and phi counts greater than one, so transposition mistakes + cannot hide. +- Geometry/frequency changes invalidate the correct data, stale async results + cannot overwrite new results, and every model is disposed on replacement + and page/component teardown. +- The UI distinguishes normalized field magnitude from gain/directivity. +- User-facing diagnostics include package/engine version and typed NEC error + codes. + +## Licensing and deck fallback + +`@necpp-engine/wasm` and the shipped `nec2pp.wasm` engine are +**GPL-2.0-or-later**. Serving or distributing a visualizer that contains them +distributes GPL software. Confirm the application's source, notices, license +text, and distribution process satisfy the GPL before shipping; obtain legal +review for a product-specific decision. + +If the typed API cannot yet express a required feature, `runDeck()` can execute +a complete NEC input deck and return its formatted report. Treat that as a +deliberate compatibility boundary, because the application must then create +cards and parse output itself. The authoritative card layout and semantics are +in the +[NEC-2 Part 3 Program Description](https://www.nec2.org/other/nec2prt3.pdf). diff --git a/examples/wasm-array-vite/README.md b/examples/wasm-array-vite/README.md index 917d1007..9888b3be 100644 --- a/examples/wasm-array-vite/README.md +++ b/examples/wasm-array-vite/README.md @@ -13,7 +13,7 @@ port quantities, and plots the combined azimuth far-field cut at 1 m. ## Run with the published package From this directory, run `npm install`, then -`npm install @necpp-engine/wasm@0.2.0`, followed by `npm run dev`. Open the URL +`npm install @necpp-engine/wasm@0.3.0`, followed by `npm run dev`. Open the URL printed by Vite. Use `npm run build` and `npm run preview` to inspect the production bundle. diff --git a/packages/necpp-wasm/README.md b/packages/necpp-wasm/README.md index 1b9cc629..b6eaf430 100644 --- a/packages/necpp-wasm/README.md +++ b/packages/necpp-wasm/README.md @@ -400,6 +400,43 @@ The initial environment is free space with no loads. Call `addLoad()`, `prepare()`. Changing ground or loads later is allowed, but invalidates the factorization and returns the model to `geometry-complete`. +### Ground-connected wires + +`groundConnection` controls the NEC `GE` connection rule; it does not install +a ground model. The default `"none"` is `GE 0`. `"interpolate"` is `GE +1`, +the normal rooted-monopole connection that interpolates current to the image +below the plane. `"zero-current"` is `GE -1` and leaves the current expansion +unchanged, so a wire end touching `z=0` is a zero-current end. + +```ts +import type { FullArrayDescription } from "@necpp-engine/wasm"; + +const rooted: FullArrayDescription = { + elements: [{ id: "monopole", positionM: [0, 0], patternId: "vertical" }], + patterns: [{ + id: "vertical", + kind: "straight-wire-pattern", + wires: [{ + id: "radiator", + segments: 11, + startM: [0, 0, 0], + endM: [0, 0, 0.25], + radiusM: 0.001, + }], + ports: [{ wireId: "radiator", segment: 2 }], + }], + ground: { kind: "perfect" }, + groundConnection: "interpolate", +}; +``` + +A non-none connection requires perfect or finite ground when `prepare()` +runs and cannot be combined with structural reflection through `z=0`. +Segments may end at the plane but may not extend below or lie in it. NEC-2 +cannot accurately model a ground stake through finite ground; a driven base +connection there can make impedance strongly dependent on source-segment +length. Reconstruct an array solver to change `groundConnection`. + ## Z and Y matrices `computeImpedanceMatrix()` factors the electromagnetic interaction matrix once @@ -461,6 +498,30 @@ when array weights change. An exactly zero achieved current produces dividing by zero. Time-average input power is `0.5 * Re(V * conjugate(I))` watts. +Every successful solve also returns a frozen aggregate native balance: + +```ts +import type { PortSolution } from "@necpp-engine/wasm"; + +declare const currentDriven: PortSolution; + +const { + inputPowerW, + radiatedPowerW, + structureLossW, + networkLossW, + efficiencyPercent, +} = currentDriven.powerBudget; +``` + +`inputPowerW` agrees numerically with the sum of simultaneous per-port +`powersW`, including mutual-coupling contributions. NEC defines +`radiatedPowerW = inputPowerW - structureLossW - networkLossW`; +`efficiencyPercent` is `null` only for exact zero input. This is the native +total balance, not power in a selected polarization. Finite lossy ground has +no separately reported ground-loss term, so do not treat this value as an +upper-hemisphere flux identity without separate validation. + ## Complex far fields and beamforming `computeFarField()` uses the most recent public solve. At the default 1 m, @@ -468,6 +529,29 @@ dividing by zero. Time-average input power is the field follows `e^(-jkR) / R` while retaining the same angular far-field approximation. +Normal steering uses one simultaneous solve followed by the native combined +field path. Multiple display or integration grids reuse that solved state: + +```ts +import type { + ComplexVector, + FarFieldRequest, + NecModel, +} from "@necpp-engine/wasm"; + +declare const model: NecModel; +declare const currents: ComplexVector; +declare const displayRequest: FarFieldRequest; +declare const integrationRequest: FarFieldRequest; + +await model.prepare({ frequencyMHz: 300 }); +const solution = model.solveCurrents(currents); +const displayField = model.computeFarField(displayRequest); +const integrationField = model.computeFarField(integrationRequest); +``` + +Do not superpose embedded fields in JavaScript for this normal path. + `computeEmbeddedFarFields()` returns one complex basis pattern per port. Unit-current normalization makes array beamforming a direct weighted sum. The arrays are basis-major, followed by the normal theta-fast sample layout. @@ -598,7 +682,7 @@ appropriate CORS header. import { createNecModel } from "@necpp-engine/wasm"; const model = await createNecModel({ - wasmUrl: new URL("https://cdn.example.test/necpp/0.2.0/nec2pp.wasm"), + wasmUrl: new URL("https://cdn.example.test/necpp/0.3.0/nec2pp.wasm"), }); model.dispose(); ``` diff --git a/packages/necpp-wasm/package-lock.json b/packages/necpp-wasm/package-lock.json index 935fc11e..4aa83177 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.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@necpp-engine/wasm", - "version": "0.2.0", + "version": "0.3.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 52f89fff..d8399c3e 100644 --- a/packages/necpp-wasm/package.json +++ b/packages/necpp-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@necpp-engine/wasm", - "version": "0.2.0", + "version": "0.3.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 34b31479..afd646a9 100644 --- a/packages/necpp-wasm/src/array-solver.ts +++ b/packages/necpp-wasm/src/array-solver.ts @@ -158,7 +158,9 @@ async function applyExplicit( description.elements[index]!.rotationDeg ?? 0, ); } - const completion = await invoke(model.completeGeometry({ groundConnection: "none" })); + const completion = await invoke(model.completeGeometry({ + groundConnection: description.groundConnection ?? "none", + })); await invoke(model.definePorts(caller.ports)); for (const allocation of caller.allocations) { for (const load of allocation.pattern.loads ?? []) { @@ -196,7 +198,7 @@ async function applySymmetric( await addElementWires(model, pattern, element.positionM, wireTags); } const completion = await invoke(model.completeGeometry({ - groundConnection: "none", + groundConnection: description.groundConnection ?? "none", symmetry: plan.symmetry, })); const nativePorts: PortDefinition[] = []; @@ -509,6 +511,7 @@ class WorkerNecArraySolver implements NecArraySolver { currents: gatherComplexVector(result.currents, scatter), activeImpedances: gatherComplexVector(result.activeImpedances, scatter), powersW, + powerBudget: result.powerBudget, factorizationGeneration: result.factorizationGeneration, solveGeneration: result.solveGeneration, }; diff --git a/packages/necpp-wasm/src/array-symmetry.ts b/packages/necpp-wasm/src/array-symmetry.ts index 22979704..c0704cea 100644 --- a/packages/necpp-wasm/src/array-symmetry.ts +++ b/packages/necpp-wasm/src/array-symmetry.ts @@ -94,12 +94,26 @@ function reason( } function validateGround(description: FullArrayDescription): void { + const connection = description.groundConnection ?? "none"; + if (connection !== "none" + && connection !== "interpolate" + && connection !== "zero-current") { + inputError("description.groundConnection is unknown", { connection }); + } const ground = description.ground as unknown; if (typeof ground !== "object" || ground === null || Array.isArray(ground)) { inputError("description.ground must be an object"); } const record = ground as Readonly>; - if (record.kind === "free-space" || record.kind === "perfect") { + if (record.kind === "free-space") { + if (connection !== "none") { + inputError("A non-none groundConnection requires a ground model", { + connection, + }); + } + return; + } + if (record.kind === "perfect") { return; } if (record.kind !== "finite") { diff --git a/packages/necpp-wasm/src/index.ts b/packages/necpp-wasm/src/index.ts index 70795589..1ac5e891 100644 --- a/packages/necpp-wasm/src/index.ts +++ b/packages/necpp-wasm/src/index.ts @@ -70,6 +70,7 @@ export type { PerfectGround, PortDefinition, PortSolution, + PowerBudget, PositionCanonicalization, PositionedArrayElement, PrepareOptions, diff --git a/packages/necpp-wasm/src/model.ts b/packages/necpp-wasm/src/model.ts index 1170971c..d8445802 100644 --- a/packages/necpp-wasm/src/model.ts +++ b/packages/necpp-wasm/src/model.ts @@ -907,6 +907,27 @@ export class WasmNecModel implements NecModel { real: this.#copyBuffer(realKind, count), imag: this.#copyBuffer(imagKind, count), }); + const inputPowerW = + this.#module._necpp_wasm_v1_solution_input_power_w(this.#handle); + const radiatedPowerW = + this.#module._necpp_wasm_v1_solution_radiated_power_w(this.#handle); + const structureLossW = + this.#module._necpp_wasm_v1_solution_structure_loss_w(this.#handle); + const networkLossW = + this.#module._necpp_wasm_v1_solution_network_loss_w(this.#handle); + if (![inputPowerW, radiatedPowerW, structureLossW, networkLossW] + .every(Number.isFinite)) { + throw new NecRuntimeError("The native port solution has a nonfinite power budget"); + } + const powerBudget = Object.freeze({ + inputPowerW, + radiatedPowerW, + structureLossW, + networkLossW, + efficiencyPercent: inputPowerW === 0 + ? null + : 100 * radiatedPowerW / inputPowerW, + }); return { drive, frequencyMHz: @@ -929,6 +950,7 @@ export class WasmNecModel implements NecModel { BUFFER.solutionActiveImpedancesImag, ), powersW: this.#copyBuffer(BUFFER.solutionPowersW, count), + powerBudget, factorizationGeneration: this.#module._necpp_wasm_v1_solution_factorization_generation( this.#handle, diff --git a/packages/necpp-wasm/src/types.ts b/packages/necpp-wasm/src/types.ts index 67ddca53..75f1d8ae 100644 --- a/packages/necpp-wasm/src/types.ts +++ b/packages/necpp-wasm/src/types.ts @@ -239,6 +239,19 @@ export interface ImpedanceResult { readonly factorizationGeneration: number; } +export interface PowerBudget { + /** Total time-average power supplied by all voltage sources. */ + readonly inputPowerW: number; + /** Native NEC balance: inputPowerW - structureLossW - networkLossW. */ + readonly radiatedPowerW: number; + /** Ohmic/dissipative power in structure loads and wire conductivity. */ + readonly structureLossW: number; + /** Net power absorbed by non-radiating networks and transmission lines. */ + readonly networkLossW: number; + /** 100 * radiatedPowerW / inputPowerW; null for exact zero input. */ + readonly efficiencyPercent: number | null; +} + export interface PortSolution { readonly drive: "voltage" | "current"; readonly frequencyMHz: number; @@ -253,6 +266,8 @@ export interface PortSolution { readonly activeImpedances: ComplexVector; /** Time-average input power: 0.5 * Re(V * conjugate(I)), in watts. */ readonly powersW: Float64Array; + /** Aggregate native NEC power balance for this simultaneous solution. */ + readonly powerBudget: PowerBudget; readonly factorizationGeneration: number; readonly solveGeneration: number; } @@ -456,6 +471,8 @@ export interface FullArrayDescription { readonly elements: readonly PositionedArrayElement[]; readonly patterns: readonly ElementWirePattern[]; readonly ground: GroundModel; + /** Defaults to `"none"`; corresponds to NEC GE 0, +1, or -1. */ + readonly groundConnection?: GroundConnection; } export interface CanonicalArrayElement { diff --git a/packages/necpp-wasm/src/versions.ts b/packages/necpp-wasm/src/versions.ts index fbe71285..9523f31c 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.2.0"; +export const packageVersion = "0.3.0"; export const abiVersion = 1; -export const engineVersion = "2.4.0"; +export const engineVersion = "2.5.0"; diff --git a/packages/necpp-wasm/src/wasm-internal.ts b/packages/necpp-wasm/src/wasm-internal.ts index 165cb3cb..bcadedb8 100644 --- a/packages/necpp-wasm/src/wasm-internal.ts +++ b/packages/necpp-wasm/src/wasm-internal.ts @@ -105,6 +105,10 @@ export interface NecWasmModule { _necpp_wasm_v1_solution_frequency_mhz(model: number): number; _necpp_wasm_v1_solution_factorization_generation(model: number): number; _necpp_wasm_v1_solution_generation(model: number): number; + _necpp_wasm_v1_solution_input_power_w(model: number): number; + _necpp_wasm_v1_solution_radiated_power_w(model: number): number; + _necpp_wasm_v1_solution_structure_loss_w(model: number): number; + _necpp_wasm_v1_solution_network_loss_w(model: number): number; _necpp_wasm_v1_far_field_radius_m(model: number): number; _necpp_wasm_v1_far_field_frequency_mhz(model: number): number; diff --git a/packages/necpp-wasm/src/worker-protocol.ts b/packages/necpp-wasm/src/worker-protocol.ts index dda18512..f858b932 100644 --- a/packages/necpp-wasm/src/worker-protocol.ts +++ b/packages/necpp-wasm/src/worker-protocol.ts @@ -364,8 +364,38 @@ function copyFloat64(value: unknown, name: string): Float64Array { return value as Float64Array; } +function finiteWorkerNumber(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new NecRuntimeError(`Worker result ${name} is not finite`); + } + return value; +} + export function revivePortSolution(value: unknown): PortSolution { const record = value as PortSolution; + const inputPowerW = finiteWorkerNumber( + record.powerBudget?.inputPowerW, + "powerBudget.inputPowerW", + ); + const radiatedPowerW = finiteWorkerNumber( + record.powerBudget?.radiatedPowerW, + "powerBudget.radiatedPowerW", + ); + const structureLossW = finiteWorkerNumber( + record.powerBudget?.structureLossW, + "powerBudget.structureLossW", + ); + const networkLossW = finiteWorkerNumber( + record.powerBudget?.networkLossW, + "powerBudget.networkLossW", + ); + const efficiencyPercent = record.powerBudget?.efficiencyPercent; + if (efficiencyPercent !== null + && (typeof efficiencyPercent !== "number" || !Number.isFinite(efficiencyPercent))) { + throw new NecRuntimeError( + "Worker result powerBudget.efficiencyPercent is neither finite nor null", + ); + } return { drive: record.drive, frequencyMHz: record.frequencyMHz, @@ -387,6 +417,13 @@ export function revivePortSolution(value: unknown): PortSolution { imag: copyFloat64(record.activeImpedances.imag, "activeImpedances.imag"), }, powersW: copyFloat64(record.powersW, "powersW"), + powerBudget: Object.freeze({ + inputPowerW, + radiatedPowerW, + structureLossW, + networkLossW, + efficiencyPercent, + }), factorizationGeneration: record.factorizationGeneration, solveGeneration: record.solveGeneration, }; diff --git a/packages/necpp-wasm/test-d/public-api.test.ts b/packages/necpp-wasm/test-d/public-api.test.ts index 53b8ca5f..4e09d22c 100644 --- a/packages/necpp-wasm/test-d/public-api.test.ts +++ b/packages/necpp-wasm/test-d/public-api.test.ts @@ -16,6 +16,7 @@ import { type FarFieldResult, type FullArrayDescription, type PortSolution, + type PowerBudget, } from "../src/index.js"; const typedArrayDescription: FullArrayDescription = { @@ -36,6 +37,7 @@ const typedArrayDescription: FullArrayDescription = { ports: [{ wireId: "wire", segment: 6 }], }], ground: { kind: "perfect" }, + groundConnection: "zero-current", }; analyzeArraySymmetry(typedArrayDescription, { positionEpsilonM: 0 }); @@ -109,6 +111,8 @@ async function validConsumer(): Promise { matrices.real[0]; solution.currents.imag[0]; + const budget: PowerBudget = solution.powerBudget; + budget.efficiencyPercent satisfies number | null; field.eThetaReal[0]; model.dispose(); diff --git a/packages/necpp-wasm/test-d/worker-api.test.ts b/packages/necpp-wasm/test-d/worker-api.test.ts index be2a67fa..0f63fe9a 100644 --- a/packages/necpp-wasm/test-d/worker-api.test.ts +++ b/packages/necpp-wasm/test-d/worker-api.test.ts @@ -45,6 +45,8 @@ async function validWorkerConsumer(): Promise { matrices.real[0]; solution.currents.imag[0]; + solution.powerBudget.inputPowerW satisfies number; + solution.powerBudget.efficiencyPercent satisfies number | null; field.eThetaReal[0]; events[0]?.operation; diff --git a/packages/necpp-wasm/test/array-solver.integration.test.mjs b/packages/necpp-wasm/test/array-solver.integration.test.mjs index c13b8fe9..f5bda403 100644 --- a/packages/necpp-wasm/test/array-solver.integration.test.mjs +++ b/packages/necpp-wasm/test/array-solver.integration.test.mjs @@ -49,6 +49,29 @@ function relativeError(left, right) { return Math.sqrt(delta) / Math.max(1, Math.sqrt(scale)); } +function assertPowerBudgetClose(left, right, tolerance = 1e-10) { + for (const field of [ + "inputPowerW", + "radiatedPowerW", + "structureLossW", + "networkLossW", + ]) { + const scale = Math.max(1, Math.abs(left[field]), Math.abs(right[field])); + assert.ok( + Math.abs(left[field] - right[field]) <= tolerance * scale, + `power budget ${field}`, + ); + } + if (left.efficiencyPercent === null || right.efficiencyPercent === null) { + assert.equal(left.efficiencyPercent, right.efficiencyPercent); + } else { + assert.ok( + Math.abs(left.efficiencyPercent - right.efficiencyPercent) <= tolerance, + "power budget efficiencyPercent", + ); + } +} + async function exerciseUnbranched(description, fixture, symmetry) { const solver = await createNecArraySolver(description, symmetry === "off" ? { symmetry } @@ -113,6 +136,11 @@ test("one unbranched facade exposes identical ordinary result shapes", { ); } assert.deepEqual(explicit.solution.ports, symmetric.solution.ports); + assertPowerBudgetClose(explicit.solution.powerBudget, symmetric.solution.powerBudget); + assert.ok(Math.abs( + explicit.solution.powerBudget.inputPowerW + - explicit.solution.powersW.reduce((sum, value) => sum + value, 0), + ) <= 1e-10); assert.deepEqual(explicit.embedded.ports, symmetric.embedded.ports); assert.deepEqual(symmetric.solution.ports.map((port) => port.tag), [1, 2, 3, 4]); for (const result of [symmetric.matrices, symmetric.solution, symmetric.field, symmetric.embedded]) { @@ -122,6 +150,33 @@ test("one unbranched facade exposes identical ordinary result shapes", { } }); +test("rooted arrays preserve both signed connections through explicit and symmetric builds", { + skip: !hasWasm && "WASM artifacts have not been built", +}, async () => { + const { description, fixture } = arrayDescription(); + for (const groundConnection of ["interpolate", "zero-current"]) { + const rooted = structuredClone(description); + rooted.groundConnection = groundConnection; + rooted.patterns[0].wires[0].startM[2] = 0; + rooted.patterns[0].ports[0].segment = 2; + const explicit = await exerciseUnbranched(rooted, fixture, "off"); + const symmetric = await exerciseUnbranched(rooted, fixture, "auto"); + assertPowerBudgetClose(explicit.solution.powerBudget, symmetric.solution.powerBudget); + assert.ok(relativeError( + explicit.matrices.impedance.real, + symmetric.matrices.impedance.real, + ) <= 1e-8); + assert.ok(relativeError( + explicit.solution.currents.real, + symmetric.solution.currents.real, + ) <= 1e-8); + assert.ok(relativeError( + explicit.field.eThetaReal, + symmetric.field.eThetaReal, + ) <= 1e-8); + } +}); + test("off-origin explicit and centered symmetric complex fields prove the phase sign", { skip: !hasWasm && "WASM artifacts have not been built", }, async () => { diff --git a/packages/necpp-wasm/test/array-symmetry.test.mjs b/packages/necpp-wasm/test/array-symmetry.test.mjs index 35a0b623..199fca02 100644 --- a/packages/necpp-wasm/test/array-symmetry.test.mjs +++ b/packages/necpp-wasm/test/array-symmetry.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { NecGeometryError, + NecInputError, analyzeArraySymmetry, applyArrayBuildPlan, gatherComplexMatrix, @@ -109,6 +110,61 @@ test("a one-element full description remains a valid explicit fallback", () => { assert.equal(plan.elements.length, 1); }); +test("array ground connections validate early and reach both builders", async () => { + const { description } = arrayDescription({ side: 2 }); + assert.throws( + () => analyzeArraySymmetry({ + ...description, + ground: { kind: "free-space" }, + groundConnection: "interpolate", + }, { positionEpsilonM: 0 }), + NecInputError, + ); + assert.throws( + () => analyzeArraySymmetry({ + ...description, + groundConnection: "unknown", + }, { positionEpsilonM: 0 }), + NecInputError, + ); + + for (const [groundConnection, ground] of [ + ["interpolate", { kind: "perfect" }], + ["zero-current", { + kind: "finite", + method: "reflection-coefficient", + relativePermittivity: 13, + conductivitySPerM: 0.005, + }], + ]) { + const candidate = { ...description, ground, groundConnection }; + const plans = [ + analyzeArraySymmetry(candidate, { positionEpsilonM: 0 }), + analyzeArraySymmetry({ + ...candidate, + elements: [candidate.elements[0]], + }, { positionEpsilonM: 0 }), + ]; + for (const plan of plans) { + const completions = []; + const grounds = []; + const model = { + addWire() {}, + completeGeometry(options) { completions.push(options); return {}; }, + definePorts() {}, + addLoad() {}, + setGround(value) { grounds.push(value); }, + }; + const appliedDescription = plan.kind === "explicit" && plan.elements.length === 1 + ? { ...candidate, elements: [candidate.elements[0]] } + : candidate; + await applyArrayBuildPlan(model, appliedDescription, plan); + assert.equal(completions[0].groundConnection, groundConnection); + assert.deepEqual(grounds[0], ground); + } + } +}); + test("input permutations retain canonical geometry and ID-based native mappings", () => { const { description } = arrayDescription(); const original = analyzeArraySymmetry(description, { positionEpsilonM: 0 }); diff --git a/packages/necpp-wasm/test/browser-integration.mjs b/packages/necpp-wasm/test/browser-integration.mjs index 7d0b36b7..674e8912 100644 --- a/packages/necpp-wasm/test/browser-integration.mjs +++ b/packages/necpp-wasm/test/browser-integration.mjs @@ -194,7 +194,7 @@ try { ${awaitPrefix}model.definePorts(ports); ${awaitPrefix}model.prepare({ frequencyMHz }); const matrices = ${awaitPrefix}model.computeImpedanceMatrix(); - ${awaitPrefix}model.solveVoltages({ + const solution = ${awaitPrefix}model.solveVoltages({ real: new Float64Array(ports.length).fill(1), imag: new Float64Array(ports.length), }); @@ -211,6 +211,7 @@ try { fieldSamples: field.eThetaReal.length, fieldFinite: [...field.eThetaReal, ...field.eThetaImag, ...field.ePhiReal, ...field.ePhiImag].every(Number.isFinite), + powerBudget: solution.powerBudget, sectionCount: completion.symmetry?.sectionCount, mode: ${JSON.stringify(mode)}, }; @@ -272,10 +273,14 @@ try { } else { assert.equal(result.mode, mode); assert.equal(result.abiVersion, 1); - assert.equal(result.engineVersion, "2.4.0"); + assert.equal(result.engineVersion, "2.5.0"); assert.ok(result.resistanceOhm > 0); assert.equal(result.fieldSamples, 3); assert.equal(result.fieldFinite, true); + assert.ok(result.powerBudget.inputPowerW > 0); + assert.ok(Math.abs( + result.powerBudget.inputPowerW - result.powerBudget.radiatedPowerW, + ) < 1e-10); assert.equal(result.sectionCount, 4); } assert.ok(wasmResponses.length >= 1, "the browser must request the emitted WASM asset"); diff --git a/packages/necpp-wasm/test/facade-runtime.test.mjs b/packages/necpp-wasm/test/facade-runtime.test.mjs index 52ee826d..d6642edf 100644 --- a/packages/necpp-wasm/test/facade-runtime.test.mjs +++ b/packages/necpp-wasm/test/facade-runtime.test.mjs @@ -54,8 +54,8 @@ function addDipole(model) { } test("package, engine, and ABI versions are exported", () => { - assert.equal(packageVersion, "0.2.0"); - assert.equal(engineVersion, "2.4.0"); + assert.equal(packageVersion, "0.3.0"); + assert.equal(engineVersion, "2.5.0"); assert.equal(abiVersion, 1); }); @@ -111,6 +111,14 @@ test("the facade performs a complete stateful solve with owned results", { assert.equal(model.state, "solved"); assert.equal(first.drive, "voltage"); assert.equal(first.ports[0].name, "feed"); + assert.equal(Object.isFrozen(first.powerBudget), true); + assert.ok(Number.isFinite(first.powerBudget.inputPowerW)); + assert.ok(Math.abs(first.powerBudget.inputPowerW - first.powersW[0]) <= 1e-12); + assert.equal(first.powerBudget.structureLossW, 0); + assert.equal(first.powerBudget.networkLossW, 0); + assert.ok(Math.abs( + first.powerBudget.inputPowerW - first.powerBudget.radiatedPowerW, + ) <= 1e-12); const retainedCurrent = [ first.currents.real[0], first.currents.imag[0], @@ -163,6 +171,18 @@ test("the facade performs a complete stateful solve with owned results", { assert.equal(currentEmbedded.normalization.kind, "unit-current"); assert.equal(currentEmbedded.eThetaReal.length, 1); + const zero = model.solveCurrents({ + real: new Float64Array([0]), + imag: new Float64Array([0]), + }); + assert.deepEqual(zero.powerBudget, { + inputPowerW: 0, + radiatedPowerW: 0, + structureLossW: 0, + networkLossW: 0, + efficiencyPercent: null, + }); + assert.throws( () => model.solveVoltages({ real: new Float64Array(0), diff --git a/packages/necpp-wasm/test/pack/consumer.test.mjs b/packages/necpp-wasm/test/pack/consumer.test.mjs index 7da9c772..d94d2f3d 100644 --- a/packages/necpp-wasm/test/pack/consumer.test.mjs +++ b/packages/necpp-wasm/test/pack/consumer.test.mjs @@ -124,10 +124,18 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { stdio: ["ignore", "pipe", "inherit"], }).stdout); assert.equal(direct.packageVersion, packageJson.version); - assert.equal(direct.engineVersion, "2.4.0"); + assert.equal(direct.engineVersion, "2.5.0"); assert.equal(direct.abiVersion, 1); assert.equal(direct.sectionCount, 4); assert.ok(direct.resistanceOhm > 0); + assert.ok(direct.powerBudget.inputPowerW > 0); + assert.equal(direct.combinedFieldSamples, 1); + assert.ok(direct.rootedInputPowers.interpolate > 0); + assert.ok(direct.rootedInputPowers["zero-current"] > 0); + assert.notEqual( + direct.rootedInputPowers.interpolate, + direct.rootedInputPowers["zero-current"], + ); assert.match(direct.resolved.replaceAll("\\", "/"), /node_modules\/@necpp-engine\/wasm/); assert.doesNotMatch(direct.resolved, /packages[/\\]necpp-wasm[/\\]src[/\\]/); @@ -138,6 +146,10 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { "_necpp_wasm_v1_geometry_section_count", "_necpp_wasm_v1_geometry_fundamental_segment_count", "_necpp_wasm_v1_geometry_full_segment_count", + "_necpp_wasm_v1_solution_input_power_w", + "_necpp_wasm_v1_solution_radiated_power_w", + "_necpp_wasm_v1_solution_structure_loss_w", + "_necpp_wasm_v1_solution_network_loss_w", ]) { assert.ok(packedLoader.includes(symbol), `packed loader is missing ${symbol}`); } @@ -151,6 +163,8 @@ test("a clean Node fixture imports the tarball by name and solves a dipole", { assert.equal(worker.packageVersion, packageJson.version); assert.equal(worker.sectionCount, 4); assert.ok(Math.abs(worker.resistanceOhm - direct.resistanceOhm) < 1e-9); + assert.deepEqual(worker.powerBudget, direct.powerBudget); + assert.equal(worker.combinedFieldSamples, 1); for (const [name, mode] of [ ["manual-direct.mjs", "direct"], diff --git a/packages/necpp-wasm/test/pack/helpers.mjs b/packages/necpp-wasm/test/pack/helpers.mjs index 9fe00a21..f69347b4 100644 --- a/packages/necpp-wasm/test/pack/helpers.mjs +++ b/packages/necpp-wasm/test/pack/helpers.mjs @@ -173,6 +173,7 @@ export function installFixture(root, extraPackages = []) { export const dipoleScript = `import { abiVersion, + createNecArraySolver, createNecModel, engineVersion, packageVersion, @@ -211,12 +212,54 @@ try { if (!(matrices.impedance.real[0] > 0)) { throw new Error("expected a positive feed resistance"); } + const solution = model.solveVoltages({ + real: new Float64Array(4).fill(1), + imag: new Float64Array(4), + }); + const field = model.computeFarField({ + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); + const rootedInputPowers = {}; + for (const groundConnection of ["interpolate", "zero-current"]) { + const rooted = await createNecArraySolver({ + elements: [{ id: "m0", positionM: [0, 0], patternId: "monopole" }], + patterns: [{ + id: "monopole", + kind: "straight-wire-pattern", + wires: [{ + id: "wire", + segments: 11, + startM: [0, 0, 0], + endM: [0, 0, 0.25], + radiusM: 0.001, + }], + ports: [{ wireId: "wire", segment: 2 }], + }], + ground: { kind: "perfect" }, + groundConnection, + }, { symmetry: "off" }); + try { + await rooted.prepare({ frequencyMHz: 300 }); + const rootedSolution = await rooted.solveVoltages({ + real: Float64Array.of(1), + imag: Float64Array.of(0), + }); + rootedInputPowers[groundConnection] = + rootedSolution.powerBudget.inputPowerW; + } finally { + await rooted.dispose(); + } + } process.stdout.write(JSON.stringify({ abiVersion, engineVersion, packageVersion, sectionCount: completion.symmetry?.sectionCount, resistanceOhm: matrices.impedance.real[0], + powerBudget: solution.powerBudget, + combinedFieldSamples: field.eThetaReal.length, + rootedInputPowers, resolved, })); } finally { @@ -258,12 +301,22 @@ try { if (!(matrices.impedance.real[0] > 0)) { throw new Error("expected a positive feed resistance"); } + const solution = await model.solveVoltages({ + real: new Float64Array(4).fill(1), + imag: new Float64Array(4), + }); + const field = await model.computeFarField({ + theta: { startDeg: 90, count: 1, stepDeg: 0 }, + phi: { startDeg: 0, count: 1, stepDeg: 0 }, + }); process.stdout.write(JSON.stringify({ abiVersion, engineVersion, packageVersion, sectionCount: completion.symmetry?.sectionCount, resistanceOhm: matrices.impedance.real[0], + powerBudget: solution.powerBudget, + combinedFieldSamples: field.eThetaReal.length, resolved, })); } finally { diff --git a/packages/necpp-wasm/test/pack/manifest.test.mjs b/packages/necpp-wasm/test/pack/manifest.test.mjs index bb6c2dd1..d1a0974f 100644 --- a/packages/necpp-wasm/test/pack/manifest.test.mjs +++ b/packages/necpp-wasm/test/pack/manifest.test.mjs @@ -17,7 +17,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.2.0"); + assert.equal(packageJson.version, "0.3.0"); assert.equal(packageJson.engines.node, ">=24"); assert.deepEqual(packageJson.publishConfig, { access: "public", diff --git a/packages/necpp-wasm/test/symmetry-equivalence.test.mjs b/packages/necpp-wasm/test/symmetry-equivalence.test.mjs index 3b52847e..1bbf2ebf 100644 --- a/packages/necpp-wasm/test/symmetry-equivalence.test.mjs +++ b/packages/necpp-wasm/test/symmetry-equivalence.test.mjs @@ -319,6 +319,18 @@ function compareSolutions(candidate, baseline, tolerance, label) { assertComplexClose(candidate.activeImpedances, baseline.activeImpedances, tolerance, `${label} active impedances`); assertRealClose(candidate.powersW, baseline.powersW, tolerance, `${label} powers`); + for (const field of [ + "inputPowerW", + "radiatedPowerW", + "structureLossW", + "networkLossW", + "efficiencyPercent", + ]) { + assert.ok( + Math.abs(candidate.powerBudget[field] - baseline.powerBudget[field]) <= tolerance, + `${label} power budget ${field}`, + ); + } } function fieldComponents(field) { diff --git a/packages/necpp-wasm/test/worker-client.test.mjs b/packages/necpp-wasm/test/worker-client.test.mjs index ad40f0e1..43c4a678 100644 --- a/packages/necpp-wasm/test/worker-client.test.mjs +++ b/packages/necpp-wasm/test/worker-client.test.mjs @@ -85,6 +85,13 @@ function createFakeModel(overrides = {}) { imag: new Float64Array([42.5]), }, powersW: new Float64Array([0.005]), + powerBudget: Object.freeze({ + inputPowerW: 0.005, + radiatedPowerW: 0.004, + structureLossW: 0.001, + networkLossW: 0, + efficiencyPercent: 80, + }), factorizationGeneration: 1, solveGeneration: 1, }; @@ -287,6 +294,14 @@ test("worker client serializes operations, reports progress, and transfers field const solution = await model.solveVoltages(voltages); assert.equal(voltages.real.buffer.byteLength, 8); assert.equal(solution.ports[0].name, "feed"); + assert.deepEqual(solution.powerBudget, { + inputPowerW: 0.005, + radiatedPowerW: 0.004, + structureLossW: 0.001, + networkLossW: 0, + efficiencyPercent: 80, + }); + assert.equal(Object.isFrozen(solution.powerBudget), true); assert.throws(() => { solution.ports[0].name = "mutated"; }, TypeError); diff --git a/packages/necpp-wasm/test/worker-integration.test.mjs b/packages/necpp-wasm/test/worker-integration.test.mjs index 2480329f..b4ff2dd9 100644 --- a/packages/necpp-wasm/test/worker-integration.test.mjs +++ b/packages/necpp-wasm/test/worker-integration.test.mjs @@ -104,8 +104,10 @@ test("worker Z matrices and fields match direct-mode results", { real: new Float64Array([1]), imag: new Float64Array([0]), }; - direct.solveVoltages(voltages); - await worker.solveVoltages(voltages); + const directSolution = direct.solveVoltages(voltages); + const workerSolution = await worker.solveVoltages(voltages); + assert.deepEqual(workerSolution.powerBudget, directSolution.powerBudget); + assert.equal(Object.isFrozen(workerSolution.powerBudget), true); const directField = direct.computeFarField(farFieldRequest); const workerField = await worker.computeFarField(farFieldRequest); diff --git a/packages/necpp-wasm/test/worker-protocol.test.mjs b/packages/necpp-wasm/test/worker-protocol.test.mjs index b6b4cb0e..6d02a80f 100644 --- a/packages/necpp-wasm/test/worker-protocol.test.mjs +++ b/packages/necpp-wasm/test/worker-protocol.test.mjs @@ -10,10 +10,27 @@ import { import { collectTransferables, reviveError, + revivePortSolution, serializeCreateOptions, serializeError, } from "../.test-build/src/worker-protocol.js"; +function portSolution(powerBudget) { + return { + drive: "voltage", + frequencyMHz: 300, + ports: [{ tag: 1, segment: 6 }], + requested: { real: Float64Array.of(1), imag: Float64Array.of(0) }, + voltages: { real: Float64Array.of(1), imag: Float64Array.of(0) }, + currents: { real: Float64Array.of(0.01), imag: Float64Array.of(0) }, + activeImpedances: { real: Float64Array.of(100), imag: Float64Array.of(0) }, + powersW: Float64Array.of(0.005), + powerBudget, + factorizationGeneration: 1, + solveGeneration: 1, + }; +} + test("collectTransferables gathers unique typed-array buffers", () => { const real = new Float64Array([1, 2, 3]); const imag = new Float64Array([4, 5, 6]); @@ -54,6 +71,45 @@ test("transferred result buffers are detached rather than duplicated", async () assert.equal(field.eThetaImag.buffer.byteLength, 0); }); +test("worker port solutions validate and freeze the complete power budget", () => { + const budget = { + inputPowerW: 0.005, + radiatedPowerW: 0.004, + structureLossW: 0.001, + networkLossW: 0, + efficiencyPercent: 80, + }; + const revived = revivePortSolution(portSolution(budget)); + assert.deepEqual(revived.powerBudget, budget); + assert.equal(Object.isFrozen(revived.powerBudget), true); + + for (const field of [ + "inputPowerW", + "radiatedPowerW", + "structureLossW", + "networkLossW", + ]) { + assert.throws( + () => revivePortSolution(portSolution({ ...budget, [field]: Number.NaN })), + NecRuntimeError, + ); + } + assert.throws( + () => revivePortSolution(portSolution({ ...budget, efficiencyPercent: Infinity })), + NecRuntimeError, + ); + assert.equal( + revivePortSolution(portSolution({ + inputPowerW: 0, + radiatedPowerW: 0, + structureLossW: 0, + networkLossW: 0, + efficiencyPercent: null, + })).powerBudget.efficiencyPercent, + null, + ); +}); + test("typed errors round-trip through the worker protocol", () => { const state = reviveError(serializeError( new NecStateError("prepare", "empty"), diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3e43706c..aadf46e4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,6 +43,7 @@ set(NECPP_PUBLIC_HEADERS nec_structure_currents.h necpp_wasm_v1.h nec_output.h + nec_power_budget.h nec_exception.h c_evlcom.h c_ggrid.h @@ -160,7 +161,7 @@ if(NECPP_BUILD_WASM) target_link_options(nec2pp_wasm PRIVATE -fexceptions -sWASM=1 - "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_complete_geometry_symmetric\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_geometry_symmetry_kind\",\"_necpp_wasm_v1_geometry_section_count\",\"_necpp_wasm_v1_geometry_fundamental_segment_count\",\"_necpp_wasm_v1_geometry_full_segment_count\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" + "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\",\"_necpp_wasm_v1_abi_version\",\"_necpp_wasm_v1_engine_version\",\"_necpp_wasm_v1_model_create\",\"_necpp_wasm_v1_model_delete\",\"_necpp_wasm_v1_model_state\",\"_necpp_wasm_v1_last_status\",\"_necpp_wasm_v1_last_error\",\"_necpp_wasm_v1_add_wire\",\"_necpp_wasm_v1_complete_geometry\",\"_necpp_wasm_v1_complete_geometry_symmetric\",\"_necpp_wasm_v1_define_ports\",\"_necpp_wasm_v1_add_load\",\"_necpp_wasm_v1_clear_loads\",\"_necpp_wasm_v1_set_ground\",\"_necpp_wasm_v1_prepare\",\"_necpp_wasm_v1_compute_impedance\",\"_necpp_wasm_v1_solve_voltages\",\"_necpp_wasm_v1_solve_currents\",\"_necpp_wasm_v1_compute_far_field\",\"_necpp_wasm_v1_compute_embedded_far_fields\",\"_necpp_wasm_v1_port_count\",\"_necpp_wasm_v1_port_tags\",\"_necpp_wasm_v1_port_segments\",\"_necpp_wasm_v1_geometry_symmetry_kind\",\"_necpp_wasm_v1_geometry_section_count\",\"_necpp_wasm_v1_geometry_fundamental_segment_count\",\"_necpp_wasm_v1_geometry_full_segment_count\",\"_necpp_wasm_v1_impedance_order\",\"_necpp_wasm_v1_impedance_frequency_mhz\",\"_necpp_wasm_v1_impedance_condition_estimate\",\"_necpp_wasm_v1_impedance_factorization_generation\",\"_necpp_wasm_v1_solution_count\",\"_necpp_wasm_v1_solution_drive\",\"_necpp_wasm_v1_solution_frequency_mhz\",\"_necpp_wasm_v1_solution_factorization_generation\",\"_necpp_wasm_v1_solution_generation\",\"_necpp_wasm_v1_solution_input_power_w\",\"_necpp_wasm_v1_solution_radiated_power_w\",\"_necpp_wasm_v1_solution_structure_loss_w\",\"_necpp_wasm_v1_solution_network_loss_w\",\"_necpp_wasm_v1_far_field_radius_m\",\"_necpp_wasm_v1_far_field_frequency_mhz\",\"_necpp_wasm_v1_far_field_theta_count\",\"_necpp_wasm_v1_far_field_phi_count\",\"_necpp_wasm_v1_embedded_radius_m\",\"_necpp_wasm_v1_embedded_frequency_mhz\",\"_necpp_wasm_v1_embedded_theta_count\",\"_necpp_wasm_v1_embedded_phi_count\",\"_necpp_wasm_v1_embedded_port_count\",\"_necpp_wasm_v1_embedded_samples_per_port\",\"_necpp_wasm_v1_embedded_normalization\",\"_necpp_wasm_v1_result_buffer\",\"_necpp_wasm_v1_result_buffer_length\",\"_necpp_wasm_v1_deck_create\",\"_necpp_wasm_v1_deck_delete\",\"_necpp_wasm_v1_deck_process\",\"_necpp_wasm_v1_deck_last_status\",\"_necpp_wasm_v1_deck_last_error\",\"_necpp_wasm_v1_deck_output\",\"_necpp_wasm_v1_deck_output_length\"]" -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 "-sSTACK_SIZE=${NECPP_WASM_STACK_SIZE}" diff --git a/src/c_geometry.cpp b/src/c_geometry.cpp index 46cb86e4..4d91143a 100644 --- a/src/c_geometry.cpp +++ b/src/c_geometry.cpp @@ -2060,7 +2060,7 @@ void c_geometry::build_connections( int ignd ) /* determine connection data for end 1 of segment. */ bool segment_on_ground = false; - if ( ignd > 0) { + if ( ignd != 0) { if ( zi1 <= -slen) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); @@ -2068,12 +2068,12 @@ void c_geometry::build_connections( int ignd ) throw nex; } - if ( zi1 <= slen) { + if ( ignd > 0 && zi1 <= slen) { icon1[i]= iz; z[i]=0.; segment_on_ground = true; } /* if ( zi1 <= slen) */ - } /* if ( ignd > 0) */ + } /* if ( ignd != 0) */ if ( false == segment_on_ground ) { int ic= i; @@ -2105,7 +2105,7 @@ void c_geometry::build_connections( int ignd ) } /* if ( ! jump ) */ /* determine connection data for end 2 of segment. */ - if ( (ignd > 0) || segment_on_ground ) { + if ( ignd != 0 ) { if ( zi2 <= -slen) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); @@ -2113,7 +2113,7 @@ void c_geometry::build_connections( int ignd ) throw nex; } - if ( zi2 <= slen) { + if ( ignd > 0 && zi2 <= slen) { if ( icon1[i] == iz ) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); @@ -2125,7 +2125,13 @@ void c_geometry::build_connections( int ignd ) z2[i]=0.; continue; } /* if ( zi2 <= slen) */ - } /* if ( ignd > 0) */ + if ( ignd < 0 && zi1 == 0.0 && zi2 == 0.0 ) { + nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); + nex.append(iz); + nex.append("LIES IN GROUND PLANE"); + throw nex; + } + } /* if ( ignd != 0) */ // re-initialize these vectors! v1 = nec_3vector(x[i], y[i], z[i]); diff --git a/src/nec_context.cpp b/src/nec_context.cpp index 0af0c993..ae8ee372 100644 --- a/src/nec_context.cpp +++ b/src/nec_context.cpp @@ -383,6 +383,16 @@ void nec_context::stateful_solve_voltage_sources( processing_state = PROCESSING_NEAR_FIELD; } +nec_power_budget nec_context::stateful_power_budget() const +{ + return { + input_power, + input_power - structure_power_loss - network_power_loss, + structure_power_loss, + network_power_loss, + }; +} + void nec_context::stateful_clear_loads() { nload = 0; @@ -1546,8 +1556,8 @@ void nec_context::print_power_budget(void) { if ( (m_excitation_type == EXCITATION_VOLTAGE) || (m_excitation_type == EXCITATION_VOLTAGE_DISC) ) { - nec_float radiated_power = input_power- network_power_loss - structure_power_loss; - nec_float efficiency = 100.0 * radiated_power/input_power; + const nec_power_budget budget = stateful_power_budget(); + nec_float efficiency = 100.0 * budget.radiated_power_w/budget.input_power_w; m_output.endl(3); m_output.nec_printf( @@ -1563,7 +1573,8 @@ void nec_context::print_power_budget(void) "NETWORK LOSS = %11.4E Watts\n" " " "EFFICIENCY = %7.2f Percent", - input_power, radiated_power, structure_power_loss, network_power_loss, efficiency ); + budget.input_power_w, budget.radiated_power_w, + budget.structure_loss_w, budget.network_loss_w, efficiency ); } /* if ( (m_excitation_type == EXCITATION_VOLTAGE) || (m_excitation_type == EXCITATION_VOLTAGE_DISC) ) */ } diff --git a/src/nec_context.h b/src/nec_context.h index 1311d385..f13b4463 100644 --- a/src/nec_context.h +++ b/src/nec_context.h @@ -31,6 +31,7 @@ #include "nec_structure_currents.h" #include "nec_output.h" #include "nec_ground.h" +#include "nec_power_budget.h" #include "c_plot_card.h" class c_geometry; @@ -106,6 +107,9 @@ class nec_context const std::vector& absolute_segments, const std::vector& voltages); + /*! Copy the latest NEC source/loss balance before another solve mutates it. */ + nec_power_budget stateful_power_budget() const; + /*! Clear load cards without relying on LD card sequencing state. */ void stateful_clear_loads(); diff --git a/src/nec_power_budget.h b/src/nec_power_budget.h new file mode 100644 index 00000000..119b0395 --- /dev/null +++ b/src/nec_power_budget.h @@ -0,0 +1,19 @@ +/* + Copyright (C) 2026 NEC2++ contributors + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +#pragma once + +#include "common.h" + +/*! Immutable-by-value snapshot of NEC's latest simultaneous power balance. */ +struct nec_power_budget { + nec_float input_power_w = 0.0; + nec_float radiated_power_w = 0.0; + nec_float structure_loss_w = 0.0; + nec_float network_loss_w = 0.0; +}; diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index f8813c8e..5567e7de 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -214,9 +214,20 @@ const nec_geometry_completion_result& nec_stateful_model::complete_geometry( nec_ground_connection connection) { require_state(nec_model_state::geometry_building, "COMPLETE GEOMETRY"); - const int flag = static_cast(connection); - if (flag < 0 || flag > 2) + int flag = 0; + switch (connection) { + case nec_ground_connection::none: + flag = 0; + break; + case nec_ground_connection::interpolate: + flag = 1; + break; + case nec_ground_connection::zero_current: + flag = -1; + break; + default: fail("COMPLETE GEOMETRY", "UNKNOWN GROUND CONNECTION MODE"); + } if (connection != nec_ground_connection::none && symmetry.kind == nec_geometry_symmetry_kind::reflection && @@ -229,6 +240,7 @@ const nec_geometry_completion_result& nec_stateful_model::complete_geometry( m_context->get_geometry()->generate_symmetry(symmetry); m_context->geometry_complete(flag); m_geometry_completion = completion; + m_ground_connection = connection; m_state = nec_model_state::geometry_complete; m_configuration_dirty = true; return m_geometry_completion; @@ -460,6 +472,10 @@ void nec_stateful_model::prepare(nec_float frequency_mhz) fail("PREPARE", "PORTS HAVE NOT BEEN DEFINED"); if (!finite_value(frequency_mhz) || !(frequency_mhz > 0.0)) fail("PREPARE", "FREQUENCY MUST BE POSITIVE AND FINITE"); + if (m_ground_connection != nec_ground_connection::none && + m_ground.kind == nec_ground_kind::free_space) + fail_geometry( + "PREPARE", "A GROUND CONNECTION REQUIRES A GROUND MODEL"); validate_symmetry_ground(m_ground, "PREPARE"); validate_symmetric_load_orbits(); @@ -521,6 +537,7 @@ const nec_port_solution& nec_stateful_model::finish_consumer_solve( solution.requested = requested; solution.voltages = std::move(achieved_voltages); solution.currents = std::move(achieved_currents); + solution.power_budget = m_context->stateful_power_budget(); solution.active_impedances.reserve(m_ports.size()); solution.powers_w.reserve(m_ports.size()); diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index 4503ede6..4f6ca3aa 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -10,6 +10,7 @@ #include "common.h" #include "nec_geometry_symmetry.h" +#include "nec_power_budget.h" #include #include @@ -173,6 +174,7 @@ struct nec_port_solution { std::vector currents; std::vector active_impedances; std::vector powers_w; + nec_power_budget power_budget; nec_float frequency_mhz = 0.0; uint64_t factorization_generation = 0; uint64_t solve_generation = 0; @@ -297,6 +299,7 @@ class nec_stateful_model { nec_far_field_result m_far_field_result; nec_embedded_far_field_result m_embedded_far_field_result; nec_ground_definition m_ground; + nec_ground_connection m_ground_connection = nec_ground_connection::none; nec_float m_frequency_mhz = 0.0; uint64_t m_factorization_generation = 0; uint64_t m_solve_generation = 0; diff --git a/src/nec_stateful_model_wp2_tb.cpp b/src/nec_stateful_model_wp2_tb.cpp index ea22b838..dba3bafb 100644 --- a/src/nec_stateful_model_wp2_tb.cpp +++ b/src/nec_stateful_model_wp2_tb.cpp @@ -253,6 +253,73 @@ TEST_CASE("WP2 zero-current drive reports the documented NaN active impedance", REQUIRE(model.retained_result_count() == 1); } +TEST_CASE("WP2 simultaneous solves retain NEC native power budgets", + "[wasm_api][wp2][power]") +{ + SECTION("lossless coupled ports close the native balance") { + nec_stateful_model model; + build_dipoles(model, 2); + const nec_port_solution first = model.solve_port_voltages_detailed({ + nec_complex(0.73, -0.19), + nec_complex(-0.28, 0.41), + }); + const nec_float port_sum = first.powers_w[0] + first.powers_w[1]; + REQUIRE(std::isfinite(first.power_budget.input_power_w)); + REQUIRE(std::isfinite(first.power_budget.radiated_power_w)); + REQUIRE(first.power_budget.input_power_w == + Catch::Approx(port_sum).epsilon(1.0e-12)); + REQUIRE(first.power_budget.structure_loss_w == + Catch::Approx(0.0).margin(1.0e-15)); + REQUIRE(first.power_budget.network_loss_w == + Catch::Approx(0.0).margin(1.0e-15)); + REQUIRE(first.power_budget.radiated_power_w == + Catch::Approx(first.power_budget.input_power_w).epsilon(1.0e-12)); + + const nec_power_budget retained = first.power_budget; + const nec_port_solution& second = model.solve_port_voltages_detailed({ + nec_complex(1.0, 0.0), + nec_complex(0.0, 1.0), + }); + REQUIRE(second.solve_generation == 2); + REQUIRE(first.power_budget.input_power_w == retained.input_power_w); + REQUIRE(first.power_budget.radiated_power_w == retained.radiated_power_w); + } + + SECTION("a dissipative load produces positive structure loss") { + nec_stateful_model model; + model.add_wire(dipole_wire(1, 0.0)); + model.complete_geometry(); + model.define_ports({{1, kFeedSegment}}); + model.add_load({ + nec_load_kind::impedance, 1, kFeedSegment, kFeedSegment, + 25.0, 0.0, 0.0, + }); + model.prepare(kFrequencyMHz); + const nec_port_solution& solution = + model.solve_port_voltages_detailed({nec_complex(1.0, 0.0)}); + REQUIRE(solution.power_budget.structure_loss_w > 0.0); + REQUIRE(solution.power_budget.network_loss_w == + Catch::Approx(0.0).margin(1.0e-15)); + REQUIRE(solution.power_budget.input_power_w == Catch::Approx( + solution.power_budget.radiated_power_w + + solution.power_budget.structure_loss_w + + solution.power_budget.network_loss_w).epsilon(1.0e-12)); + REQUIRE(solution.power_budget.radiated_power_w < + solution.power_budget.input_power_w); + } + + SECTION("zero excitation has an exact zero budget") { + nec_stateful_model model; + build_dipoles(model, 1); + const nec_port_solution& solution = + model.solve_port_currents({nec_complex(0.0, 0.0)}); + REQUIRE(solution.power_budget.input_power_w == 0.0); + REQUIRE(solution.power_budget.radiated_power_w == 0.0); + REQUIRE(solution.power_budget.structure_loss_w == 0.0); + REQUIRE(solution.power_budget.network_loss_w == 0.0); + } +} + TEST_CASE("WP2 singular and badly conditioned matrices fail diagnostically", "[wasm_api][wp2][conditioning]") { diff --git a/src/nec_stateful_model_wp3_tb.cpp b/src/nec_stateful_model_wp3_tb.cpp index 8b3082df..53ac3932 100644 --- a/src/nec_stateful_model_wp3_tb.cpp +++ b/src/nec_stateful_model_wp3_tb.cpp @@ -63,6 +63,30 @@ nec_float relative_error( std::max({nec_float(1.0), std::sqrt(first_squared), std::sqrt(second_squared)}); } +nec_float integrate_far_field_power(const nec_far_field_result& field) +{ + REQUIRE(field.theta_deg.size() >= 2); + REQUIRE(field.phi_deg.size() >= 2); + const nec_float delta_theta = degrees_to_rad( + field.theta_deg[1] - field.theta_deg[0]); + const nec_float delta_phi = degrees_to_rad( + field.phi_deg[1] - field.phi_deg[0]); + nec_float angular_field_sum = 0.0; + for (size_t phi = 0; phi < field.phi_deg.size(); ++phi) { + for (size_t theta = 0; theta < field.theta_deg.size(); ++theta) { + const nec_float theta_mid = degrees_to_rad(field.theta_deg[theta]); + const nec_float ring_weight = delta_phi * ( + std::cos(theta_mid - delta_theta / 2.0) - + std::cos(theta_mid + delta_theta / 2.0)); + angular_field_sum += ring_weight * ( + std::norm(field.e_theta_at(theta, phi)) + + std::norm(field.e_phi_at(theta, phi))); + } + } + return field.radius_m * field.radius_m * angular_field_sum / + (2.0 * em::impedance()); +} + std::vector superpose( const std::vector& embedded, size_t samples_per_port, @@ -278,3 +302,104 @@ TEST_CASE("WP3 ground-skipped angles have deterministic zero field entries", REQUIRE(field.e_theta_at(1, 0) == nec_complex(0.0, 0.0)); REQUIRE(field.e_phi_at(1, 0) == nec_complex(0.0, 0.0)); } + +TEST_CASE("WP3 native power budgets agree with converged field flux", + "[wasm_api][wp3][power][far_field]") +{ + SECTION("a coupled lossless free-space solve closes over the full sphere") { + nec_stateful_model model; + build_dipoles(model, 2); + const nec_port_solution solution = model.solve_port_voltages_detailed({ + nec_complex(0.73, -0.19), + nec_complex(-0.28, 0.41), + }); + const nec_far_field_result& field = model.compute_far_field({ + 1.0, + 0.5, 180, 1.0, + 0.0, 360, 1.0, + }); + // The one-degree quadrature is converged; NEC's discretized source + // balance and RP field agree within 0.4% for this coupled fixture. + REQUIRE(integrate_far_field_power(field) == Catch::Approx( + solution.power_budget.radiated_power_w).epsilon(4.0e-3)); + } + + SECTION("a perfect-ground monopole closes over the upper hemisphere") { + nec_stateful_model model; + model.add_wire({ + 1, kSegments, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.25, + 0.001, + }); + model.complete_geometry(nec_ground_connection::interpolate); + model.define_ports({{1, 2}}); + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); + model.prepare(kFrequencyMHz); + const nec_port_solution solution = model.solve_port_voltages_detailed({ + nec_complex(1.0, 0.0), + }); + const nec_far_field_result& field = model.compute_far_field({ + 1.0, + 0.5, 90, 1.0, + 0.0, 180, 2.0, + }); + REQUIRE(integrate_far_field_power(field) == Catch::Approx( + solution.power_budget.radiated_power_w).epsilon(2.0e-3)); + } +} + +TEST_CASE("WP3 signed ground connections retain distinct NEC GE semantics", + "[wasm_api][wp3][ground][connection]") +{ + const auto rooted_impedance = [](nec_ground_connection connection) { + nec_stateful_model model; + model.add_wire({ + 1, kSegments, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.25, + 0.001, + }); + model.complete_geometry(connection); + model.define_ports({{1, 2}}); + model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); + model.prepare(kFrequencyMHz); + return model.solve_port_voltages_detailed({nec_complex(1.0, 0.0)}) + .active_impedances[0]; + }; + + const nec_complex interpolated = + rooted_impedance(nec_ground_connection::interpolate); + const nec_complex zero_current = + rooted_impedance(nec_ground_connection::zero_current); + REQUIRE(std::abs(interpolated - zero_current) > 1.0e-6); + + nec_stateful_model missing_ground; + missing_ground.add_wire({ + 1, kSegments, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.001, + }); + missing_ground.complete_geometry(nec_ground_connection::zero_current); + missing_ground.define_ports({{1, 2}}); + REQUIRE_THROWS_AS(missing_ground.prepare(kFrequencyMHz), nec_exception); +} + +TEST_CASE("WP3 both signed ground modes reject invalid ground-plane geometry", + "[wasm_api][wp3][ground][validation]") +{ + for (const nec_ground_connection connection : { + nec_ground_connection::interpolate, + nec_ground_connection::zero_current, + }) { + nec_stateful_model below; + below.add_wire({ + 1, 3, 0.0, 0.0, -0.1, 0.0, 0.0, 0.2, 0.001, + }); + REQUIRE_THROWS_AS(below.complete_geometry(connection), nec_exception); + + nec_stateful_model in_plane; + in_plane.add_wire({ + 1, 3, -0.1, 0.0, 0.0, 0.1, 0.0, 0.0, 0.001, + }); + REQUIRE_THROWS_AS(in_plane.complete_geometry(connection), nec_exception); + } +} diff --git a/src/necpp_wasm_v1.cpp b/src/necpp_wasm_v1.cpp index 6f99a8bd..13a63384 100644 --- a/src/necpp_wasm_v1.cpp +++ b/src/necpp_wasm_v1.cpp @@ -81,6 +81,10 @@ struct solution_buffers { std::vector powers_w; int32_t drive = NECPP_WASM_V1_DRIVE_VOLTAGE; double frequency_mhz = 0.0; + double input_power_w = 0.0; + double radiated_power_w = 0.0; + double structure_loss_w = 0.0; + double network_loss_w = 0.0; uint64_t factorization_generation = 0; uint64_t solve_generation = 0; bool available = false; @@ -94,6 +98,10 @@ struct solution_buffers { powers_w.clear(); drive = NECPP_WASM_V1_DRIVE_VOLTAGE; frequency_mhz = 0.0; + input_power_w = 0.0; + radiated_power_w = 0.0; + structure_loss_w = 0.0; + network_loss_w = 0.0; factorization_generation = 0; solve_generation = 0; available = false; @@ -319,6 +327,10 @@ void sync_solution( ? NECPP_WASM_V1_DRIVE_CURRENT : NECPP_WASM_V1_DRIVE_VOLTAGE; next.frequency_mhz = result.frequency_mhz; + next.input_power_w = result.power_budget.input_power_w; + next.radiated_power_w = result.power_budget.radiated_power_w; + next.structure_loss_w = result.power_budget.structure_loss_w; + next.network_loss_w = result.power_budget.network_loss_w; next.factorization_generation = result.factorization_generation; next.solve_generation = result.solve_generation; next.available = true; @@ -1110,6 +1122,34 @@ double necpp_wasm_v1_solution_generation( ? static_cast(model->solution.solve_generation) : 0.0; } +double necpp_wasm_v1_solution_input_power_w( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.input_power_w : 0.0; +} + +double necpp_wasm_v1_solution_radiated_power_w( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.radiated_power_w : 0.0; +} + +double necpp_wasm_v1_solution_structure_loss_w( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.structure_loss_w : 0.0; +} + +double necpp_wasm_v1_solution_network_loss_w( + const necpp_wasm_v1_model* model) +{ + return model != nullptr && model->solution.available + ? model->solution.network_loss_w : 0.0; +} + double necpp_wasm_v1_far_field_radius_m(const necpp_wasm_v1_model* model) { return model != nullptr && model->far_field.available diff --git a/src/necpp_wasm_v1.h b/src/necpp_wasm_v1.h index bf86ebeb..57e2aa12 100644 --- a/src/necpp_wasm_v1.h +++ b/src/necpp_wasm_v1.h @@ -210,6 +210,14 @@ double necpp_wasm_v1_solution_factorization_generation( const necpp_wasm_v1_model* model); double necpp_wasm_v1_solution_generation( const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_input_power_w( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_radiated_power_w( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_structure_loss_w( + const necpp_wasm_v1_model* model); +double necpp_wasm_v1_solution_network_loss_w( + const necpp_wasm_v1_model* model); double necpp_wasm_v1_far_field_radius_m(const necpp_wasm_v1_model* model); double necpp_wasm_v1_far_field_frequency_mhz( diff --git a/src/necpp_wasm_v1_c_tb.c b/src/necpp_wasm_v1_c_tb.c index e31d64ea..4d12a4d1 100644 --- a/src/necpp_wasm_v1_c_tb.c +++ b/src/necpp_wasm_v1_c_tb.c @@ -277,6 +277,10 @@ int necpp_wasm_v1_run_c_contract_test(void) CHECK(buffer_is_finite(model, NECPP_WASM_V1_ADMITTANCE_IMAG, 4) == 0); CHECK(necpp_wasm_v1_result_buffer(model, 999) == NULL); CHECK(necpp_wasm_v1_result_buffer_length(model, 999) == 0); + CHECK(necpp_wasm_v1_solution_input_power_w(model) == 0.0); + CHECK(necpp_wasm_v1_solution_radiated_power_w(model) == 0.0); + CHECK(necpp_wasm_v1_solution_structure_loss_w(model) == 0.0); + CHECK(necpp_wasm_v1_solution_network_loss_w(model) == 0.0); CHECK(necpp_wasm_v1_solve_voltages(model, NULL, NULL, 2) == NECPP_WASM_V1_INPUT_ERROR); @@ -288,6 +292,14 @@ int necpp_wasm_v1_run_c_contract_test(void) CHECK(necpp_wasm_v1_solution_frequency_mhz(model) == 300.0); CHECK(necpp_wasm_v1_solution_factorization_generation(model) == 1.0); CHECK(necpp_wasm_v1_solution_generation(model) == 1.0); + CHECK(isfinite(necpp_wasm_v1_solution_input_power_w(model))); + CHECK(isfinite(necpp_wasm_v1_solution_radiated_power_w(model))); + CHECK(isfinite(necpp_wasm_v1_solution_structure_loss_w(model))); + CHECK(isfinite(necpp_wasm_v1_solution_network_loss_w(model))); + CHECK(fabs(necpp_wasm_v1_solution_input_power_w(model) - + (necpp_wasm_v1_solution_radiated_power_w(model) + + necpp_wasm_v1_solution_structure_loss_w(model) + + necpp_wasm_v1_solution_network_loss_w(model))) < 1.0e-12); for (index = NECPP_WASM_V1_SOLUTION_REQUESTED_REAL; index <= NECPP_WASM_V1_SOLUTION_POWERS_W; ++index) CHECK(buffer_is_finite(model, index, 2) == 0); diff --git a/src/necpp_wasm_v1_tb.cpp b/src/necpp_wasm_v1_tb.cpp index e3707abc..39b450e4 100644 --- a/src/necpp_wasm_v1_tb.cpp +++ b/src/necpp_wasm_v1_tb.cpp @@ -84,6 +84,10 @@ TEST_CASE("WP-S3 ABI completion metadata matches the stateful model", std::unique_ptr abi(necpp_wasm_v1_model_create(), &necpp_wasm_v1_model_delete); REQUIRE(abi != nullptr); + REQUIRE(necpp_wasm_v1_solution_input_power_w(abi.get()) == 0.0); + REQUIRE(necpp_wasm_v1_solution_radiated_power_w(abi.get()) == 0.0); + REQUIRE(necpp_wasm_v1_solution_structure_loss_w(abi.get()) == 0.0); + REQUIRE(necpp_wasm_v1_solution_network_loss_w(abi.get()) == 0.0); REQUIRE(necpp_wasm_v1_add_wire( abi.get(), 1, 11, 0.25, 0.25, 0.1, @@ -156,6 +160,14 @@ TEST_CASE("WP4 bulk ABI buffers reproduce native results", const double voltage_imag[] = {0.0}; REQUIRE(necpp_wasm_v1_solve_voltages( abi.get(), voltage_real, voltage_imag, 1) == NECPP_WASM_V1_OK); + REQUIRE(necpp_wasm_v1_solution_input_power_w(abi.get()) == Catch::Approx( + native_solution.power_budget.input_power_w).epsilon(1.0e-12)); + REQUIRE(necpp_wasm_v1_solution_radiated_power_w(abi.get()) == Catch::Approx( + native_solution.power_budget.radiated_power_w).epsilon(1.0e-12)); + REQUIRE(necpp_wasm_v1_solution_structure_loss_w(abi.get()) == Catch::Approx( + native_solution.power_budget.structure_loss_w).margin(1.0e-15)); + REQUIRE(necpp_wasm_v1_solution_network_loss_w(abi.get()) == Catch::Approx( + native_solution.power_budget.network_loss_w).margin(1.0e-15)); require_complex_buffer_matches( abi.get(), NECPP_WASM_V1_SOLUTION_CURRENTS_REAL,