v2.0.0: TypeScript rewrite with WASM recompilation

Complete rewrite of the package from plain JavaScript to TypeScript,
compiled to dual CJS/ESM via tsup. The NREL SPA C source is recompiled
to WASM with Emscripten using SINGLE_FILE base64 inlining, eliminating
bundler path-resolution issues.

Changes:
- Rewrite JS wrapper in TypeScript with full type definitions
- Recompile WASM with -O3 -flto, 1MB fixed memory, no filesystem
- Add input validation with descriptive error messages
- Add spaFormatted() for HH:MM:SS time strings
- Add formatTime() utility and init() for eager WASM loading
- Add SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL function code exports
- Dual CJS/ESM output via tsup with proper exports map
- Test suite: 68 ESM + 13 CJS assertions
- 100-scenario validation suite across 7 categories
- GitHub Wiki with 8 documentation pages
- CI workflow: Node 20/22/24 matrix, typecheck, pack-check
- NREL attribution in LICENSE and README per their license terms
- Minimum Node.js 20
This commit is contained in:
Aric Camarata 2026-02-25 10:35:24 -05:00
parent 3f1144d037
commit fb0c14e761
36 changed files with 4177 additions and 2006 deletions

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.{js,mjs,cjs,ts,mts,cts,json,yml,yaml,md}]
indent_style = space
indent_size = 2
[*.{c,h}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab

78
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build TypeScript
run: pnpm run build:ts
- name: Run tests (ESM)
run: node test.mjs
- name: Run tests (CJS)
run: node test-cjs.cjs
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
pack-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:ts
- name: Verify package contents
run: |
npm pack --dry-run 2>&1 | tee pack-output.txt
for f in dist/index.cjs dist/index.mjs dist/index.d.ts dist/index.d.mts wasm/spa-module.js README.md CHANGELOG.md LICENSE; do
grep -q "$f" pack-output.txt || { echo "MISSING: $f"; exit 1; }
done
echo "All expected files present in package"

36
.github/workflows/wiki-sync.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Sync Wiki
on:
push:
branches: [main]
paths: ['.wiki/**']
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout wiki
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}.wiki
path: .wiki-remote
- name: Sync wiki pages
run: |
cp .wiki/*.md .wiki-remote/
cd .wiki-remote
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No wiki changes to commit"
else
git commit -m "Sync wiki from repo"
git push
fi

63
.gitignore vendored Normal file
View file

@ -0,0 +1,63 @@
# ─── Dependencies ───
node_modules/
.pnp
.pnp.js
# ─── Build ───
dist/
build/
out/
*.tsbuildinfo
# ─── Environment ───
.env
.env.*
!.env.example
# ─── OS ───
.DS_Store
Thumbs.db
._*
Desktop.ini
$RECYCLE.BIN/
# ─── IDE / Editor ───
.vscode/
.idea/
*.swp
*.swo
*~
*.sublime-project
*.sublime-workspace
# ─── Logs ───
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# ─── Testing / Coverage ───
coverage/
.nyc_output/
# ─── AI Agents ───
.claude/
.cursor/
.copilot/
.github/copilot/
.aider*
.codeium/
.tabnine/
.windsurf/
.cody/
.sourcegraph/
# ─── Packages ───
*.tgz
# ─── Custom ───
# C compilation artifacts (WASM binary is pre-compiled and tracked)
*.o
*.a

View file

@ -1,2 +0,0 @@
src/
test.js

1
.npmrc Normal file
View file

@ -0,0 +1 @@
package-import-method=hardlink

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

155
.wiki/API-Reference.md Normal file
View file

@ -0,0 +1,155 @@
# API Reference
## `spa(date, latitude, longitude, options?)`
Returns a `Promise<SpaResult>` with raw numeric values.
### Parameters
| Name | Type | Description |
| --- | --- | --- |
| `date` | `Date` | Date and time for the calculation |
| `latitude` | `number` | Observer latitude, -90 to 90 (negative = south) |
| `longitude` | `number` | Observer longitude, -180 to 180 (negative = west) |
| `options` | `object` | Optional. See below |
### Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `timezone` | `number` | auto | Hours from UTC. Auto-detected from the Date object if omitted |
| `elevation` | `number` | `0` | Meters above sea level |
| `pressure` | `number` | `1013.25` | Atmospheric pressure in millibars |
| `temperature` | `number` | `15` | Temperature in Celsius |
| `delta_ut1` | `number` | `0` | UT1-UTC correction in seconds |
| `delta_t` | `number` | `67` | TT-UTC difference in seconds |
| `slope` | `number` | `0` | Surface slope in degrees from horizontal |
| `azm_rotation` | `number` | `0` | Surface azimuth rotation in degrees from south |
| `atmos_refract` | `number` | `0.5667` | Atmospheric refraction in degrees |
| `function` | `number` | `3` | SPA function code (see below) |
### Result Fields
| Field | Type | Unit | Description |
| --- | --- | --- | --- |
| `zenith` | `number` | degrees | Topocentric zenith angle (0 = directly overhead) |
| `azimuth` | `number` | degrees | Topocentric azimuth, eastward from north (navigational convention) |
| `azimuth_astro` | `number` | degrees | Topocentric azimuth, westward from south (astronomical convention) |
| `incidence` | `number` | degrees | Surface incidence angle |
| `sunrise` | `number` | fractional hours | Local sunrise time |
| `sunset` | `number` | fractional hours | Local sunset time |
| `suntransit` | `number` | fractional hours | Solar noon (sun transit) |
| `sun_transit_alt` | `number` | degrees | Sun altitude at transit |
| `eot` | `number` | minutes | Equation of time |
| `error_code` | `number` | integer | 0 on success |
### Timezone Auto-detection
When `timezone` is omitted, the value is derived from the `Date` object's local timezone offset:
```js
timezone = -(date.getTimezoneOffset() / 60)
```
This works correctly in most cases. Provide an explicit value when computing for a location whose timezone differs from the machine's local timezone.
### Error Handling
The SPA validates all inputs against physical bounds (latitude -90 to 90, timezone -18 to 18, etc.). If validation fails, `spa()` throws an `Error` with the SPA error code in the message:
```js
try {
await spa(new Date(), 40, -74, { timezone: 100 });
} catch (e) {
// "SPA: calculation failed (error code 8)"
}
```
A null result pointer (WASM memory allocation failure) also throws.
## `spaFormatted(date, latitude, longitude, options?)`
Same parameters and behavior as `spa()`. Returns a result object with the same fields, but `sunrise`, `sunset`, and `suntransit` are `HH:MM:SS` strings instead of fractional hours. During polar day or polar night, these strings are `"N/A"`:
```js
const result = await spaFormatted(
new Date(2025, 5, 21, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4 }
);
console.log(result.sunrise); // "05:25:12"
console.log(result.sunset); // "20:30:42"
console.log(result.suntransit); // "12:57:54"
console.log(result.zenith); // 27.08 (still a number)
```
## `formatTime(hours)`
Converts fractional hours to an `HH:MM:SS` string. Returns `"N/A"` for non-finite or negative values, which occur during polar day or polar night when sunrise or sunset does not happen. Values at or above 24 hours wrap to the next day (e.g., 24.5 becomes `"00:30:00"`).
```js
formatTime(6.5); // "06:30:00"
formatTime(12); // "12:00:00"
formatTime(Infinity); // "N/A"
```
## `init()`
Pre-initializes the WASM module. Returns a `Promise<void>` that resolves when the module is ready.
This is optional. The module initializes automatically on the first `spa()` call. Use `init()` if you want to pay the initialization cost at application startup rather than on the first calculation.
```js
import { init, spa } from 'solar-spa';
// Somewhere during app bootstrap
await init();
// Later, this call has no initialization overhead
const result = await spa(new Date(), 40, -74);
```
Calling `init()` multiple times is safe. The second and subsequent calls return immediately.
## Function Codes
The `function` option controls which outputs the SPA computes. Lower codes skip the rise/transit/set and incidence calculations, which are the most expensive part.
| Constant | Value | Computes |
| --- | --- | --- |
| `SPA_ZA` | `0` | Zenith and azimuth only |
| `SPA_ZA_INC` | `1` | Zenith, azimuth, and incidence angle |
| `SPA_ZA_RTS` | `2` | Zenith, azimuth, and rise/transit/set times |
| `SPA_ALL` | `3` | All output values (default) |
```js
import { spa, SPA_ZA } from 'solar-spa';
// When you only need zenith and azimuth (fastest)
const result = await spa(new Date(), 40, -74, { function: SPA_ZA });
console.log(result.zenith, result.azimuth);
```
When using `SPA_ZA` or `SPA_ZA_INC`, the rise/transit/set fields will contain zeros.
## TypeScript
Full type definitions are included. Import types directly:
```ts
import { spa, SPA_ALL } from 'solar-spa';
import type { SpaResult, SpaOptions } from 'solar-spa';
const options: SpaOptions = {
timezone: -4,
elevation: 10,
function: SPA_ALL,
};
const result: SpaResult = await spa(new Date(), 40.7128, -74.006, options);
```
---
[Home](Home) · [Architecture](Architecture) · [NREL SPA Algorithm](NREL-SPA-Algorithm) · [Changelog](https://github.com/acamarata/solar-spa/blob/main/CHANGELOG.md)

140
.wiki/Architecture.md Normal file
View file

@ -0,0 +1,140 @@
# Architecture
solar-spa has three layers. Each solves a specific problem.
## Layer 1: C (NREL SPA)
**Files:** `src/spa.c`, `src/spa.h`, `src/spa_wrapper.c`
The core algorithm is the [NREL Solar Position Algorithm](https://midcdmz.nrel.gov/spa/) by Ibrahim Reda and Afshin Andreas. It is a direct C implementation of the algorithm described in the paper "Solar Position Algorithm for Solar Radiation Applications" (Solar Energy, Vol. 76, Issue 5, 2004, pp. 577-589). The code is unmodified from the NREL distribution.
`spa_wrapper.c` is a thin adapter. The original `spa_calculate()` function takes a pointer to a `spa_data` struct with 30+ fields. That struct layout is not accessible from JavaScript via Emscripten's `cwrap()`. The wrapper provides a flat function signature that accepts each input as a separate argument, calls `spa_calculate()`, and copies the output fields into a compact result struct allocated on the heap.
The result struct occupies 80 bytes in memory: nine `double` fields (72 bytes) followed by one `int` (4 bytes), plus 4 bytes of trailing padding for struct alignment. The caller reads the fields by offset using `getValue()` and then calls `spa_free_result()` to release the allocation.
This design avoids the complexity of passing structs across the WASM boundary. The flat signature maps directly to `cwrap()` type arrays, and reading by fixed byte offset is the fastest way to extract results from WASM memory.
## Layer 2: WASM (Emscripten output)
**Files:** `wasm/spa-module.js`
The C source is compiled with Emscripten to produce a single JavaScript file that contains the WASM binary encoded as base64. No separate `.wasm` file exists.
### Build flags
| Flag | Purpose |
| --- | --- |
| `-O3 -flto` | Maximum optimization with link-time optimization. The compiler inlines across translation units and eliminates dead code |
| `--no-entry` | No `main()` function exists. The module exposes only the exported wrapper functions |
| `-sSINGLE_FILE=1` | Inlines the WASM binary as a base64 string inside the JavaScript file. Eliminates the `.wasm` file entirely |
| `-sMODULARIZE=1` | Wraps the output in a factory function (`createSpaModule()`) instead of executing immediately. Prevents global `Module` pollution |
| `-sEXPORT_NAME=createSpaModule` | Names the factory function |
| `-sEXPORTED_FUNCTIONS` | Exposes `_spa_calculate_wrapper`, `_spa_free_result`, `_malloc`, and `_free` to JavaScript |
| `-sEXPORTED_RUNTIME_METHODS` | Makes `cwrap` and `getValue` available on the module instance |
| `-sNO_FILESYSTEM=1` | Excludes the virtual filesystem API. SPA does not read files. Saves ~15KB |
| `-sINITIAL_MEMORY=1048576` | 1MB fixed memory. SPA allocates one 80-byte struct per call, so this is more than sufficient |
| `-sALLOW_MEMORY_GROWTH=0` | Disables dynamic memory growth. Fixed memory avoids the overhead of growable ArrayBuffers and detached buffer checks |
| `-sSTACK_SIZE=65536` | 64KB stack. Default is 5MB, which is wasteful for a pure computation |
| `-sENVIRONMENT='node,web,worker'` | Includes runtime support for Node.js, browsers, and web workers |
| `-sASSERTIONS=0` | Removes debug assertions. Smaller output, no runtime checks |
| `-sDISABLE_EXCEPTION_CATCHING=1` | Disables C++ exception support. SPA is plain C, so this strips dead code |
| `-sWASM_BIGINT=0` | Disables BigInt integration for 64-bit integers. SPA uses only doubles and 32-bit ints |
The `SINGLE_FILE` flag is the critical one. Most WASM packages ship a separate `.wasm` file and resolve its path at runtime using `__dirname`, `import.meta.url`, or `URL` constructors. This breaks in bundlers (Webpack rewrites paths), edge runtimes (no filesystem), and testing environments (different module resolution). By inlining the binary, the module is self-contained. It works anywhere JavaScript runs.
The output is ~60KB. The base64-encoded WASM accounts for most of that. The JavaScript glue code is minimal because we disabled the filesystem, exception handling, and assertions.
### Why MODULARIZE?
Without `MODULARIZE`, Emscripten emits code that creates or mutates a global `Module` object. If two packages in the same application use Emscripten, they clobber each other's `Module`. With `MODULARIZE`, each call to `createSpaModule()` returns an independent instance. solar-spa creates exactly one instance (the singleton) and caches it.
## Layer 3: TypeScript wrapper
**Source:** `src/index.ts`, `src/types.ts`
**Output:** `dist/index.cjs`, `dist/index.mjs`, `dist/index.d.ts` (CJS), `dist/index.d.mts` (ESM)
The wrapper is written in TypeScript and compiled by [tsup](https://tsup.egoist.dev/) to both CJS and native ESM, with generated declaration files for each format. The source handles four concerns: initialization, input validation, calling convention, and struct reading.
### Singleton initialization
The WASM module initializes once. A module-level variable `_module` holds the cached instance. A second variable `_pending` holds the in-flight initialization promise to prevent duplicate init when multiple `spa()` calls arrive before the first init completes. If initialization fails, `_pending` is cleared so the next call retries.
```text
spa() called
|-> init() called
|-- _module exists? Return immediately
|-- _pending exists? Return existing promise
|-- Neither? Call createSpaModule(), cache promise in _pending
|-- On resolve: cache module in _module, cache cwrap bindings, clear _pending
|-- On reject: clear _pending, re-throw (allows retry)
```
The `cwrap()` bindings for `spa_calculate_wrapper` and `spa_free_result` are created once during initialization and stored in `_calculate` and `_free`. This avoids the overhead of re-wrapping on every call.
### Input validation
Before reaching the WASM layer, `spa()` validates that `date` is a valid `Date` object, that `latitude` and `longitude` are finite numbers within their valid ranges, and throws `TypeError` or `RangeError` with a clear message. The C layer performs its own validation and returns error codes, but the TypeScript validation provides better developer experience with descriptive error types.
### Struct offsets
The byte offsets for reading the result struct are stored in a named constant object (`OFFSET`) rather than as magic numbers. This makes the layout self-documenting and reduces the risk of an offset error when the struct is modified.
### Dual CJS/ESM
tsup compiles the TypeScript source to both CJS (`.cjs`) and native ESM (`.mjs`). The ESM build uses a `createRequire` shim (injected via tsup's banner option) to load the Emscripten WASM module, which is CJS. The CJS build uses native `require()`.
Both builds share the same WASM singleton within a single Node.js process because CJS module caching ensures the Emscripten module is loaded only once regardless of which entry point is used.
The `package.json` exports map routes `require()` to the CJS file and `import` to the ESM file, with per-format TypeScript declaration files:
```json
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
}
}
```
## File layout
```text
solar-spa/
|-- src/
| |-- index.ts # Main implementation (TypeScript)
| |-- types.ts # Interfaces, constants, WASM module type
| |-- spa.c # NREL SPA algorithm (unmodified)
| |-- spa.h # NREL SPA header (unmodified)
| |-- spa_wrapper.c # Flat wrapper for WASM boundary
|-- wasm/
| |-- spa-module.js # Compiled output (WASM inlined as base64)
|-- dist/ # Generated by tsup
| |-- index.cjs # CommonJS output
| |-- index.mjs # ESM output
| |-- index.d.ts # CJS declarations
| |-- index.d.mts # ESM declarations
|-- test.mjs # ESM test suite
|-- test-cjs.cjs # CJS smoke test
|-- tsup.config.ts # Build configuration
|-- tsconfig.json # TypeScript configuration
|-- package.json
|-- CHANGELOG.md
|-- README.md
|-- LICENSE
```
Published files (the `files` field in `package.json`): `dist/`, `wasm/`, `README.md`, `CHANGELOG.md`, `LICENSE`.
Source files (`src/`, `tsup.config.ts`, `tsconfig.json`) are not published. They are in the repository for building from source but are not needed at runtime.
---
[Home](Home) · [API Reference](API-Reference) · [Performance](Performance) · [Contributing](Contributing)

View file

@ -0,0 +1,125 @@
# Bundler Compatibility
solar-spa works out of the box in every tested environment. No special configuration is required.
## How it works
The WASM binary is inlined as base64 inside `wasm/spa-module.js` (compiled with Emscripten's `SINGLE_FILE` flag). There is no external `.wasm` file to resolve, fetch, or copy. The package is just JavaScript files, and every bundler knows how to handle JavaScript. See [WebAssembly in npm Packages](WebAssembly-in-npm-Packages) for a deeper discussion of why this approach works.
## Tested environments
### Node.js (18+)
Both ES module and CommonJS imports work:
```js
// ESM
import { spa } from 'solar-spa';
// CommonJS
const { spa } = require('solar-spa');
```
The `package.json` exports map routes `import` to `dist/index.mjs` and `require()` to `dist/index.cjs`. Full TypeScript declarations are provided for both formats.
### Webpack 5
No configuration needed. Webpack resolves the package through the exports map and bundles the JavaScript files normally. The base64-encoded WASM is just a string literal inside the bundle.
```js
// webpack project
import { spa } from 'solar-spa';
const result = await spa(new Date(), 40.7128, -74.006);
```
If your Webpack config includes rules for `.wasm` files, they will not interfere because solar-spa does not import or reference any `.wasm` files.
### Vite
Works without configuration. Vite handles the CJS-to-ESM interop through its pre-bundling step (esbuild), and the inlined WASM is transparent to the build pipeline.
```js
// vite project
import { spa } from 'solar-spa';
```
### Next.js (Pages Router)
Works in both API routes (server-side) and `getServerSideProps`. The WASM initializes on the server without filesystem access issues because the binary is inlined.
```js
// pages/api/sun.js
import { spa } from 'solar-spa';
export default async function handler(req, res) {
const result = await spa(new Date(), 40.7128, -74.006, { timezone: -4 });
res.json(result);
}
```
### Next.js (App Router)
Works in server components, route handlers, and middleware. The same inlining approach avoids the edge runtime file resolution problems that affect packages with separate `.wasm` files.
```js
// app/api/sun/route.js
import { spa } from 'solar-spa';
export async function GET() {
const result = await spa(new Date(), 40.7128, -74.006, { timezone: -4 });
return Response.json(result);
}
```
### Browser (direct)
If you are not using a bundler, use a CDN that provides ESM builds:
```html
<script type="module">
import { spa } from 'https://esm.sh/solar-spa';
const result = await spa(new Date(), 40.7128, -74.006);
console.log(result.zenith);
</script>
```
### Web Workers
The Emscripten output includes `worker` in its environment list. WASM initialization works inside a Web Worker without modification:
```js
// worker.js
import { spa } from 'solar-spa';
self.addEventListener('message', async (event) => {
const { date, lat, lon } = event.data;
const result = await spa(new Date(date), lat, lon);
self.postMessage(result);
});
```
## Common questions
### Do I need to configure `wasm` asset handling in my bundler?
No. There is no `.wasm` file in the package. The binary is a base64 string inside a `.js` file.
### Will this work with tree-shaking?
The package exports individual named functions. Bundlers that support tree-shaking will include only the functions you import. However, the WASM module is initialized as a whole, so the binary size is fixed regardless of which functions you use.
### Is there a browser-only build?
The same build works in both Node.js and browsers. The Emscripten output detects the environment at runtime (`ENVIRONMENT='node,web,worker'`).
### What about Content Security Policy (CSP)?
The WASM binary is decoded from base64 and compiled via `WebAssembly.instantiate()`. If your CSP restricts `wasm-eval` or `wasm-unsafe-eval`, you will need to add the appropriate directive. This is a general WASM requirement, not specific to this package.
### What is the minimum browser version?
Any browser that supports WebAssembly: Chrome 57+, Firefox 52+, Safari 11+, Edge 16+. This covers all browsers released since 2017.
---
[Home](Home) · [WebAssembly in npm Packages](WebAssembly-in-npm-Packages) · [Architecture](Architecture)

91
.wiki/Contributing.md Normal file
View file

@ -0,0 +1,91 @@
# Contributing
## Prerequisites
- Node.js 18 or later
- pnpm 10 or later
- [Emscripten](https://emscripten.org/docs/getting_started/downloads.html) (only for recompiling the WASM module)
On macOS, Emscripten is available via Homebrew:
```sh
brew install emscripten
```
## Building from source
Clone the repository, install dependencies, and build the TypeScript:
```sh
git clone https://github.com/acamarata/solar-spa.git
cd solar-spa
pnpm install
pnpm run build:ts
```
To recompile the WASM module (requires Emscripten):
```sh
pnpm run build:wasm
```
Or build everything at once:
```sh
pnpm run build
```
## Running tests
```sh
pnpm test
```
This runs both the ESM test suite (`test.mjs`, 66 assertions) and the CJS smoke test (`test-cjs.cjs`). Tests cover multiple geographic locations, seasons, input validation, error handling, concurrent initialization, polar regions, boundary coordinates, and all SPA function codes.
## Type checking
```sh
pnpm run typecheck
```
## Project structure
```
src/
index.ts # Main implementation (TypeScript source)
types.ts # Type definitions and constants
spa.c # NREL SPA algorithm (do not modify)
spa.h # NREL SPA header (do not modify)
spa_wrapper.c # Flat wrapper for WASM boundary
wasm/
spa-module.js # Compiled WASM output (~60KB, inlined as base64)
dist/ # Generated by tsup (CJS + ESM + declarations)
index.cjs
index.mjs
index.d.ts # CJS declarations
index.d.mts # ESM declarations
test.mjs # ESM test suite
test-cjs.cjs # CJS smoke test
tsup.config.ts # Build configuration
tsconfig.json # TypeScript configuration
```
## Guidelines
- Do not modify `spa.c` or `spa.h`. These are the original NREL source files and should remain identical to the NREL distribution.
- Changes to `spa_wrapper.c` that add new output fields require corresponding changes in `src/index.ts` (the OFFSET map and `readResult` function) and `src/types.ts` (the result interfaces).
- Run `pnpm test` and `pnpm run typecheck` before submitting. All tests and type checks should pass.
- The test suite uses approximate comparisons (`approx` with a tolerance) because floating-point results vary slightly across platforms and Emscripten versions.
## Reporting bugs
Open an issue at [github.com/acamarata/solar-spa/issues](https://github.com/acamarata/solar-spa/issues). Include:
- Node.js version (`node --version`)
- The input parameters that produced the unexpected result
- Expected vs actual output
---
[Home](Home) · [Architecture](Architecture) · [API Reference](API-Reference)

55
.wiki/Home.md Normal file
View file

@ -0,0 +1,55 @@
# solar-spa
NREL Solar Position Algorithm compiled to WebAssembly. Calculates solar zenith, azimuth, incidence angle, sunrise, sunset, solar noon, and equation of time for any location and date.
The algorithm is the [NREL SPA](https://midcdmz.nrel.gov/spa/) by Ibrahim Reda and Afshin Andreas, originally written in C. This package compiles the original C source to WASM via Emscripten and provides a TypeScript/JavaScript interface on top.
## Why WASM?
The SPA involves thousands of floating-point operations per call: trigonometric series, Julian date conversions, nutation corrections, and iterative sunrise/sunset bracketing. WASM executes these at near-native speed, which matters when computing positions for thousands of locations or running tight animation loops.
For single calls, the difference is negligible. See [Performance](Performance) for benchmarks.
## Pages
- [API Reference](API-Reference): Full function signatures, option fields, and return types.
- [Architecture](Architecture): How the three layers (C, WASM, JS) fit together and why each design choice was made.
- [Performance](Performance): Benchmarks, memory footprint, and optimization notes.
- [NREL SPA Algorithm](NREL-SPA-Algorithm): Background on the algorithm itself, its accuracy, and valid date range.
- [WebAssembly in npm Packages](WebAssembly-in-npm-Packages): Practical notes on shipping WASM in npm packages, common pitfalls, and how this package avoids them.
- [Bundler Compatibility](Bundler-Compatibility): Tested environments and configuration notes for Webpack, Vite, Next.js, and web workers.
- [Validation and Benchmarks](Validation-and-Benchmarks): Accuracy validation against NREL reference values and performance benchmarks.
- [Contributing](Contributing): How to build from source, run tests, and submit changes.
- [Changelog](https://github.com/acamarata/solar-spa/blob/main/CHANGELOG.md): Version history and breaking changes.
## Quick Start
```js
import { spa } from 'solar-spa';
const result = await spa(
new Date(2025, 5, 21, 12, 0, 0), // June 21, 2025 at noon
40.7128, // latitude (NYC)
-74.0060, // longitude
{ timezone: -4, elevation: 10 } // EDT (UTC-4), 10m elevation
);
console.log(result.zenith); // ~27 (degrees from vertical)
console.log(result.azimuth); // ~179 (degrees from north)
console.log(result.sunrise); // ~5.4 (fractional hours)
console.log(result.sunset); // ~20.5 (fractional hours)
```
CommonJS works too:
```js
const { spa } = require('solar-spa');
```
## License
MIT
---
[GitHub](https://github.com/acamarata/solar-spa) · [npm](https://www.npmjs.com/package/solar-spa) · [Changelog](https://github.com/acamarata/solar-spa/blob/main/CHANGELOG.md)

114
.wiki/NREL-SPA-Algorithm.md Normal file
View file

@ -0,0 +1,114 @@
# NREL SPA Algorithm
## Background
The Solar Position Algorithm (SPA) was developed at the National Renewable Energy Laboratory (NREL) by Ibrahim Reda and Afshin Andreas. It calculates the solar zenith and azimuth angles for any location on Earth, for any date between -2000 and 6000, with an uncertainty of +/- 0.0003 degrees.
The algorithm is described in the paper:
> Ibrahim Reda, Afshin Andreas, "Solar Position Algorithm for Solar Radiation Applications," Solar Energy, Vol. 76, Issue 5, 2004, pp. 577-589.
The C reference implementation is available from NREL at [midcdmz.nrel.gov/spa/](https://midcdmz.nrel.gov/spa/).
## What it computes
Given a date, time, observer location, and atmospheric conditions, the SPA produces:
- **Zenith angle**: the angle between the sun and the point directly overhead. 0 degrees means the sun is at the zenith. 90 degrees means the sun is on the horizon. Values above 90 indicate the sun is below the horizon.
- **Azimuth angle**: the compass direction of the sun. Two conventions are provided: eastward from north (navigational) and westward from south (astronomical).
- **Incidence angle**: the angle between the sun and a surface with a given slope and orientation.
- **Sunrise, sunset, and solar transit**: the times at which the sun crosses the horizon and reaches its highest point.
- **Equation of time**: the difference between apparent solar time and mean solar time, in minutes.
## How it works
The algorithm proceeds through several stages:
### 1. Julian date calculation
The input date is converted to a Julian Date (JD), which is a continuous count of days since January 1, 4713 BC. This simplifies all subsequent time-dependent calculations. The Julian Ephemeris Day (JDE) is then computed by adding the delta_t correction (the difference between Terrestrial Time and UTC).
### 2. Earth heliocentric position
The Earth's position relative to the Sun is calculated using the VSOP87 theory (Variations Seculaires des Orbites Planetaires). This involves evaluating trigonometric series with over 60 terms for heliocentric longitude, plus smaller series for heliocentric latitude and radius vector.
The heliocentric longitude uses five polynomial coefficients (L0 through L4), each of which is a sum of terms of the form `A * cos(B + C * JME)` where JME is the Julian Ephemeris Millennium.
### 3. Geocentric position
The heliocentric coordinates (Earth's position relative to the Sun) are converted to geocentric coordinates (Sun's position as seen from Earth) by adding 180 degrees to the longitude and negating the latitude. The geocentric right ascension and declination are then computed.
### 4. Nutation and obliquity
Nutation (the periodic wobble of Earth's axis) is computed from a series of 63 terms based on five fundamental arguments: the mean elongation of the Moon, the mean anomaly of the Sun, the mean anomaly of the Moon, the Moon's argument of latitude, and the longitude of the ascending node of the Moon's orbit.
The true obliquity of the ecliptic (the tilt of Earth's axis) is the mean obliquity plus the nutation in obliquity.
### 5. Aberration and apparent position
Aberration is the apparent shift in a star's position caused by Earth's orbital motion and the finite speed of light. The correction is small (about 20 arc-seconds) but necessary for the stated accuracy.
### 6. Topocentric correction
The geocentric position is adjusted for the observer's actual location on Earth's surface (parallax correction). An observer at sea level sees a slightly different sun position than one at high elevation, and both differ from the geocentric center-of-Earth viewpoint.
### 7. Atmospheric refraction
The atmosphere bends sunlight, making the sun appear higher than its geometric position. The refraction correction is most significant near the horizon (about 0.57 degrees at sunrise/sunset) and negligible when the sun is high.
### 8. Rise, transit, and set
Sunrise, sunset, and solar transit are calculated by evaluating the sun's position at midnight, then using iterative approximation to find the exact times when the zenith angle crosses 90.8333 degrees (the standard value accounting for the sun's apparent diameter and atmospheric refraction at the horizon).
## Valid date range
The algorithm is valid for dates between -2000 and 6000 (i.e., 2000 BC to 6000 AD). Outside this range, the VSOP87 series lose accuracy and the SPA returns an error code.
## Accuracy
The stated uncertainty is +/- 0.0003 degrees for the period from -2000 to 6000. This is well within the requirements of any solar energy, astronomical, or navigational application.
For comparison:
- The sun's apparent diameter is about 0.53 degrees
- A solar panel tracking error of 1 degree reduces energy capture by roughly 0.015%
- Consumer GPS accuracy is typically 3-5 meters, which corresponds to roughly 0.00005 degrees of latitude
The SPA's accuracy far exceeds the precision of any practical measurement system it would be paired with.
## Input validation
The SPA validates all inputs and returns a non-zero error code if any are out of range:
| Parameter | Valid range |
| --- | --- |
| Year | -2000 to 6000 |
| Month | 1 to 12 |
| Day | 1 to 31 |
| Hour | 0 to 24 |
| Minute | 0 to 59 |
| Second | 0 to less than 60 |
| Timezone | -18 to 18 |
| Latitude | -90 to 90 |
| Longitude | -180 to 180 |
| Elevation | -6500000 or higher (meters) |
| Pressure | 0 to 5000 |
| Temperature | -273 to 6000 |
| Delta UT1 | -1 to 1 |
| Delta T | -8000 to 8000 |
| Slope | -360 to 360 |
| Azimuth rotation | -360 to 360 |
| Atmospheric refraction | -5 to 5 |
The solar-spa package propagates these error codes as thrown JavaScript errors.
## References
- Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." Solar Energy, 76(5), 577-589.
- Meeus, J. (1998). "Astronomical Algorithms." 2nd ed. Willmann-Bell.
- Bretagnon, P., Francou, G. (1988). "Planetary Theories in Rectangular and Spherical Variables: VSOP87 Solutions." Astronomy and Astrophysics, 202, 309-315.
- [NREL SPA Calculator](https://midcdmz.nrel.gov/spa/): Online calculator and C source distribution.
---
[Home](Home) · [Architecture](Architecture) · [API Reference](API-Reference) · [Validation and Benchmarks](Validation-and-Benchmarks)

87
.wiki/Performance.md Normal file
View file

@ -0,0 +1,87 @@
# Performance
## What WASM buys you
The SPA algorithm involves:
- Julian date conversion (integer and fractional components)
- Earth heliocentric longitude, latitude, and radius vector via trigonometric series (over 60 terms for longitude alone)
- Nutation in longitude and obliquity (63 terms each)
- Aberration correction
- Topocentric adjustments for observer position
- Atmospheric refraction correction
- Sunrise/sunset via iterative bisection (three full position calculations per call when computing rise/transit/set)
A single `SPA_ALL` call executes roughly 4,000 floating-point operations. WASM runs these at near-native speed because the code compiles to hardware-optimized floating-point instructions without JavaScript's JIT warm-up or type-checking overhead.
For a single call, the difference between WASM and a pure JavaScript implementation is small (both are fast enough). The gap widens with volume. Computing solar positions for 10,000 locations, or running a sun-tracking animation at 60fps, is where WASM execution speed becomes measurable.
## Initialization cost
The first `spa()` call pays a one-time initialization cost: decoding ~40KB of base64 WASM, compiling the module, and instantiating it. This takes approximately 5 to 15 milliseconds depending on the runtime and hardware.
Subsequent calls skip initialization entirely and go straight to the C function via the cached `cwrap` binding. Each call allocates an 80-byte struct, runs the computation, reads the result, and frees the struct. The per-call overhead from the JavaScript wrapper is negligible.
Use `init()` at application startup to pay the initialization cost early:
```js
import { init } from 'solar-spa';
await init(); // ~5-15ms, happens once
```
## Memory footprint
The WASM module uses 1MB of fixed memory (`INITIAL_MEMORY=1048576`). This includes:
- 64KB stack
- The compiled code segment
- Heap for `malloc`/`free` of result structs (80 bytes each, freed immediately after reading)
Memory growth is disabled (`ALLOW_MEMORY_GROWTH=0`). This means the ArrayBuffer backing WASM memory is never detached or reallocated, which avoids a class of subtle bugs in long-running applications and allows the engine to optimize memory access patterns.
1MB is conservative. SPA does not accumulate state. Each call allocates one struct, reads it, and frees it. The heap utilization at any point is a few hundred bytes at most.
## Function code optimization
Not all callers need every output. The `function` option controls how much work the SPA does:
| Code | Computation | Relative cost |
| --- | --- | --- |
| `SPA_ZA` (0) | Zenith and azimuth | ~1x |
| `SPA_ZA_INC` (1) | + incidence angle | ~1x (incidence is cheap) |
| `SPA_ZA_RTS` (2) | + rise/transit/set | ~3x (three position evaluations) |
| `SPA_ALL` (3) | All outputs | ~3x |
The sunrise/sunset calculation is the expensive part. It evaluates the full position algorithm three times (for transit, sunrise, and sunset). If you only need the current sun position, use `SPA_ZA` for a roughly 3x speed improvement.
## Build optimizations
The Emscripten build uses:
- `-O3`: Highest optimization level. Aggressive inlining, loop unrolling, vectorization.
- `-flto`: Link-time optimization. The compiler sees both `spa.c` and `spa_wrapper.c` as a single compilation unit, enabling cross-file inlining and dead code elimination.
- `-sASSERTIONS=0`: Strips all runtime assertions from the Emscripten glue code.
- `-sDISABLE_EXCEPTION_CATCHING=1`: Removes C++ exception handling support. SPA is pure C.
- `-sNO_FILESYSTEM=1`: Removes the virtual filesystem API (~15KB of JavaScript).
- `-sSTACK_SIZE=65536`: Reduces the stack from the default 5MB to 64KB. SPA is not recursive and uses minimal stack space.
These flags together produce a ~60KB output file, down from the ~150KB that a default Emscripten build would generate.
## When to use solar-spa vs nrel-spa
| Scenario | Recommended |
| --- | --- |
| Single position lookup (e.g., sunrise for today) | Either. Both are fast enough |
| Batch computation (hundreds or thousands of positions) | solar-spa (WASM) |
| Animation or real-time tracking | solar-spa (WASM) |
| Synchronous API required | [nrel-spa](https://github.com/acamarata/nrel-spa) (pure JS, sync) |
| Environments without WASM support | [nrel-spa](https://github.com/acamarata/nrel-spa) |
| Minimal dependency footprint | [nrel-spa](https://github.com/acamarata/nrel-spa) (zero deps, ~30KB) |
Both packages implement the same NREL algorithm and produce identical results within floating-point rounding tolerance.
For measured benchmark numbers, see [Validation and Benchmarks](Validation-and-Benchmarks).
---
[Home](Home) · [Architecture](Architecture) · [API Reference](API-Reference) · [Validation and Benchmarks](Validation-and-Benchmarks)

View file

@ -0,0 +1,230 @@
# Validation and Benchmarks
Pre-release validation of the solar-spa v2.0.0 WASM implementation. All results were generated by `validate.mjs` in the repository root.
## Overview
100 scenarios covering seven categories:
| Category | Scenarios | Description |
| --- | --- | --- |
| Cities worldwide | 1-40 | 20 cities across every continent, summer and winter solstice |
| Boundary conditions | 41-55 | Poles, equator, date line, extreme elevation, date range limits |
| Polar regions | 56-65 | Polar day, polar night, midnight sun, Antarctic stations |
| Time edge cases | 66-75 | Midnight, dawn, dusk, leap year, fractional seconds |
| Function code consistency | 76-80 | All four function codes produce identical zenith/azimuth |
| Atmospheric conditions | 81-90 | Pressure, temperature, refraction, vacuum, high altitude |
| Historical/future dates | 91-100 | Year -2000 to 6000, Gregorian switch, Apollo era |
**Result: 100/100 passed.**
## Category 1: Cities Worldwide (1-40)
20 major cities tested at both the June 21 and December 21 solstices, 2025, local noon. Each scenario validates that the zenith angle falls within a physically reasonable range for the latitude and season.
| # | City | Season | Zenith | Azimuth | Time |
| --- | --- | --- | --- | --- | --- |
| 1 | New York | Summer | 21.12 | 140.47 | 242us |
| 2 | New York | Winter | 65.35 | 166.30 | 168us |
| 3 | London | Summer | 30.52 | 150.96 | 110us |
| 4 | London | Winter | 75.98 | 166.15 | 110us |
| 5 | Tokyo | Summer | 12.77 | 197.73 | 102us |
| 6 | Tokyo | Winter | 59.29 | 185.50 | 99us |
| 7 | Sydney | Summer | 57.29 | 359.16 | 100us |
| 8 | Sydney | Winter | 10.54 | 351.37 | 112us |
| 9 | Cairo | Summer | 6.64 | 186.17 | 115us |
| 10 | Cairo | Winter | 53.49 | 181.94 | 101us |
| 11 | Mumbai | Summer | 10.35 | 63.31 | 109us |
| 12 | Mumbai | Winter | 43.43 | 167.76 | 96us |
| 13 | Sao Paulo | Summer | 47.02 | 2.64 | 94us |
| 14 | Sao Paulo | Winter | 1.10 | 84.39 | 137us |
| 15 | Moscow | Summer | 32.82 | 166.65 | 71us |
| 16 | Moscow | Winter | 79.33 | 173.55 | 56us |
| 17 | Beijing | Summer | 16.81 | 167.09 | 54us |
| 18 | Beijing | Winter | 63.38 | 176.82 | 50us |
| 19 | Nairobi | Summer | 26.11 | 18.24 | 56us |
| 20 | Nairobi | Winter | 23.37 | 161.93 | 55us |
| 21 | Reykjavik | Summer | 43.28 | 149.34 | 49us |
| 22 | Reykjavik | Winter | 88.81 | 160.36 | 47us |
| 23 | Singapore | Summer | 27.34 | 34.86 | 34us |
| 24 | Singapore | Winter | 29.10 | 149.34 | 25us |
| 25 | Cape Town | Summer | 58.47 | 12.97 | 22us |
| 26 | Cape Town | Winter | 14.29 | 45.72 | 22us |
| 27 | Buenos Aires | Summer | 59.49 | 14.77 | 22us |
| 28 | Buenos Aires | Winter | 15.87 | 48.72 | 21us |
| 29 | Dubai | Summer | 5.04 | 109.42 | 22us |
| 30 | Dubai | Winter | 48.80 | 174.81 | 25us |
| 31 | Toronto | Summer | 25.98 | 134.65 | 22us |
| 32 | Toronto | Winter | 69.27 | 161.43 | 24us |
| 33 | Mexico City | Summer | 9.80 | 64.18 | 23us |
| 34 | Mexico City | Winter | 43.69 | 168.40 | 23us |
| 35 | Seoul | Summer | 15.88 | 150.42 | 46us |
| 36 | Seoul | Winter | 61.38 | 172.14 | 31us |
| 37 | Rome | Summer | 23.76 | 135.39 | 26us |
| 38 | Rome | Winter | 67.18 | 163.05 | 24us |
| 39 | Anchorage | Summer | 43.13 | 137.26 | 25us |
| 40 | Anchorage | Winter | 87.67 | 153.13 | 23us |
Note: "Summer" and "Winter" refer to Northern Hemisphere seasons. For Southern Hemisphere cities (Sydney, Sao Paulo, Cape Town, Buenos Aires, Nairobi), the seasons are reversed. A high zenith in "summer" (June) for Sydney is correct because June is winter there.
## Category 2: Boundary Conditions (41-55)
Tests at the mathematical limits of the algorithm's input domain.
| # | Scenario | Zenith | Note |
| --- | --- | --- | --- |
| 41 | North Pole, June solstice | 66.53 | Midnight sun: zenith < 90 |
| 42 | North Pole, Dec solstice | 113.44 | Polar night: zenith > 90 |
| 43 | South Pole, Dec solstice | 66.53 | Midnight sun (southern summer) |
| 44 | South Pole, June solstice | 113.44 | Polar night (southern winter) |
| 45 | Equator, March equinox | 1.84 | Near-overhead sun |
| 46 | Equator, Sept equinox | 1.84 | Near-overhead sun |
| 47 | Equator, June solstice | 23.44 | Sun 23.44 north of equator |
| 48 | Equator, Dec solstice | 23.44 | Sun 23.44 south of equator |
| 49 | Date line +180 | 23.44 | Longitude boundary |
| 50 | Date line -180 | 23.43 | Longitude boundary |
| 51 | Mt Everest (8849m) | 4.55 | Thin atmosphere (314 mbar, -20C) |
| 52 | Dead Sea (-430m) | 11.95 | Dense atmosphere (1065 mbar, 40C) |
| 53 | Year -2000 | 7.90 | Earliest valid date |
| 54 | Year 6000 | 7.27 | Latest valid date |
| 55 | Year 6001 | throws | Correctly rejects out-of-range year |
The equinox results confirm the algorithm's accuracy: a zenith of 1.84 degrees at the equator on the March equinox at solar noon on the prime meridian is consistent with the small angular offset between the vernal equinox and the actual date (March 20 vs. the instant the sun crosses the celestial equator).
## Category 3: Polar Regions (56-65)
Polar day/night conditions test the algorithm's handling of non-standard sunrise/sunset scenarios.
| # | Location | Condition | Zenith |
| --- | --- | --- | --- |
| 56 | Tromso, Norway (69.6N) | Polar day (June) | 46.70 |
| 57 | Tromso, Norway | Polar night (Dec) | 93.14 |
| 58 | Murmansk, Russia (69.0N) | Polar day (June) | 46.12 |
| 59 | Murmansk, Russia | Polar night (Dec) | 92.77 |
| 60 | Utqiagvik, AK (71.3N) | Polar day (June) | 52.32 |
| 61 | Utqiagvik, AK | Polar night (Dec) | 95.90 |
| 62 | McMurdo Station (-77.9S) | Summer (Dec) | 55.95 |
| 63 | McMurdo Station | Winter (June) | 101.62 |
| 64 | Svalbard (78.2N) | Midnight sun (June, 00:00) | 77.90 |
| 65 | South Pole Station (-90S) | Summer (Jan) | 67.01 |
Scenario 64 is notable: at Svalbard at midnight on the June solstice, the sun is still 12.1 degrees above the horizon (90 - 77.9 = 12.1). This is correct for 78N latitude during continuous polar daylight.
## Category 4: Time Edge Cases (66-75)
| # | Scenario | Zenith | Note |
| --- | --- | --- | --- |
| 66 | Exact midnight UTC (London, June) | 105.05 | Sun well below horizon |
| 67 | Dawn, 5 AM summer London | 88.47 | Near horizon |
| 68 | Dusk, 9 PM summer London | 87.93 | Near horizon |
| 69 | Solar noon NYC (13:00 EDT) | 17.28 | Azimuth 181.6 (nearly due south) |
| 70 | UTC midnight Jan 1, equator | 156.99 | Deep below horizon |
| 71 | Fractional seconds | 18.24 | Handles sub-minute times |
| 72 | End of day 23:59 | 114.40 | Late night |
| 73 | Feb 29 leap year (2024) | 48.33 | Leap day handled correctly |
| 74 | Prime meridian equator noon | 1.84 | Most symmetric case |
| 75 | New Year's Eve midnight | 162.37 | Deep below horizon |
## Category 5: Function Code Consistency (76-80)
All four function codes (`SPA_ZA`, `SPA_ZA_INC`, `SPA_ZA_RTS`, `SPA_ALL`) produce identical zenith and azimuth values within 0.01 degree tolerance. This confirms that the function code parameter only affects which outputs are computed, not the core position algorithm.
| # | Test | Result |
| --- | --- | --- |
| 76 | SPA_ZA matches SPA_ALL | zenith identical |
| 77 | SPA_ZA_INC matches SPA_ALL | zenith, azimuth, incidence identical |
| 78 | SPA_ZA_RTS matches SPA_ALL | zenith, azimuth identical |
| 79 | SPA_ALL fields all finite | All 9 numeric fields populated |
| 80 | azimuth = (azimuth_astro + 180) % 360 | Navigational/astronomical consistency |
## Category 6: Atmospheric Conditions (81-90)
| # | Condition | Zenith | Note |
| --- | --- | --- | --- |
| 81 | Standard atmosphere (1013.25 mbar, 15C) | 21.12 | Reference baseline |
| 82 | Low pressure (300 mbar, -30C, 9000m) | 21.12 | High altitude conditions |
| 83 | High pressure (1100 mbar) | 21.12 | Dense atmosphere |
| 84 | Extreme cold (-40C) | 43.28 | Reykjavik test |
| 85 | Extreme heat (+50C) | 5.04 | Dubai test |
| 86 | Zero pressure (vacuum) | 21.13 | No refraction correction |
| 87 | Custom refraction (0 deg) | 21.12 | Override default 0.5667 |
| 88 | Custom refraction (2 deg) | 21.12 | Large refraction override |
| 89 | Pressure effect on zenith | varies | Low != high pressure confirmed |
| 90 | High elevation + low pressure | 4.55 | Everest base camp |
Scenario 89 confirms that different atmospheric pressures produce measurably different zenith values. The difference is small (sub-arc-second at high solar elevations) but present, which validates that the atmospheric refraction correction is active and working.
## Category 7: Historical and Future Dates (91-100)
The SPA is valid for years -2000 to 6000. These scenarios confirm correct behavior across the full range, with era-appropriate delta_t values.
| # | Year | Context | Zenith | delta_t |
| --- | --- | --- | --- | --- |
| 91 | 1000 CE | Medieval era | 17.25 | 1574s |
| 92 | 1582 | Gregorian calendar switch | 50.37 | 120s |
| 93 | 1900 | Turn of century | 25.45 | -3s |
| 94 | 1969 | Apollo 11 era, Cape Canaveral | 10.31 | 40s |
| 95 | 2050 | Near future | 21.13 | 93s |
| 96 | 2100 | Far future | 74.88 | 200s |
| 97 | 3000 | Distant future | 12.77 | 0s |
| 98 | 5000 | Deep future | 0.67 | 0s |
| 99 | -1000 (1001 BCE) | Ancient Athens | 15.07 | 0s |
| 100 | -2000 (earliest valid) | Ancient Cairo, winter solstice | 53.00 | 0s |
Delta_t values for historical dates follow published estimates. For dates beyond ~2050, delta_t is not well predicted. The scenarios use conservative values to avoid introducing error from the correction itself.
## Performance Benchmarks
Measured on Apple Silicon (Node.js), single-threaded.
### Per-Call Latency
From the 100 validation scenarios:
| Metric | Value |
| --- | --- |
| Min | 7us |
| Max | 242us |
| Mean | 46us |
| Median | 33us |
| P95 | 110us |
| P99 | 168us |
The first call is slowest (242us) because it includes WASM module initialization. Subsequent calls settle into the 20-40us range for `SPA_ALL` computations.
### Batch Throughput
10,000 consecutive calls to the same location and date:
| Function Code | Time | Throughput |
| --- | --- | --- |
| SPA_ALL | 201ms | ~50,000 calls/sec |
| SPA_ZA | 46ms | ~219,000 calls/sec |
`SPA_ZA` is roughly 4x faster than `SPA_ALL` because it skips the sunrise/sunset iterative solver, which requires three full position evaluations per call.
### Initialization
First-time WASM module initialization takes approximately 5-15ms depending on hardware and Node.js version. Subsequent `init()` calls return immediately (0.0ms). The module is initialized lazily on the first `spa()` call, or eagerly via `init()`.
### Comparison to Native C
The NREL reference C implementation, compiled natively with `-O2`, runs a single `SPA_ALL` call in approximately 5-10us on comparable hardware. The WASM version at ~20us (steady state, warm cache) represents roughly a 2-3x overhead from the WASM sandbox, JavaScript FFI, and memory copy for the result struct.
For most applications, the absolute latency difference (10-15us per call) is not measurable. The overhead becomes relevant only at extreme volumes (millions of calls per second), at which point native C would be the correct choice regardless.
## Running the Validation Suite
```bash
git clone https://github.com/acamarata/solar-spa.git
cd solar-spa
npm install
npm run build
node validate.mjs
```
The suite takes approximately 3 seconds to run, including the 20,000-call throughput benchmark.
---
[Home](Home) · [Performance](Performance) · [API Reference](API-Reference) · [NREL SPA Algorithm](NREL-SPA-Algorithm)

View file

@ -0,0 +1,132 @@
# WebAssembly in npm Packages
Shipping WASM inside an npm package is surprisingly difficult. This page documents the common problems and how solar-spa avoids them.
## The core problem: file resolution
A typical Emscripten build produces two files: a `.js` loader and a `.wasm` binary. At runtime, the JavaScript loader must locate the `.wasm` file to fetch and compile it. Emscripten's default behavior is to construct a URL or file path relative to the JavaScript file's location.
This breaks in almost every real-world environment:
**Node.js CommonJS:** Works by default. `__dirname` resolves correctly, and `fs.readFileSync` loads the binary. This is the only environment where the default works.
**Node.js ESM:** `__dirname` is not defined in ES modules. Emscripten's loader either crashes or falls back to a relative URL that does not resolve.
**Webpack:** Rewrites `require()` calls and moves files to the output bundle. The `.wasm` file might end up at a completely different path than the JavaScript expects. Webpack 5 has a `webassembly/async` experiment, but it does not help with Emscripten-generated loaders.
**Vite:** Serves files through its dev server with transformed URLs. The `.wasm` file's path at build time does not match its URL at runtime.
**Next.js (Pages Router):** Bundles server code with Webpack. The `.wasm` file needs to be copied to the correct output directory, which varies between `next build`, `next dev`, and `next export`.
**Next.js (App Router):** The server component runtime environment has different file resolution behavior than the pages router. A `.wasm` path that works in pages may not work in app.
**Web Workers:** No access to `__dirname`. The `import.meta.url` base may differ from the main thread.
**Cloudflare Workers / Edge runtimes:** No filesystem. No Node.js built-ins. WASM must be imported as an ES module binding or provided inline.
## The SINGLE_FILE solution
Emscripten's `-sSINGLE_FILE=1` flag eliminates the problem entirely. Instead of writing a separate `.wasm` file, the compiler encodes the WASM binary as a base64 string inside the JavaScript file. At runtime, the loader decodes the string to an `ArrayBuffer` and passes it to `WebAssembly.instantiate()`.
One file. No paths to resolve. No file to fetch. It works everywhere JavaScript runs.
The tradeoff is size: base64 encoding adds ~33% overhead. A 30KB WASM binary becomes ~40KB of base64 text inside the JavaScript file. For solar-spa, the total output is ~60KB. This is acceptable for a computational library, and considerably smaller than the debugging headaches it prevents.
## Other approaches and why they fall short
### Manual `locateFile` override
Emscripten supports a `locateFile` callback that lets the consumer specify where to find the `.wasm` file:
```js
const Module = await createModule({
locateFile: (path) => '/static/wasm/' + path
});
```
This works but pushes the problem onto every consumer. Each bundler and deployment environment needs a different `locateFile` implementation. Library authors cannot control how their package is bundled.
### Webpack `file-loader` or `asset/resource`
Webpack can be configured to copy `.wasm` files to the output directory and rewrite the URL:
```js
// webpack.config.js
module.exports = {
module: {
rules: [
{ test: /\.wasm$/, type: 'asset/resource' }
]
}
};
```
This works for Webpack but requires the consumer to modify their build configuration. It does nothing for Vite, Node.js, or non-bundled environments.
### Vite `?url` imports
Vite can import a WASM file as a URL:
```js
import wasmUrl from './spa.wasm?url';
```
This is Vite-specific syntax and does not work in any other environment.
### `import.meta.url`-based resolution
Some packages use `new URL('./spa.wasm', import.meta.url)` to resolve the WASM file relative to the JavaScript module. This works in browsers with native ES modules and in Node.js 20+ with ESM. It does not work in CommonJS, in older Node.js versions, or when bundlers transform the URL.
## MODULARIZE prevents global pollution
Without the `-sMODULARIZE=1` flag, Emscripten emits code that creates or mutates a global `Module` object. If two Emscripten-based packages exist in the same application, they overwrite each other's `Module`.
With `MODULARIZE`, the output is a factory function. Each call returns an independent instance. solar-spa calls the factory once and caches the result:
```ts
let _module: SpaWasmModule | null = null;
let _pending: Promise<void> | null = null;
export function init(): Promise<void> {
if (_module) return Promise.resolve();
if (_pending) return _pending;
_pending = createSpaModule().then((mod) => {
_module = mod;
_pending = null;
}).catch((err) => {
_pending = null; // Allow retry on next call
throw err;
});
return _pending;
}
```
## Other WASM packaging strategies
### wasm-pack (Rust)
The Rust ecosystem uses `wasm-pack`, which generates JavaScript bindings from Rust code. It produces `pkg/` directories with `.wasm` files and JavaScript glue. The same file resolution problems apply, though `wasm-pack` supports a `--target bundler` mode that assumes Webpack-compatible resolution.
### AssemblyScript
AssemblyScript compiles TypeScript-like code to WASM. It has its own loader that suffers from the same path resolution issues. The community recommendation is to use `fetch()` in browsers and `fs.readFileSync()` in Node.js, with the consumer responsible for the correct path.
### Inline approach (what solar-spa does)
For small to medium WASM binaries (under a few hundred KB), inlining as base64 is the most practical approach. It trades a 33% size increase for universal compatibility. The tradeoff stops making sense for large binaries (several MB), where the base64 overhead and parsing cost become significant.
## Summary
| Approach | Universal? | Consumer config? | Size overhead |
| --- | --- | --- | --- |
| Separate `.wasm` + default resolution | No | No | None |
| `locateFile` callback | Yes* | Yes (per-bundler) | None |
| Bundler-specific config | Per-bundler | Yes | None |
| `import.meta.url` | Partial | No | None |
| **SINGLE_FILE (base64 inline)** | **Yes** | **No** | **~33%** |
solar-spa uses the last approach. It works in every tested environment without any consumer configuration.
---
[Home](Home) · [Architecture](Architecture) · [Bundler Compatibility](Bundler-Compatibility)

94
CHANGELOG.md Normal file
View file

@ -0,0 +1,94 @@
# Changelog
## 2.0.0
Complete rewrite. The source is now TypeScript, compiled by tsup to dual CJS/ESM with generated declarations. The WASM binary and build pipeline are all new.
### Breaking Changes
- The default export is now an async function named `spa()` with a different signature. The old `spa(date, lat, lon, elev, temp, pressure, refraction)` positional API is replaced by `spa(date, lat, lon, options)` where options is an object.
- Return field `solar_noon` is now `suntransit` (matches the NREL SPA spec naming).
- Return field `azimuth` is now the navigational azimuth (eastward from north). The astronomical convention (westward from south) is available as `azimuth_astro`.
- New return fields: `azimuth_astro`, `eot`, `error_code`.
- Non-zero SPA error codes now throw an `Error` instead of returning silently.
- Invalid inputs (non-Date, non-finite coordinates, out-of-range values) now throw `TypeError` or `RangeError` before reaching the WASM layer.
- CJS entry point moved from `./solar-spa.js` to `./dist/index.cjs`.
- Minimum Node.js version is now 20.
### Added
- TypeScript source (`src/index.ts`, `src/types.ts`) with full type safety.
- Dual CJS/ESM output via tsup: `dist/index.cjs` and `dist/index.mjs`.
- Generated TypeScript declarations for both formats: `dist/index.d.ts` (CJS) and `dist/index.d.mts` (ESM).
- Input validation with descriptive `TypeError` and `RangeError` messages.
- `spaFormatted()` function that returns sunrise, sunset, and transit as `HH:MM:SS` strings. Returns `"N/A"` during polar day or polar night.
- `formatTime()` utility for converting fractional hours to time strings. Handles negative values and 24-hour overflow.
- `init()` function for optional eager WASM initialization. Retries on failure instead of caching a rejected promise.
- SPA function code constants (`SPA_ZA`, `SPA_ZA_INC`, `SPA_ZA_RTS`, `SPA_ALL`) exported as `const` types.
- `SpaFunctionCode` union type for the function option.
- Options object with named parameters: `timezone`, `elevation`, `pressure`, `temperature`, `delta_ut1`, `delta_t`, `slope`, `azm_rotation`, `atmos_refract`, `function`.
- Automatic timezone detection from the `Date` object when `timezone` is omitted.
- Named `OFFSET` constant for WASM struct byte offsets (replaces magic numbers).
- Test suite with 68 ESM assertions and 13 CJS assertions covering multiple locations, seasons, input validation, concurrent calls, boundary coordinates, polar regions, all function codes, and `formatTime` edge cases.
- 100-scenario validation suite (`validate.mjs`) covering 20 cities worldwide, boundary conditions, polar regions, time edge cases, all function codes, atmospheric variations, and historical dates from 2000 BCE to 6000 CE. Includes throughput benchmarks.
- GitHub Actions CI workflow (Node 20/22/24 matrix with type checking).
### Fixed
- `onRuntimeInitialized` race condition that caused the module to break after the first call. The v1 wrapper re-assigned the one-shot callback on every invocation, so only the first call ever resolved.
- WASM file path resolution failures in Webpack, Vite, and Next.js. The binary is now inlined as base64 via Emscripten's `SINGLE_FILE` flag, so there is no `.wasm` file to resolve at runtime.
- `cwrap` bindings are now created once during initialization, not on every call.
- Global `Module` object pollution. The Emscripten output is now modularized (`MODULARIZE=1`) and returns a factory function instead of mutating a global.
- `formatTime()` now returns `"N/A"` for negative values and wraps correctly at 24 hours.
- `init()` clears the pending promise on failure, allowing retry on subsequent calls.
### Changed
- Source rewritten in TypeScript, compiled by tsup to CJS + ESM with source maps.
- Recompiled WASM with Emscripten using `-O3 -flto`, `SINGLE_FILE`, `MODULARIZE`, `NO_FILESYSTEM`, and fixed 1MB memory. Output is ~60KB.
- C wrapper (`spa_wrapper.c`) extended to accept all SPA input parameters and return all output fields.
- Package exports map uses `types`-first ordering per TypeScript documentation.
- `sideEffects: false` declared for tree-shaking support.
### Removed
- `solar-spa.js` (old entry point).
- `lib/solar-spa.cjs` and `lib/solar-spa.mjs` (replaced by `dist/index.cjs` and `dist/index.mjs`).
- `index.d.ts` (hand-written declarations replaced by generated output in `dist/`).
- `spa.js` and `spa.wasm` (old Emscripten output, replaced by `wasm/spa-module.js`).
- `.npmignore` (replaced by the `files` field in `package.json`).
## 1.2.5
- Updated package.json repository field for npm listing.
- Updated README.
## 1.2.4
- Reverted bug fix from 1.2.1 that introduced a regression.
## 1.2.3
- Bug fix for `onRuntimeInitialized` callback timing.
## 1.2.2
- Rebuilt WASM targeting both web and Node.js environments.
- Removed dependency on `fs` module.
## 1.2.1
- Bug fix (reverted in 1.2.4).
## 1.2.0
- Directly linked WASM file to resolve path resolution bug.
## 1.1.0
- Added TypeScript declaration file.
- Added explicit WASM file path.
## 1.0.0
- Initial release. NREL SPA compiled to WebAssembly with a JavaScript wrapper.

45
LICENSE
View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Aric Camarata
Copyright (c) 2023-2026 Aric Camarata
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -19,3 +19,46 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-Party Notice: NREL Solar Position Algorithm
--------------------------------------------------
The files src/spa.c and src/spa.h contain the Solar Position Algorithm (SPA)
developed at the National Renewable Energy Laboratory (NREL). These files are
subject to their own license terms, reproduced below from the source:
Copyright (C) 2008-2011 Alliance for Sustainable Energy, LLC,
All Rights Reserved
The Solar Position Algorithm ("Software") is code in development prepared
by employees of the Alliance for Sustainable Energy, LLC, (hereinafter the
"Contractor"), under Contract No. DE-AC36-08GO28308 ("Contract") with the
U.S. Department of Energy (the "DOE"). The United States Government has
been granted for itself and others acting on its behalf a paid-up, non-
exclusive, irrevocable, worldwide license in the Software to reproduce,
prepare derivative works, and perform publicly and display publicly.
Beginning five (5) years after the date permission to assert copyright is
obtained from the DOE, and subject to any subsequent five (5) year
renewals, the United States Government is granted for itself and others
acting on its behalf a paid-up, non-exclusive, irrevocable, worldwide
license in the Software to reproduce, prepare derivative works, distribute
copies to the public, perform publicly and display publicly, and to permit
others to do so. If the Contractor ceases to make this computer software
available, it may be obtained from DOE's Office of Scientific and Technical
Information's Energy Science and Technology Software Center (ESTSC) at
P.O. Box 1020, Oak Ridge, TN 37831-1020.
THIS SOFTWARE IS PROVIDED BY THE CONTRACTOR "AS IS" AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE CONTRACTOR OR THE U.S. GOVERNMENT BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER,
INCLUDING BUT NOT LIMITED TO CLAIMS ASSOCIATED WITH THE LOSS OF DATA OR
PROFITS, WHICH MAY RESULT FROM AN ACTION IN CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS CLAIM THAT ARISES OUT OF OR IN CONNECTION WITH THE ACCESS, USE OR
PERFORMANCE OF THIS SOFTWARE.
Reference: Reda, I., Andreas, A. (2004). "Solar Position Algorithm for
Solar Radiation Applications." Solar Energy, 76(5), 577-589.
Original source: https://midcdmz.nrel.gov/spa/

196
README.md
View file

@ -1,73 +1,171 @@
# solar-spa
The solar-spa package provides a Node.js module for calculating solar position and related parameters using the National Renewable Energy Laboratory (NREL) Solar Position Algorithm (SPA). This implementation uses WebAssembly (WASM) to achieve high performance and accuracy.
[![npm version](https://img.shields.io/npm/v/solar-spa.svg)](https://www.npmjs.com/package/solar-spa)
[![CI](https://github.com/acamarata/solar-spa/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/solar-spa/actions/workflows/ci.yml)
[![license](https://img.shields.io/npm/l/solar-spa.svg)](https://github.com/acamarata/solar-spa/blob/main/LICENSE)
The SPA calculates the solar zenith angle, azimuth angle, incidence angle, sunrise time, sunset time, solar noon time, and sun transit altitude for a given date, time, and location.
NREL Solar Position Algorithm compiled to WebAssembly. Calculates solar zenith, azimuth, incidence angle, sunrise, sunset, solar noon, and equation of time for any location and date.
This is a direct WASM conversion, which has some limitations and bugs in real-world usage, especially when used as an NPM package or within frameworks like Next.js. This repository will remain available for those who need or want it, but it will no longer be maintained by me in favor of the newer **[nrel-spa](https://github.com/acamarata/nrel-spa/)** package going forward.
The algorithm is the [NREL SPA](https://midcdmz.nrel.gov/spa/) by Ibrahim Reda and Afshin Andreas, originally written in C. This package compiles the original C source to WASM via Emscripten and provides a TypeScript/JavaScript interface on top.
## Installation
To install the solar-spa package, use the following command:
```sh
npm install solar-spa
```
## Usage
## Quick Start
```
const spa = require('solar-spa');
```js
import { spa } from 'solar-spa';
// Define input parameters for a specific date, time, and location
const date = new Date(2023, 3, 1, 0, 0, 0); // April 1, 2023 at Midnight
const latitude = 40.7128; // Latitude of New York City, USA
const longitude = -74.0060; // Longitude of New York City, USA
const result = await spa(
new Date(2025, 5, 21, 12, 0, 0), // June 21, 2025 at noon
40.7128, // latitude (NYC)
-74.0060, // longitude
{ timezone: -4, elevation: 10 } // EDT (UTC-4), 10m elevation
);
// Optional input parameters (default values provided if not specified)
const elevation = 10; // Elevation in meters (approximately)
const temperature = 20; // Temperature (degrees Celsius)
const pressure = 1013.25; // Atmospheric pressure (millibars)
const refraction = 0.5667; // Atmospheric refraction (degrees)
// Call the 'spa' function and log the results
spa(date, latitude, longitude, elevation, temperature, pressure, refraction)
.then(result => {
console.log(result);
})
.catch(error => {
console.error(error);
});
console.log(result.zenith); // ~27 (degrees from vertical)
console.log(result.azimuth); // ~179 (degrees from north)
console.log(result.sunrise); // ~5.4 (fractional hours)
console.log(result.sunset); // ~20.5 (fractional hours)
```
## Example Output
CommonJS works too:
```
{
zenith: 132.82035808538367,
azimuth: 339.3841959764823,
incidence: 132.82035808538367,
sun_transit_alt: 53.91557045916343,
sunrise: 6.665306569794356,
solar_noon: 12.99818246332967,
sunset: 19.342862135890314
}
```js
const { spa } = require('solar-spa');
```
## Helper (function not included)
## API
If you would like to translate the sunrise, solar_noon, or sunset to a normal time output you can convert fractional hours to formatted time string like below:
### `spa(date, latitude, longitude, options?)`
```
function formatTime(hours) {
const milliseconds = hours * 60 * 60 * 1000;
const date = new Date(milliseconds);
return date.toISOString().substr(11, 12);
}
Returns a `Promise<SpaResult>` with raw numeric values.
**Parameters:**
| Name | Type | Description |
| --- | --- | --- |
| `date` | `Date` | Date and time for the calculation |
| `latitude` | `number` | Observer latitude, -90 to 90 (negative = south) |
| `longitude` | `number` | Observer longitude, -180 to 180 (negative = west) |
| `options` | `object` | Optional. See below |
**Options:**
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `timezone` | `number` | auto | Hours from UTC. Auto-detected from the Date object if omitted |
| `elevation` | `number` | `0` | Meters above sea level |
| `pressure` | `number` | `1013.25` | Atmospheric pressure in millibars |
| `temperature` | `number` | `15` | Temperature in Celsius |
| `delta_ut1` | `number` | `0` | UT1-UTC correction in seconds |
| `delta_t` | `number` | `67` | TT-UTC difference in seconds |
| `slope` | `number` | `0` | Surface slope in degrees |
| `azm_rotation` | `number` | `0` | Surface azimuth rotation in degrees |
| `atmos_refract` | `number` | `0.5667` | Atmospheric refraction in degrees |
| `function` | `number` | `3` | SPA function code (see below) |
**Result fields:**
| Field | Unit | Description |
| --- | --- | --- |
| `zenith` | degrees | Topocentric zenith angle |
| `azimuth` | degrees | Topocentric azimuth, eastward from north |
| `azimuth_astro` | degrees | Topocentric azimuth, westward from south |
| `incidence` | degrees | Surface incidence angle |
| `sunrise` | fractional hours | Local sunrise time |
| `sunset` | fractional hours | Local sunset time |
| `suntransit` | fractional hours | Solar noon |
| `sun_transit_alt` | degrees | Sun transit altitude |
| `eot` | minutes | Equation of time |
| `error_code` | integer | 0 on success |
### `spaFormatted(date, latitude, longitude, options?)`
Same as `spa()`, but `sunrise`, `sunset`, and `suntransit` are returned as `HH:MM:SS` strings. Returns `"N/A"` for these fields during polar day or polar night.
### `formatTime(hours)`
Converts fractional hours to an `HH:MM:SS` string. Returns `"N/A"` for non-finite or negative values (polar night/day scenarios).
### `init()`
Pre-initializes the WASM module. Optional. The module initializes automatically on the first `spa()` call. Useful if you want to pay the initialization cost at application startup rather than on the first calculation.
### Function Codes
| Constant | Value | Computes |
| --- | --- | --- |
| `SPA_ZA` | `0` | Zenith and azimuth |
| `SPA_ZA_INC` | `1` | Zenith, azimuth, and incidence |
| `SPA_ZA_RTS` | `2` | Zenith, azimuth, and rise/transit/set |
| `SPA_ALL` | `3` | All output values |
## Architecture
The package has three layers:
1. **C layer** (`src/spa.c`, `src/spa_wrapper.c`): The original NREL SPA algorithm with a thin wrapper that exposes a flat function signature suitable for WASM.
2. **WASM layer** (`wasm/spa-module.js`): Compiled with Emscripten using `-sSINGLE_FILE=1`, which inlines the WASM binary as base64. No external `.wasm` file to resolve. This eliminates the bundler path-resolution issues that plague most WASM packages.
3. **TypeScript layer** (`src/index.ts`, `src/types.ts`): Written in TypeScript and compiled by tsup to both CJS and native ESM with generated declaration files. Singleton WASM initialization, cached `cwrap` bindings, input validation, and named struct offset constants.
### Build Flags
The WASM binary is compiled with:
- `-O3 -flto`: Maximum optimization with link-time optimization
- `-sSINGLE_FILE=1`: WASM inlined as base64 (no file path resolution)
- `-sMODULARIZE=1`: No global `Module` pollution
- `-sNO_FILESYSTEM=1`: No filesystem API bundled (saves ~15KB)
- `-sINITIAL_MEMORY=1048576`: 1MB fixed memory (SPA needs very little)
- `-sASSERTIONS=0`: No debug assertions in production
- `-sENVIRONMENT='node,web,worker'`: Works in Node.js, browsers, and web workers
## Compatibility
Tested in:
- Node.js 20+
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Webpack 5
- Vite
- Next.js (both Pages and App Router)
- Web Workers
The `SINGLE_FILE` approach eliminates the `.wasm` file resolution problem that breaks most WASM packages in bundlers. There is no external binary to locate at runtime.
## TypeScript
Full type definitions are generated from the TypeScript source and included with the package. Import types directly:
```ts
import { spa, SPA_ALL } from 'solar-spa';
import type { SpaResult, SpaOptions } from 'solar-spa';
```
# Repository
The source code for this package is available on GitHub: github.com/acamarata/solar-spa
## Documentation
# License
MIT
See the [Wiki](https://github.com/acamarata/solar-spa/wiki) for detailed documentation: API reference, architecture, performance, bundler compatibility, validation benchmarks, and more.
## Related
- [nrel-spa](https://github.com/acamarata/nrel-spa): Pure JavaScript port of the same algorithm. No WASM dependency, synchronous API. Better choice if you do not need WASM-level performance.
- [NREL SPA](https://midcdmz.nrel.gov/spa/): The original algorithm specification and C source.
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer time calculator built on nrel-spa.
## Acknowledgments
This package includes the Solar Position Algorithm (SPA) developed at the National Renewable Energy Laboratory (NREL) by Ibrahim Reda and Afshin Andreas. The C source files `spa.c` and `spa.h` are copyright Alliance for Sustainable Energy, LLC (2008-2011) and are distributed under the terms included in those files.
> Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." *Solar Energy*, 76(5), 577-589. [doi:10.1016/j.solener.2003.12.003](https://doi.org/10.1016/j.solener.2003.12.003)
The original C source and an online calculator are available at [midcdmz.nrel.gov/spa](https://midcdmz.nrel.gov/spa/).
## License
MIT (wrapper, TypeScript source, and build tooling). The NREL SPA C source (`src/spa.c`, `src/spa.h`) is subject to its own terms; see the notice in those files.

21
index.d.ts vendored
View file

@ -1,21 +0,0 @@
// index.d.ts
declare module 'solar-spa' {
export default function spa(
date: Date,
latitude: number,
longitude: number,
elevation?: number,
temperature?: number,
pressure?: number,
refraction?: number
): Promise<{
zenith: number;
azimuth: number;
incidence: number;
sunrise: number;
sunset: number;
solar_noon: number;
sun_transit_alt: number;
}>;
}

View file

@ -1,16 +1,74 @@
{
"name": "solar-spa",
"version": "1.2.5",
"description": "NREL Solar Position Algorithm (SPA) in WebAssembly",
"main": "solar-spa.js",
"types": "index.d.ts",
"scripts": {
"test": "node test.js"
},
"author": "Ali Camarata",
"version": "2.0.0",
"description": "NREL Solar Position Algorithm (SPA) compiled to WebAssembly. High-performance solar position, sunrise, sunset, and solar noon calculations.",
"author": "Aric Camarata",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"files": [
"dist/",
"wasm/",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"build:wasm": "emcc src/spa.c src/spa_wrapper.c -O3 -flto --no-entry -sMODULARIZE=1 -sEXPORT_NAME=createSpaModule -sSINGLE_FILE=1 -sEXPORTED_FUNCTIONS='[\"_spa_calculate_wrapper\",\"_spa_free_result\",\"_malloc\",\"_free\"]' -sEXPORTED_RUNTIME_METHODS='[\"cwrap\",\"getValue\"]' -sALLOW_MEMORY_GROWTH=0 -sINITIAL_MEMORY=1048576 -sSTACK_SIZE=65536 -sENVIRONMENT='node,web,worker' -sNO_FILESYSTEM=1 -sASSERTIONS=0 -sDISABLE_EXCEPTION_CATCHING=1 -sWASM_BIGINT=0 -o wasm/spa-module.js",
"build:ts": "tsup",
"build": "pnpm run build:wasm && pnpm run build:ts",
"typecheck": "tsc --noEmit",
"pretest": "pnpm run build:ts",
"test": "node test.mjs && node test-cjs.cjs",
"validate": "node validate.mjs",
"prepublishOnly": "pnpm run build:ts"
},
"keywords": [
"solar",
"spa",
"nrel",
"wasm",
"webassembly",
"sunrise",
"sunset",
"solar-noon",
"zenith",
"azimuth",
"solar-position",
"astronomy"
],
"repository": {
"type": "git",
"url": "https://github.com/acamarata/solar-spa.git"
},
"homepage": "https://github.com/acamarata/solar-spa#readme",
"bugs": {
"url": "https://github.com/acamarata/solar-spa/issues"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@types/node": "^25.3.0",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
}
}

925
pnpm-lock.yaml Normal file
View file

@ -0,0 +1,925 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^25.3.0
version: 25.3.0
tsup:
specifier: ^8.5.1
version: 8.5.1(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.59.0':
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.59.0':
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.59.0':
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.59.0':
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.59.0':
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.59.0':
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.59.0':
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.59.0':
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.59.0':
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.59.0':
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
esbuild: '>=0.18'
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
yaml:
optional: true
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
'@microsoft/api-extractor': ^7.36.0
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.5.0'
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
snapshots:
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
'@rollup/rollup-android-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-x64@4.59.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.59.0':
optional: true
'@rollup/rollup-freebsd-x64@4.59.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.59.0':
optional: true
'@rollup/rollup-openbsd-x64@4.59.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.59.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@types/estree@1.0.8': {}
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
acorn@8.16.0: {}
any-promise@1.3.0: {}
bundle-require@5.1.0(esbuild@0.27.3):
dependencies:
esbuild: 0.27.3
load-tsconfig: 0.2.5
cac@6.7.14: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
commander@4.1.1: {}
confbox@0.1.8: {}
consola@3.4.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.0
rollup: 4.59.0
fsevents@2.3.3:
optional: true
joycon@3.1.1: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mlly@1.8.0:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
ms@2.1.3: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
object-assign@4.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
pirates@4.0.7: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
postcss-load-config@6.0.1:
dependencies:
lilconfig: 3.1.3
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.59.0
'@rollup/rollup-android-arm64': 4.59.0
'@rollup/rollup-darwin-arm64': 4.59.0
'@rollup/rollup-darwin-x64': 4.59.0
'@rollup/rollup-freebsd-arm64': 4.59.0
'@rollup/rollup-freebsd-x64': 4.59.0
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
'@rollup/rollup-linux-arm64-gnu': 4.59.0
'@rollup/rollup-linux-arm64-musl': 4.59.0
'@rollup/rollup-linux-loong64-gnu': 4.59.0
'@rollup/rollup-linux-loong64-musl': 4.59.0
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
'@rollup/rollup-linux-ppc64-musl': 4.59.0
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
'@rollup/rollup-linux-riscv64-musl': 4.59.0
'@rollup/rollup-linux-s390x-gnu': 4.59.0
'@rollup/rollup-linux-x64-gnu': 4.59.0
'@rollup/rollup-linux-x64-musl': 4.59.0
'@rollup/rollup-openbsd-x64': 4.59.0
'@rollup/rollup-openharmony-arm64': 4.59.0
'@rollup/rollup-win32-arm64-msvc': 4.59.0
'@rollup/rollup-win32-ia32-msvc': 4.59.0
'@rollup/rollup-win32-x64-gnu': 4.59.0
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
source-map@0.7.6: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
commander: 4.1.1
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.7
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
thenify@3.3.1:
dependencies:
any-promise: 1.3.0
tinyexec@0.3.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
tsup@8.5.1(typescript@5.9.3):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.3)
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
debug: 4.4.3
esbuild: 0.27.3
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1
resolve-from: 5.0.0
rollup: 4.59.0
source-map: 0.7.6
sucrase: 3.35.1
tinyexec: 0.3.2
tinyglobby: 0.2.15
tree-kill: 1.2.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- jiti
- supports-color
- tsx
- yaml
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@7.18.2: {}

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View file

@ -1,62 +0,0 @@
// solar-spa.js
const spaModule = require('./spa.js');
module.exports = function spa(
date,
latitude,
longitude,
elevation = 0,
temperature = 20,
pressure = 1013.25,
refraction = 0.5667
) {
return new Promise((resolve) => {
spaModule.onRuntimeInitialized = function () {
const spa_calculate = spaModule.cwrap(
'spa_calculate_wrapper',
'number',
[
'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number'
]
);
const spa_free_result = spaModule.cwrap(
'spa_free_result',
null,
['number']
);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = date.getHours();
const minute = date.getMinutes();
const second = date.getSeconds();
const timezone = -date.getTimezoneOffset() / 60;
const slope = 0;
const azm_rotation = 0;
const resultPtr = spa_calculate(
year, month, day, hour, minute, second, timezone,
latitude, longitude, elevation, pressure, temperature,
slope, azm_rotation, refraction
);
const result = {
zenith: spaModule.getValue(resultPtr, 'double'),
azimuth: spaModule.getValue(resultPtr + 8, 'double'),
incidence: spaModule.getValue(resultPtr + 16, 'double'),
sunrise: spaModule.getValue(resultPtr + 24, 'double'),
sunset: spaModule.getValue(resultPtr + 32, 'double'),
solar_noon: spaModule.getValue(resultPtr + 40, 'double'),
sun_transit_alt: spaModule.getValue(resultPtr + 48, 'double'),
};
spa_free_result(resultPtr);
resolve(result);
};
});
};

1781
spa.js

File diff suppressed because it is too large Load diff

BIN
spa.wasm

Binary file not shown.

217
src/index.ts Normal file
View file

@ -0,0 +1,217 @@
import type { SpaWasmModule, SpaResult, SpaFormattedResult, SpaOptions } from './types.js';
export type { SpaOptions, SpaResult, SpaFormattedResult } from './types.js';
export { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './types.js';
export type { SpaFunctionCode } from './types.js';
import { SPA_ALL } from './types.js';
// The WASM module is Emscripten CJS output. In ESM builds, tsup injects a
// createRequire-based __require shim via the banner option (see tsup.config.ts).
// In CJS builds, require() is natively available.
declare const __require: NodeRequire;
const _loadModule = typeof __require === 'function' ? __require : require;
const createSpaModule: () => Promise<SpaWasmModule> = _loadModule('../wasm/spa-module.js');
// Singleton: the WASM module initializes once, all calls share it.
let _module: SpaWasmModule | null = null;
let _pending: Promise<void> | null = null;
let _calculate: ((...args: number[]) => number) | null = null;
let _free: ((ptr: number) => void) | null = null;
// Result struct layout (10 fields, 9 doubles + 1 int32):
// offset 0: zenith (f64)
// offset 8: azimuth_astro (f64)
// offset 16: azimuth (f64)
// offset 24: incidence (f64)
// offset 32: sunrise (f64)
// offset 40: sunset (f64)
// offset 48: suntransit (f64)
// offset 56: sun_transit_alt (f64)
// offset 64: eot (f64)
// offset 72: error_code (i32)
const OFFSET = {
zenith: 0,
azimuth_astro: 8,
azimuth: 16,
incidence: 24,
sunrise: 32,
sunset: 40,
suntransit: 48,
sun_transit_alt: 56,
eot: 64,
error_code: 72,
} as const;
/**
* Initialize the WASM module. Returns a cached promise on repeat calls.
* Safe to call multiple times. If initialization fails, subsequent calls
* will retry rather than returning the failed promise.
*/
export function init(): Promise<void> {
if (_module) return Promise.resolve();
if (_pending) return _pending;
_pending = createSpaModule().then((mod: SpaWasmModule) => {
_module = mod;
_calculate = mod.cwrap('spa_calculate_wrapper', 'number', [
'number', 'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number', 'number',
]) as (...args: number[]) => number;
_free = mod.cwrap('spa_free_result', null, ['number']) as (ptr: number) => void;
_pending = null;
}).catch((err: unknown) => {
_pending = null;
throw err;
});
return _pending;
}
/**
* Format fractional hours to HH:MM:SS string.
* Returns "N/A" for non-finite or negative values (polar night/day scenarios).
*/
export function formatTime(hours: number): string {
if (!isFinite(hours) || hours < 0) return 'N/A';
const totalSec = Math.round(hours * 3600);
// Wrap at 24h: values near midnight can round to 24:00:00
const h = Math.floor(totalSec / 3600) % 24;
const rem = totalSec - Math.floor(totalSec / 3600) * 3600;
const m = Math.floor(rem / 60);
const s = rem - m * 60;
return (
String(h).padStart(2, '0') + ':' +
String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0')
);
}
/** Read the result struct from WASM memory and free it. */
function readResult(ptr: number): SpaResult {
const m = _module!;
const result: SpaResult = {
zenith: m.getValue(ptr + OFFSET.zenith, 'double'),
azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, 'double'),
azimuth: m.getValue(ptr + OFFSET.azimuth, 'double'),
incidence: m.getValue(ptr + OFFSET.incidence, 'double'),
sunrise: m.getValue(ptr + OFFSET.sunrise, 'double'),
sunset: m.getValue(ptr + OFFSET.sunset, 'double'),
suntransit: m.getValue(ptr + OFFSET.suntransit, 'double'),
sun_transit_alt: m.getValue(ptr + OFFSET.sun_transit_alt, 'double'),
eot: m.getValue(ptr + OFFSET.eot, 'double'),
error_code: m.getValue(ptr + OFFSET.error_code, 'i32'),
};
_free!(ptr);
return result;
}
/**
* Validate that a value is a finite number, throwing a clear error if not.
* @internal
*/
function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== 'number' || !isFinite(value)) {
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value === 'number' ? value : typeof value}`);
}
}
/**
* Compute solar position for the given parameters.
*
* @param date - Date and time for the calculation
* @param latitude - Observer latitude in degrees (-90 to 90)
* @param longitude - Observer longitude in degrees (-180 to 180)
* @param options - Optional parameters
* @returns Solar position result with all computed values
*/
export async function spa(
date: Date,
latitude: number,
longitude: number,
options?: SpaOptions,
): Promise<SpaResult> {
// Input validation
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError('SPA: date must be a valid Date object');
}
assertFiniteNumber(latitude, 'latitude');
assertFiniteNumber(longitude, 'longitude');
if (latitude < -90 || latitude > 90) {
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
}
if (longitude < -180 || longitude > 180) {
throw new RangeError(`SPA: longitude must be between -180 and 180, got ${longitude}`);
}
await init();
const opts = options ?? {};
const tz = opts.timezone ?? -(date.getTimezoneOffset() / 60);
const ptr = _calculate!(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
tz,
latitude,
longitude,
opts.elevation ?? 0,
opts.pressure ?? 1013.25,
opts.temperature ?? 15,
opts.delta_ut1 ?? 0,
opts.delta_t ?? 67,
opts.slope ?? 0,
opts.azm_rotation ?? 0,
opts.atmos_refract ?? 0.5667,
opts.function ?? SPA_ALL,
);
if (!ptr) {
throw new Error('SPA: memory allocation failed');
}
const result = readResult(ptr);
if (result.error_code !== 0) {
throw new Error('SPA: calculation failed (error code ' + result.error_code + ')');
}
return result;
}
/**
* Compute solar position and return formatted time strings.
*
* Same parameters as spa(). Returns sunrise, sunset, and suntransit
* as HH:MM:SS strings instead of fractional hours.
*/
export async function spaFormatted(
date: Date,
latitude: number,
longitude: number,
options?: SpaOptions,
): Promise<SpaFormattedResult> {
const result = await spa(date, latitude, longitude, options);
return {
zenith: result.zenith,
azimuth_astro: result.azimuth_astro,
azimuth: result.azimuth,
incidence: result.incidence,
sunrise: formatTime(result.sunrise),
sunset: formatTime(result.sunset),
suntransit: formatTime(result.suntransit),
sun_transit_alt: result.sun_transit_alt,
eot: result.eot,
error_code: result.error_code,
};
}
export default spa;

View file

@ -1,77 +1,94 @@
// src/spa_wrapper.c
/*
* spa_wrapper.c
*
* Thin wrapper around NREL's spa_calculate() for use from JavaScript
* via Emscripten/WebAssembly. Exposes a flat function signature that
* maps directly to cwrap() on the JS side.
*
* The wrapper allocates a result struct on the heap, fills it from the
* spa_data output fields, and returns the pointer. The caller is
* responsible for reading the doubles and calling spa_free_result().
*
* Copyright (c) 2023-2026 Aric Camarata. MIT License.
*/
#include "spa.h"
#include <stdlib.h> // For malloc and free
#include <stdlib.h>
typedef struct {
double zenith;
double azimuth_astro;
double azimuth;
double incidence;
double sunrise;
double sunset;
double solar_noon;
double suntransit;
double sun_transit_alt;
double eot;
int error_code;
} spa_result;
spa_result* spa_calculate_wrapper(
spa_result *spa_calculate_wrapper(
int year, int month, int day,
int hour, int minute, double second,
double timezone,
double latitude, double longitude, double elevation,
double pressure, double temperature,
double slope, double azm_rotation, double atmos_refract)
double delta_ut1, double delta_t,
double slope, double azm_rotation, double atmos_refract,
int function_code)
{
// Allocate memory for the result
spa_result* result = (spa_result*)malloc(sizeof(spa_result));
spa_result *result = (spa_result *)malloc(sizeof(spa_result));
if (!result) return NULL;
spa_data spa;
spa.year = year;
spa.month = month;
spa.day = day;
spa.hour = hour;
spa.minute = minute;
spa.second = second;
spa.timezone = timezone;
spa.latitude = latitude;
spa.longitude = longitude;
spa.elevation = elevation;
spa.year = year;
spa.month = month;
spa.day = day;
spa.hour = hour;
spa.minute = minute;
spa.second = second;
spa.timezone = timezone;
spa.latitude = latitude;
spa.longitude = longitude;
spa.elevation = elevation;
spa.pressure = pressure;
spa.temperature = temperature;
spa.delta_ut1 = delta_ut1;
spa.delta_t = delta_t;
spa.slope = slope;
spa.azm_rotation = azm_rotation;
spa.atmos_refract = atmos_refract;
spa.function = function_code;
// Set default values for optional inputs if they are not given
spa.pressure = (pressure == 0.0) ? 820.0 : pressure; // Standard atmospheric pressure
spa.temperature = (temperature == 0.0) ? 11.0 : temperature; // Standard temperature
spa.slope = slope; // Surface slope angle (default 0.0)
spa.azm_rotation = azm_rotation; // Surface azimuth angle (default 0.0)
spa.atmos_refract = (atmos_refract == 0.0) ? 0.5667 : atmos_refract;
int rc = spa_calculate(&spa);
result->error_code = rc;
// Set the calculation mode to SPA_ALL
spa.function = SPA_ALL;
// Calculate SPA values
int result_code = spa_calculate(&spa);
if (result_code == 0) {
result->zenith = spa.zenith;
result->azimuth = spa.azimuth;
result->incidence = spa.incidence;
result->sunrise = spa.sunrise;
result->sunset = spa.sunset;
result->solar_noon = spa.suntransit;
if (rc == 0) {
result->zenith = spa.zenith;
result->azimuth_astro = spa.azimuth_astro;
result->azimuth = spa.azimuth;
result->incidence = spa.incidence;
result->sunrise = spa.sunrise;
result->sunset = spa.sunset;
result->suntransit = spa.suntransit;
result->sun_transit_alt = spa.sta;
result->eot = spa.eot;
} else {
// Error handling (fill with zeros or other default values)
result->zenith = 0;
result->azimuth = 0;
result->incidence = 0;
result->sunrise = 0;
result->sunset = 0;
result->solar_noon = 0;
result->sun_transit_alt = 0;
result->zenith = 0.0;
result->azimuth_astro = 0.0;
result->azimuth = 0.0;
result->incidence = 0.0;
result->sunrise = 0.0;
result->sunset = 0.0;
result->suntransit = 0.0;
result->sun_transit_alt = 0.0;
result->eot = 0.0;
}
return result;
}
// Function to free the allocated result memory
void spa_free_result(spa_result* result) {
free(result);
void spa_free_result(spa_result *result) {
if (result) free(result);
}

74
src/types.ts Normal file
View file

@ -0,0 +1,74 @@
/** SPA function codes. Control which outputs are computed. */
export const SPA_ZA = 0 as const;
export const SPA_ZA_INC = 1 as const;
export const SPA_ZA_RTS = 2 as const;
export const SPA_ALL = 3 as const;
export type SpaFunctionCode = typeof SPA_ZA | typeof SPA_ZA_INC | typeof SPA_ZA_RTS | typeof SPA_ALL;
export interface SpaOptions {
/**
* Hours from UTC. If omitted, derived from the Date object's local offset.
* For historical dates or DST transitions, pass an explicit value.
*/
timezone?: number;
/** Observer elevation in meters above sea level. Default: 0. */
elevation?: number;
/** Atmospheric pressure in millibars. Default: 1013.25. */
pressure?: number;
/** Temperature in degrees Celsius. Default: 15. */
temperature?: number;
/** UT1-UTC correction in seconds. Default: 0. */
delta_ut1?: number;
/** TT-UTC difference in seconds. Default: 67. */
delta_t?: number;
/** Surface slope in degrees from horizontal. Default: 0. */
slope?: number;
/** Surface azimuth rotation in degrees from south. Default: 0. */
azm_rotation?: number;
/** Atmospheric refraction at sunrise/sunset in degrees. Default: 0.5667. */
atmos_refract?: number;
/** SPA function code. Default: SPA_ALL (3). */
function?: SpaFunctionCode;
}
export interface SpaResult {
/** Topocentric zenith angle in degrees. */
zenith: number;
/** Topocentric azimuth angle, westward from south (astronomical convention), in degrees. */
azimuth_astro: number;
/** Topocentric azimuth angle, eastward from north (navigational convention), in degrees. */
azimuth: number;
/** Surface incidence angle in degrees. */
incidence: number;
/** Local sunrise time as fractional hours. */
sunrise: number;
/** Local sunset time as fractional hours. */
sunset: number;
/** Local sun transit time (solar noon) as fractional hours. */
suntransit: number;
/** Sun transit altitude in degrees. */
sun_transit_alt: number;
/** Equation of time in minutes. */
eot: number;
/** SPA error code. Always 0 on a successful return (non-zero throws). */
error_code: number;
}
export interface SpaFormattedResult extends Omit<SpaResult, 'sunrise' | 'sunset' | 'suntransit'> {
/** Local sunrise time as HH:MM:SS string. "N/A" during polar day/night. */
sunrise: string;
/** Local sunset time as HH:MM:SS string. "N/A" during polar day/night. */
sunset: string;
/** Local sun transit time as HH:MM:SS string. "N/A" during polar day/night. */
suntransit: string;
}
/**
* Emscripten module interface. Matches the shape returned by createSpaModule().
* @internal
*/
export interface SpaWasmModule {
cwrap(name: string, returnType: string | null, argTypes: string[]): Function;
getValue(ptr: number, type: string): number;
}

58
test-cjs.cjs Normal file
View file

@ -0,0 +1,58 @@
'use strict';
const { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ALL } = require('./dist/index.cjs');
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(' FAIL: ' + message);
}
}
async function run() {
console.log('CJS smoke test\n');
// Verify all exports are available
assert(typeof spa === 'function', 'spa is a function');
assert(typeof spaFormatted === 'function', 'spaFormatted is a function');
assert(typeof formatTime === 'function', 'formatTime is a function');
assert(typeof init === 'function', 'init is a function');
assert(SPA_ZA === 0, 'SPA_ZA constant is 0');
assert(SPA_ALL === 3, 'SPA_ALL constant is 3');
// Core calculation
const result = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10 },
);
assert(result.error_code === 0, 'calculation succeeds');
assert(result.zenith > 0, 'zenith is positive');
assert(result.azimuth > 0, 'azimuth is positive');
assert(result.sunrise > 0, 'sunrise is positive');
// Formatted output
const fmt = await spaFormatted(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4 },
);
assert(typeof fmt.sunrise === 'string', 'formatted sunrise is a string');
assert(/^\d{2}:\d{2}:\d{2}$/.test(fmt.sunrise), 'sunrise matches HH:MM:SS');
// formatTime
assert(formatTime(6.5) === '06:30:00', 'formatTime works');
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

35
test.js
View file

@ -1,35 +0,0 @@
// test.js
const spa = require('./solar-spa.js');
// Define input parameters for a specific date, time, and location
const date = new Date(2023, 3, 1, 0, 0, 0); // April 1, 2023 at Midnight
const latitude = 40.7128; // Latitude of New York City, USA
const longitude = -74.0060; // Longitude of New York City, USA
const elevation = 10; // Elevation in meters (approximately)
const pressure = 1013.25; // Optional input: Atmospheric pressure (millibars)
const temperature = 20; // Optional input: Temperature (degrees Celsius)
const refraction = 0.5667; // Optional input: Atmospheric refraction (degrees)
// Convert fractional hours to formatted time string for sunrise, sunset, solar_noon
function formatTime(hours) {
const milliseconds = hours * 60 * 60 * 1000;
const date = new Date(milliseconds);
return date.toISOString().substr(11, 12);
}
// Call the 'spa' function and log the results
spa(date, latitude, longitude, elevation, temperature, pressure, refraction)
.then(result => {
console.log({
zenith: result.zenith,
azimuth: result.azimuth,
incidence: result.incidence,
sun_transit_alt: result.sun_transit_alt,
sunrise: result.sunrise,
solar_noon: result.solar_noon,
sunset: result.sunset
});
})
.catch(error => {
console.error(error);
});

258
test.mjs Normal file
View file

@ -0,0 +1,258 @@
import { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './dist/index.mjs';
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(' FAIL: ' + message);
}
}
function approx(actual, expected, tolerance, label) {
const diff = Math.abs(actual - expected);
assert(diff <= tolerance, label + ': expected ' + expected + ', got ' + actual + ' (diff: ' + diff.toFixed(6) + ')');
}
async function assertThrows(fn, check, label) {
try {
await fn();
assert(false, label + ': should have thrown');
} catch (e) {
if (check) {
assert(check(e), label + ': ' + e.message);
} else {
passed++;
}
}
}
async function run() {
console.log('solar-spa test suite\n');
// ── Test 1: New York City, April 1 2023 ──
console.log('1. NYC, April 1 2023, midnight local (UTC-4)');
const nyc = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
approx(nyc.zenith, 132.82, 0.1, 'zenith');
approx(nyc.azimuth, 339.38, 0.1, 'azimuth');
approx(nyc.sunrise, 6.665, 0.01, 'sunrise');
approx(nyc.sunset, 19.343, 0.01, 'sunset');
approx(nyc.suntransit, 12.998, 0.01, 'solar noon');
approx(nyc.sun_transit_alt, 53.916, 0.1, 'transit altitude');
assert(nyc.error_code === 0, 'error_code is 0');
// ── Test 2: London, Summer Solstice ──
console.log('2. London, June 21 2025, noon UTC');
const london = await spa(
new Date(2025, 5, 21, 12, 0, 0),
51.5074, -0.1278,
{ timezone: 0, elevation: 11, temperature: 18 },
);
assert(london.zenith < 30, 'zenith near noon is below 30 degrees');
assert(london.azimuth > 170 && london.azimuth < 200, 'azimuth roughly south at noon');
assert(london.sunrise > 3 && london.sunrise < 6, 'sunrise between 3 and 6');
assert(london.sunset > 19 && london.sunset < 23, 'sunset between 19 and 23');
assert(london.error_code === 0, 'error_code is 0');
// ── Test 3: Equator, Equinox ──
console.log('3. Quito (equator), March 20 2025, noon UTC-5');
const quito = await spa(
new Date(2025, 2, 20, 12, 0, 0),
-0.1807, -78.4678,
{ timezone: -5, elevation: 2850 },
);
assert(quito.zenith < 20, 'near-overhead sun at equinox on equator');
assert(quito.error_code === 0, 'error_code is 0');
// ── Test 4: Sydney, Winter ──
console.log('4. Sydney, June 21 2025 (winter), noon AEST');
const sydney = await spa(
new Date(2025, 5, 21, 12, 0, 0),
-33.8688, 151.2093,
{ timezone: 10 },
);
assert(sydney.zenith > 50, 'low sun in southern winter');
assert(sydney.sunrise > 6 && sydney.sunrise < 8, 'winter sunrise after 6');
assert(sydney.error_code === 0, 'error_code is 0');
// ── Test 5: Formatted output ──
console.log('5. Formatted output (NYC)');
const fmt = await spaFormatted(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
assert(typeof fmt.sunrise === 'string', 'sunrise is a string');
assert(typeof fmt.sunset === 'string', 'sunset is a string');
assert(typeof fmt.suntransit === 'string', 'suntransit is a string');
assert(/^\d{2}:\d{2}:\d{2}$/.test(fmt.sunrise), 'sunrise matches HH:MM:SS');
assert(typeof fmt.zenith === 'number', 'zenith remains numeric');
assert(typeof fmt.error_code === 'number', 'error_code is present in formatted result');
// ── Test 6: formatTime utility ──
console.log('6. formatTime utility');
assert(formatTime(0) === '00:00:00', 'midnight');
assert(formatTime(12) === '12:00:00', 'noon');
assert(formatTime(6.5) === '06:30:00', '6.5 hours');
assert(formatTime(23.9997) === '23:59:59', 'end of day');
assert(formatTime(Infinity) === 'N/A', 'Infinity returns N/A');
assert(formatTime(-Infinity) === 'N/A', '-Infinity returns N/A');
assert(formatTime(NaN) === 'N/A', 'NaN returns N/A');
assert(formatTime(-1) === 'N/A', 'negative returns N/A');
assert(formatTime(-0.5) === 'N/A', 'negative fractional returns N/A');
assert(formatTime(24.0) === '00:00:00', '24h wraps to midnight');
assert(formatTime(24.5) === '00:30:00', '24.5h wraps to 00:30');
// ── Test 7: SPA error handling ──
console.log('7. SPA error handling');
await assertThrows(
() => spa(new Date(2023, 0, 1), 40, -74, { timezone: 100 }),
(e) => e.message.includes('error code'),
'invalid timezone throws with error code',
);
// ── Test 8: Input validation ──
console.log('8. Input validation');
await assertThrows(
() => spa(null, 40, -74),
(e) => e instanceof TypeError,
'null date throws TypeError',
);
await assertThrows(
() => spa(new Date('invalid'), 40, -74),
(e) => e instanceof TypeError,
'invalid date throws TypeError',
);
await assertThrows(
() => spa(new Date(), 'forty', -74),
(e) => e instanceof TypeError,
'string latitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 40, undefined),
(e) => e instanceof TypeError,
'undefined longitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 91, -74),
(e) => e instanceof RangeError,
'latitude > 90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), -91, -74),
(e) => e instanceof RangeError,
'latitude < -90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, 181),
(e) => e instanceof RangeError,
'longitude > 180 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, -181),
(e) => e instanceof RangeError,
'longitude < -180 throws RangeError',
);
// ── Test 9: Function code selection ──
console.log('9. Function code SPA_ZA (zenith/azimuth only)');
const za = await spa(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4, function: SPA_ZA },
);
assert(za.zenith > 0, 'zenith computed');
assert(za.azimuth > 0, 'azimuth computed');
assert(za.error_code === 0, 'error_code is 0');
// ── Test 10: Repeated calls (verify singleton init) ──
console.log('10. Repeated calls');
const a = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
const b = await spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 });
assert(a.zenith !== b.zenith, 'different dates produce different results');
assert(a.error_code === 0 && b.error_code === 0, 'both succeed');
// ── Test 11: Concurrent calls (verify init dedup) ──
console.log('11. Concurrent calls');
const [c1, c2, c3] = await Promise.all([
spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 }),
spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
]);
assert(c1.error_code === 0 && c2.error_code === 0 && c3.error_code === 0, 'all three concurrent calls succeed');
assert(c1.zenith !== c2.zenith, 'concurrent results differ by date');
// ── Test 12: Boundary coordinates ──
console.log('12. Boundary coordinates');
const northPole = await spa(new Date(2025, 5, 21, 12, 0, 0), 90, 0, { timezone: 0 });
assert(northPole.error_code === 0, 'north pole succeeds');
const southPole = await spa(new Date(2025, 5, 21, 12, 0, 0), -90, 0, { timezone: 0 });
assert(southPole.error_code === 0, 'south pole succeeds');
const dateLine = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, 180, { timezone: 12 });
assert(dateLine.error_code === 0, 'date line (180) succeeds');
const dateLineNeg = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, -180, { timezone: -12 });
assert(dateLineNeg.error_code === 0, 'date line (-180) succeeds');
// ── Test 13: Arctic polar day (sun never sets) ──
console.log('13. Arctic polar day');
const tromso = await spa(
new Date(2025, 5, 21, 12, 0, 0),
69.6496, 18.9560,
{ timezone: 2 },
);
assert(tromso.error_code === 0, 'Tromso summer succeeds');
// During polar day, sunrise/sunset values from SPA may be non-standard
// The key is that the computation succeeds and zenith is low (sun is up)
assert(tromso.zenith < 50, 'sun is high at Tromso in summer');
// ── Test 14: Explicit init() call ──
console.log('14. Explicit init()');
await init(); // should be a no-op since module is already loaded
const afterInit = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
assert(afterInit.error_code === 0, 'spa works after explicit init');
// ── Test 15: Constants are correct ──
console.log('15. Constants');
assert(SPA_ZA === 0, 'SPA_ZA is 0');
assert(SPA_ZA_INC === 1, 'SPA_ZA_INC is 1');
assert(SPA_ZA_RTS === 2, 'SPA_ZA_RTS is 2');
assert(SPA_ALL === 3, 'SPA_ALL is 3');
// ── Test 16: Historical date ──
console.log('16. Historical date (year 1000)');
const historical = await spa(
new Date(1000, 5, 21, 12, 0, 0),
40.7128, -74.006,
{ timezone: -5, delta_t: 1574 },
);
assert(historical.error_code === 0, 'historical date succeeds');
assert(historical.zenith > 0 && historical.zenith < 90, 'historical zenith is reasonable');
// ── Test 17: All function codes ──
console.log('17. All function codes');
const zaRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA });
const zaIncRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_INC });
const zaRtsRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_RTS });
const allRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ALL });
assert(zaRes.error_code === 0, 'SPA_ZA succeeds');
assert(zaIncRes.error_code === 0, 'SPA_ZA_INC succeeds');
assert(zaRtsRes.error_code === 0, 'SPA_ZA_RTS succeeds');
assert(allRes.error_code === 0, 'SPA_ALL succeeds');
approx(zaRes.zenith, allRes.zenith, 0.001, 'zenith consistent across function codes');
// ── Results ──
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(function (err) {
console.error(err);
process.exit(1);
});

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "wasm"]
}

28
tsup.config.ts Normal file
View file

@ -0,0 +1,28 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
outDir: 'dist',
splitting: false,
sourcemap: true,
target: 'es2020',
platform: 'node',
outExtension({ format }) {
return {
js: format === 'cjs' ? '.cjs' : '.mjs',
};
},
banner({ format }) {
if (format === 'esm') {
return {
js: `import { createRequire as __cr } from 'node:module';\nconst __require = __cr(import.meta.url);`,
};
}
return {};
},
// The WASM module is Emscripten CJS output, keep it external.
external: ['../wasm/spa-module.js'],
});

854
validate.mjs Normal file
View file

@ -0,0 +1,854 @@
/**
* NREL SPA Validation Suite
*
* 100-scenario validation test for the solar-spa WASM implementation.
* Validates against known algorithm behavior, boundary conditions,
* polar regions, atmospheric variations, and historical/future dates.
*
* Run: node validate.mjs
*/
import { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './dist/index.mjs';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const results = [];
let scenarioNum = 0;
/**
* Create a Date object with an explicit year (works for years < 100).
* JavaScript's Date constructor treats 2-digit years as 1900+year,
* so we must use setFullYear() for historical dates.
*/
function makeDate(year, month, day, hour = 12, minute = 0, second = 0) {
const d = new Date(2000, month - 1, day, hour, minute, second);
d.setFullYear(year);
return d;
}
/**
* Run a single validation scenario.
* @param {string} name - Scenario label
* @param {Function} fn - Async function that returns { pass, zenith, azimuth, detail }
*/
async function scenario(name, fn) {
scenarioNum++;
const num = scenarioNum;
const t0 = performance.now();
try {
const result = await fn();
const elapsed = performance.now() - t0;
const us = Math.round(elapsed * 1000);
results.push({
num,
name,
pass: result.pass,
detail: result.detail || '',
us,
zenith: result.zenith,
azimuth: result.azimuth,
});
} catch (err) {
const elapsed = performance.now() - t0;
const us = Math.round(elapsed * 1000);
results.push({
num,
name,
pass: false,
detail: `EXCEPTION: ${err.message}`,
us,
zenith: null,
azimuth: null,
});
}
}
/**
* Run a scenario that expects spa() to throw.
*/
async function scenarioThrows(name, fn, checkMsg) {
scenarioNum++;
const num = scenarioNum;
const t0 = performance.now();
try {
await fn();
const elapsed = performance.now() - t0;
results.push({
num,
name,
pass: false,
detail: 'Expected throw but succeeded',
us: Math.round(elapsed * 1000),
zenith: null,
azimuth: null,
});
} catch (err) {
const elapsed = performance.now() - t0;
const pass = checkMsg ? err.message.includes(checkMsg) || err instanceof RangeError || err instanceof TypeError || err.message.includes('error code') : true;
results.push({
num,
name,
pass,
detail: pass ? `Threw: ${err.message.substring(0, 60)}` : `Wrong error: ${err.message}`,
us: Math.round(elapsed * 1000),
zenith: null,
azimuth: null,
});
}
}
function between(val, lo, hi) {
return val >= lo && val <= hi;
}
function approx(actual, expected, tolerance) {
return Math.abs(actual - expected) <= tolerance;
}
// ─── City Data ────────────────────────────────────────────────────────────────
const cities = [
// [name, lat, lon, tz, elevation, summerZenithRange, winterZenithRange]
// Summer solstice: June 21 2025 noon local
// Winter solstice: Dec 21 2025 noon local
// Zenith ranges are [min, max] approximate expectations
['NYC', 40.7128, -74.0060, -4, 10, [16, 30], [60, 78]],
['London', 51.5074, -0.1278, 1, 11, [27, 33], [72, 80]],
['Tokyo', 35.6762, 139.6503, 9, 40, [11, 20], [52, 62]],
['Sydney', -33.8688, 151.2093, 10, 3, [52, 62], [10, 18]],
['Cairo', 30.0444, 31.2357, 2, 75, [ 6, 14], [47, 55]],
['Mumbai', 19.0760, 72.8777, 5.5, 14, [ 4, 12], [37, 45]],
['Sao Paulo', -23.5505, -46.6333, -3,760, [44, 52], [ 0, 8]],
['Moscow', 55.7558, 37.6173, 3,156, [31, 37], [76, 84]],
['Beijing', 39.9042, 116.4074, 8, 43, [15, 22], [58, 66]],
['Nairobi', -1.2921, 36.8219, 3,1795, [24, 30], [22, 28]],
['Reykjavik', 64.1466, -21.9426, 0, 50, [40, 48], [88, 100]],
['Singapore', 1.3521, 103.8198, 8, 15, [22, 28], [24, 30]],
['Cape Town', -33.9249, 18.4241, 2, 30, [52, 62], [10, 18]],
['Buenos Aires',-34.6037, -58.3816, -3, 25, [52, 62], [ 4, 16]],
['Dubai', 25.2048, 55.2708, 4, 5, [ 1, 8], [43, 51]],
['Toronto', 43.6532, -79.3832, -4, 76, [19, 30], [62, 72]],
['Mexico City', 19.4326, -99.1332, -6,2240, [ 4, 10], [37, 44]],
['Seoul', 37.5665, 126.9780, 9, 38, [13, 20], [56, 64]],
['Rome', 41.9028, 12.4964, 2, 21, [18, 24], [60, 68]],
['Anchorage', 61.2181, -149.9003, -8, 30, [37, 44], [82, 96]],
];
// ─── Run all scenarios ────────────────────────────────────────────────────────
async function runAll() {
// Warmup
await init();
await spa(new Date(2025, 5, 21, 12, 0, 0), 40, -74, { timezone: -4 });
// ══════════════════════════════════════════════════════════════════
// CATEGORY 1: Cities worldwide (40 scenarios, 1-40)
// ══════════════════════════════════════════════════════════════════
for (const [name, lat, lon, tz, elev, summerRange, winterRange] of cities) {
// Summer solstice: June 21 2025, noon local
await scenario(`${name} summer solstice`, async () => {
const r = await spa(
makeDate(2025, 6, 21, 12, 0, 0),
lat, lon,
{ timezone: tz, elevation: elev },
);
const pass = r.error_code === 0
&& between(r.zenith, summerRange[0], summerRange[1])
&& between(r.azimuth, 0, 360);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
detail: !pass ? `zenith=${r.zenith.toFixed(2)} expected [${summerRange}]` : '',
};
});
// Winter solstice: Dec 21 2025, noon local
await scenario(`${name} winter solstice`, async () => {
const r = await spa(
makeDate(2025, 12, 21, 12, 0, 0),
lat, lon,
{ timezone: tz, elevation: elev },
);
const pass = r.error_code === 0
&& between(r.zenith, winterRange[0], winterRange[1])
&& between(r.azimuth, 0, 360);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
detail: !pass ? `zenith=${r.zenith.toFixed(2)} expected [${winterRange}]` : '',
};
});
}
// ══════════════════════════════════════════════════════════════════
// CATEGORY 2: Boundary conditions (15 scenarios, 41-55)
// ══════════════════════════════════════════════════════════════════
// 41: North Pole summer solstice (midnight sun)
await scenario('North Pole summer solstice', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 90, 0, { timezone: 0 });
// Sun should be above horizon (zenith < 90) in arctic summer
const pass = r.error_code === 0 && r.zenith < 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 42: North Pole winter solstice (polar night)
await scenario('North Pole winter solstice', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), 90, 0, { timezone: 0 });
// Sun should be below horizon (zenith > 90) in arctic winter
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 43: South Pole summer solstice (Dec = summer in south)
await scenario('South Pole summer solstice', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), -90, 0, { timezone: 0 });
// Sun above horizon at south pole in December
const pass = r.error_code === 0 && r.zenith < 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 44: South Pole winter solstice (June = winter in south)
await scenario('South Pole winter solstice', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), -90, 0, { timezone: 0 });
// Sun below horizon at south pole in June
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 45: Equator March equinox noon
await scenario('Equator March equinox noon', async () => {
const r = await spa(makeDate(2025, 3, 20, 12, 0, 0), 0, 0, { timezone: 0 });
// Sun nearly overhead at equator on equinox
const pass = r.error_code === 0 && r.zenith < 5;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 46: Equator September equinox noon
await scenario('Equator September equinox noon', async () => {
const r = await spa(makeDate(2025, 9, 22, 12, 0, 0), 0, 0, { timezone: 0 });
const pass = r.error_code === 0 && r.zenith < 5;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 47: Equator June solstice (sun north of equator)
await scenario('Equator June solstice noon', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 0, 0, { timezone: 0 });
// Declination ~23.44, so zenith ~23.44 at equator
const pass = r.error_code === 0 && between(r.zenith, 20, 27);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 48: Equator December solstice (sun south of equator)
await scenario('Equator December solstice noon', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), 0, 0, { timezone: 0 });
const pass = r.error_code === 0 && between(r.zenith, 20, 27);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 49: International Date Line east (+180)
await scenario('Date line +180 longitude', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 0, 180, { timezone: 12 });
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 50: International Date Line west (-180)
await scenario('Date line -180 longitude', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 0, -180, { timezone: -12 });
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 51: Mt Everest (8849m elevation)
await scenario('Mt Everest summit elevation', async () => {
const r = await spa(
makeDate(2025, 6, 21, 12, 0, 0),
27.9881, 86.9250,
{ timezone: 5.75, elevation: 8849, pressure: 314, temperature: -20 },
);
const pass = r.error_code === 0 && between(r.zenith, 0, 15);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 52: Dead Sea (-430m elevation)
await scenario('Dead Sea negative elevation', async () => {
const r = await spa(
makeDate(2025, 6, 21, 12, 0, 0),
31.5, 35.5,
{ timezone: 3, elevation: -430, pressure: 1065, temperature: 40 },
);
const pass = r.error_code === 0 && between(r.zenith, 0, 15);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 53: Extreme past date (year -2000, earliest valid)
await scenario('Year -2000 (earliest valid)', async () => {
const r = await spa(makeDate(-2000, 6, 21, 12, 0, 0), 30, 0, { timezone: 0, delta_t: 0 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 54: Extreme future date (year 6000, latest valid)
await scenario('Year 6000 (latest valid)', async () => {
const r = await spa(makeDate(6000, 6, 21, 12, 0, 0), 30, 0, { timezone: 0, delta_t: 0 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 55: Year out of range (6001) should throw
await scenarioThrows('Year 6001 (out of range)', async () => {
await spa(makeDate(6001, 6, 21, 12, 0, 0), 30, 0, { timezone: 0, delta_t: 0 });
}, 'error code');
// ══════════════════════════════════════════════════════════════════
// CATEGORY 3: Polar regions (10 scenarios, 56-65)
// ══════════════════════════════════════════════════════════════════
// 56: Tromso polar day (June)
await scenario('Tromso polar day (June)', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 69.6496, 18.956, { timezone: 2 });
const pass = r.error_code === 0 && r.zenith < 50;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 57: Tromso polar night (December)
await scenario('Tromso polar night (Dec)', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), 69.6496, 18.956, { timezone: 1 });
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 58: Murmansk polar day
await scenario('Murmansk polar day (June)', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 68.9585, 33.0827, { timezone: 3 });
const pass = r.error_code === 0 && r.zenith < 50;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 59: Murmansk polar night
await scenario('Murmansk polar night (Dec)', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), 68.9585, 33.0827, { timezone: 3 });
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 60: Utqiagvik (Barrow) AK polar day
await scenario('Utqiagvik AK polar day (June)', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), 71.2906, -156.7886, { timezone: -8 });
const pass = r.error_code === 0 && r.zenith < 55;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 61: Utqiagvik AK polar night
await scenario('Utqiagvik AK polar night (Dec)', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), 71.2906, -156.7886, { timezone: -9 });
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 62: McMurdo Station Antarctica summer (Dec)
await scenario('McMurdo Station summer (Dec)', async () => {
const r = await spa(makeDate(2025, 12, 21, 12, 0, 0), -77.8500, 166.6667, { timezone: 13 });
const pass = r.error_code === 0 && r.zenith < 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 63: McMurdo Station winter (June)
await scenario('McMurdo Station winter (June)', async () => {
const r = await spa(makeDate(2025, 6, 21, 12, 0, 0), -77.8500, 166.6667, { timezone: 12 });
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 64: Svalbard (78N) midnight sun
await scenario('Svalbard midnight sun (June)', async () => {
const r = await spa(makeDate(2025, 6, 21, 0, 0, 0), 78.2296, 15.6167, { timezone: 2 });
// Even at midnight, sun should be above horizon
const pass = r.error_code === 0 && r.zenith < 95;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 65: Amundsen-Scott South Pole Station summer
await scenario('South Pole Station summer (Jan)', async () => {
const r = await spa(makeDate(2025, 1, 1, 12, 0, 0), -90, 0, { timezone: 0 });
const pass = r.error_code === 0 && r.zenith < 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// ══════════════════════════════════════════════════════════════════
// CATEGORY 4: Time edge cases (10 scenarios, 66-75)
// ══════════════════════════════════════════════════════════════════
// 66: Exact midnight
await scenario('Exact midnight UTC', async () => {
const r = await spa(makeDate(2025, 6, 21, 0, 0, 0), 51.5074, -0.1278, { timezone: 0 });
// Sun should be well below horizon in London at midnight (even in summer)
const pass = r.error_code === 0 && r.zenith > 70;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 67: Dawn (around 5 AM summer London)
await scenario('Dawn (5 AM summer London)', async () => {
const r = await spa(makeDate(2025, 6, 21, 5, 0, 0), 51.5074, -0.1278, { timezone: 1 });
// Near sunrise, zenith should be around 85-95 degrees
const pass = r.error_code === 0 && between(r.zenith, 75, 100);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 68: Dusk (around 9 PM summer London)
await scenario('Dusk (9 PM summer London)', async () => {
const r = await spa(makeDate(2025, 6, 21, 21, 0, 0), 51.5074, -0.1278, { timezone: 1 });
const pass = r.error_code === 0 && between(r.zenith, 80, 105);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 69: Solar noon (transit) NYC
await scenario('Solar noon NYC (approx 13:00 EDT)', async () => {
const r = await spa(makeDate(2025, 6, 21, 13, 0, 0), 40.7128, -74.006, { timezone: -4 });
// Near transit, azimuth should be close to 180 (south-ish)
const pass = r.error_code === 0 && between(r.azimuth, 170, 195);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 70: UTC boundary (hour=0, minute=0, second=0)
await scenario('UTC boundary midnight Jan 1', async () => {
const r = await spa(makeDate(2025, 1, 1, 0, 0, 0), 0, 0, { timezone: 0 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 71: Fractional seconds (second=59.999)
await scenario('Fractional seconds (59.999s)', async () => {
// We pass 12:30:00 and rely on the sub-second being handled
const d = makeDate(2025, 6, 21, 12, 30, 0);
const r = await spa(d, 40.7128, -74.006, { timezone: -4 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 72: Hour=23, minute=59
await scenario('End of day 23:59:00', async () => {
const r = await spa(makeDate(2025, 6, 21, 23, 59, 0), 40.7128, -74.006, { timezone: -4 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 73: February 29 leap year
await scenario('Feb 29 leap year (2024)', async () => {
const r = await spa(makeDate(2024, 2, 29, 12, 0, 0), 40.7128, -74.006, { timezone: -5 });
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 74: Noon exactly at prime meridian equator (most symmetric case)
await scenario('Prime meridian equator noon', async () => {
const r = await spa(makeDate(2025, 3, 20, 12, 0, 0), 0, 0, { timezone: 0 });
// Equinox at equator at noon on prime meridian: zenith should be very small
const pass = r.error_code === 0 && r.zenith < 5;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 75: New Year's Eve midnight
await scenario('New Year Eve midnight', async () => {
const r = await spa(makeDate(2025, 12, 31, 0, 0, 0), 40.7128, -74.006, { timezone: -5 });
const pass = r.error_code === 0 && r.zenith > 90;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// ══════════════════════════════════════════════════════════════════
// CATEGORY 5: All function codes (5 scenarios, 76-80)
// ══════════════════════════════════════════════════════════════════
const fcDate = makeDate(2025, 6, 21, 12, 0, 0);
const fcLat = 40.7128;
const fcLon = -74.006;
const fcOpts = { timezone: -4, elevation: 10 };
// Get the reference SPA_ALL result first
const refAll = await spa(fcDate, fcLat, fcLon, { ...fcOpts, function: SPA_ALL });
// 76: SPA_ZA zenith matches SPA_ALL
await scenario('SPA_ZA zenith matches SPA_ALL', async () => {
const r = await spa(fcDate, fcLat, fcLon, { ...fcOpts, function: SPA_ZA });
const pass = r.error_code === 0
&& approx(r.zenith, refAll.zenith, 0.01)
&& approx(r.azimuth, refAll.azimuth, 0.01);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
detail: !pass ? `diff zenith=${Math.abs(r.zenith - refAll.zenith).toFixed(6)}` : '',
};
});
// 77: SPA_ZA_INC zenith/azimuth match SPA_ALL
await scenario('SPA_ZA_INC matches SPA_ALL', async () => {
const r = await spa(fcDate, fcLat, fcLon, { ...fcOpts, function: SPA_ZA_INC });
const pass = r.error_code === 0
&& approx(r.zenith, refAll.zenith, 0.01)
&& approx(r.azimuth, refAll.azimuth, 0.01)
&& approx(r.incidence, refAll.incidence, 0.01);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
detail: !pass ? `diff incidence=${Math.abs(r.incidence - refAll.incidence).toFixed(6)}` : '',
};
});
// 78: SPA_ZA_RTS zenith/azimuth match SPA_ALL
await scenario('SPA_ZA_RTS matches SPA_ALL', async () => {
const r = await spa(fcDate, fcLat, fcLon, { ...fcOpts, function: SPA_ZA_RTS });
const pass = r.error_code === 0
&& approx(r.zenith, refAll.zenith, 0.01)
&& approx(r.azimuth, refAll.azimuth, 0.01);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
};
});
// 79: SPA_ALL returns all fields populated
await scenario('SPA_ALL all fields populated', async () => {
const r = refAll;
const pass = r.error_code === 0
&& isFinite(r.zenith)
&& isFinite(r.azimuth)
&& isFinite(r.azimuth_astro)
&& isFinite(r.incidence)
&& isFinite(r.sunrise)
&& isFinite(r.sunset)
&& isFinite(r.suntransit)
&& isFinite(r.eot);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 80: SPA_ALL azimuth = azimuth_astro + 180
await scenario('SPA_ALL azimuth consistency', async () => {
const r = refAll;
// azimuth (from north) = azimuth_astro (from south) + 180, mod 360
const expected = (r.azimuth_astro + 180) % 360;
const pass = r.error_code === 0 && approx(r.azimuth, expected, 0.01);
return {
pass,
zenith: r.zenith,
azimuth: r.azimuth,
detail: !pass ? `azimuth=${r.azimuth}, expected=${expected}` : '',
};
});
// ══════════════════════════════════════════════════════════════════
// CATEGORY 6: Atmospheric conditions (10 scenarios, 81-90)
// ══════════════════════════════════════════════════════════════════
const atmoDate = makeDate(2025, 6, 21, 12, 0, 0);
const atmoLat = 40.7128;
const atmoLon = -74.006;
// 81: Standard atmosphere
const stdAtmo = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 1013.25, temperature: 15,
});
await scenario('Standard atmosphere (1013.25mb, 15C)', async () => {
const pass = stdAtmo.error_code === 0 && between(stdAtmo.zenith, 0, 90);
return { pass, zenith: stdAtmo.zenith, azimuth: stdAtmo.azimuth };
});
// 82: Very low pressure (high altitude, ~300 mbar)
await scenario('Low pressure 300 mbar', async () => {
const r = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 300, temperature: -30, elevation: 9000,
});
// Should still compute; zenith will differ slightly from standard
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 83: High pressure (1100 mbar)
await scenario('High pressure 1100 mbar', async () => {
const r = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 1100, temperature: 15,
});
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 84: Extreme cold (-40C)
await scenario('Extreme cold -40C', async () => {
const r = await spa(atmoDate, 64.1466, -21.9426, {
timezone: 0, temperature: -40, pressure: 1013.25,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 85: Extreme heat (+50C)
await scenario('Extreme heat +50C', async () => {
const r = await spa(atmoDate, 25.2048, 55.2708, {
timezone: 4, temperature: 50, pressure: 1000,
});
const pass = r.error_code === 0 && between(r.zenith, 0, 10);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 86: Zero pressure
await scenario('Zero pressure (vacuum)', async () => {
const r = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 0, temperature: 15,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 87: Custom atmospheric refraction (0 degrees)
await scenario('Custom refraction 0 deg', async () => {
const r = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, atmos_refract: 0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 88: Custom atmospheric refraction (2 degrees)
await scenario('Custom refraction 2 deg', async () => {
const r = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, atmos_refract: 2.0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 89: Pressure/temperature affect zenith slightly
await scenario('Pressure effect on zenith', async () => {
const rLow = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 300, temperature: 15,
});
const rHigh = await spa(atmoDate, atmoLat, atmoLon, {
timezone: -4, pressure: 1100, temperature: 15,
});
// Both should succeed; zenith should differ slightly due to refraction
const pass = rLow.error_code === 0 && rHigh.error_code === 0
&& rLow.zenith !== rHigh.zenith;
return {
pass,
zenith: rLow.zenith,
azimuth: rLow.azimuth,
detail: `low=${rLow.zenith.toFixed(4)}, high=${rHigh.zenith.toFixed(4)}`,
};
});
// 90: High elevation with matching low pressure
await scenario('High elevation + low pressure combo', async () => {
const r = await spa(atmoDate, 27.9881, 86.925, {
timezone: 5.75, elevation: 5364, pressure: 500, temperature: -5,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// ══════════════════════════════════════════════════════════════════
// CATEGORY 7: Historical/future dates (10 scenarios, 91-100)
// ══════════════════════════════════════════════════════════════════
// 91: Year 1000
await scenario('Year 1000 CE', async () => {
const r = await spa(makeDate(1000, 6, 21, 12, 0, 0), 40.7128, -74.006, {
timezone: -5, delta_t: 1574,
});
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 92: Year 1582 (Gregorian calendar switch)
await scenario('Year 1582 (Gregorian switch)', async () => {
const r = await spa(makeDate(1582, 10, 15, 12, 0, 0), 41.9028, 12.4964, {
timezone: 1, delta_t: 120,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 93: Year 1900
await scenario('Year 1900', async () => {
const r = await spa(makeDate(1900, 6, 21, 12, 0, 0), 48.8566, 2.3522, {
timezone: 0, delta_t: -3,
});
const pass = r.error_code === 0 && between(r.zenith, 0, 90);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 94: Year 1969 (Apollo 11 era)
await scenario('Year 1969 (Apollo era)', async () => {
const r = await spa(makeDate(1969, 7, 20, 12, 0, 0), 28.5721, -80.648, {
timezone: -5, delta_t: 40,
});
const pass = r.error_code === 0 && between(r.zenith, 0, 20);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 95: Year 2050
await scenario('Year 2050', async () => {
const r = await spa(makeDate(2050, 6, 21, 12, 0, 0), 40.7128, -74.006, {
timezone: -4, delta_t: 93,
});
const pass = r.error_code === 0 && between(r.zenith, 16, 30);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 96: Year 2100
await scenario('Year 2100', async () => {
const r = await spa(makeDate(2100, 12, 21, 12, 0, 0), 51.5074, -0.1278, {
timezone: 0, delta_t: 200,
});
const pass = r.error_code === 0 && between(r.zenith, 70, 80);
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 97: Year 3000
await scenario('Year 3000', async () => {
const r = await spa(makeDate(3000, 6, 21, 12, 0, 0), 35.6762, 139.6503, {
timezone: 9, delta_t: 0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 98: Year 5000
await scenario('Year 5000', async () => {
const r = await spa(makeDate(5000, 3, 20, 12, 0, 0), 0, 0, {
timezone: 0, delta_t: 0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 99: Year -1000 (1001 BCE)
await scenario('Year -1000 (1001 BCE)', async () => {
const r = await spa(makeDate(-1000, 6, 21, 12, 0, 0), 37.9715, 23.7267, {
timezone: 2, delta_t: 0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// 100: Year -2000 (earliest valid, winter solstice)
await scenario('Year -2000 winter solstice', async () => {
const r = await spa(makeDate(-2000, 12, 21, 12, 0, 0), 30.0444, 31.2357, {
timezone: 2, delta_t: 0,
});
const pass = r.error_code === 0;
return { pass, zenith: r.zenith, azimuth: r.azimuth };
});
// ══════════════════════════════════════════════════════════════════
// Print Results
// ══════════════════════════════════════════════════════════════════
console.log('NREL SPA Validation Suite');
console.log('=========================\n');
let passCount = 0;
let failCount = 0;
const latencies = [];
for (const r of results) {
if (r.pass) passCount++;
else failCount++;
latencies.push(r.us);
const numStr = String(r.num).padStart(3, ' ');
const status = r.pass ? 'PASS' : 'FAIL';
const nameStr = r.name.padEnd(44, ' ');
let info = '';
if (r.zenith !== null) {
info = `(zenith=${r.zenith.toFixed(2)}\u00B0, azimuth=${r.azimuth.toFixed(2)}\u00B0, ${r.us}\u00B5s)`;
} else if (r.detail) {
info = `(${r.detail}, ${r.us}\u00B5s)`;
} else {
info = `(${r.us}\u00B5s)`;
}
if (r.pass) {
console.log(`Scenario ${numStr}: ${nameStr} ${status} ${info}`);
} else {
console.log(`Scenario ${numStr}: ${nameStr} ${status} ${info}${r.detail ? ' -- ' + r.detail : ''}`);
}
}
console.log(`\nResults: ${passCount}/${results.length} passed` + (failCount > 0 ? ` (${failCount} failed)` : ''));
// ══════════════════════════════════════════════════════════════════
// Performance Benchmarks
// ══════════════════════════════════════════════════════════════════
// Compute latency stats from the 100 scenario calls
const sorted = [...latencies].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const mean = sum / sorted.length;
const median = sorted.length % 2 === 0
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
: sorted[Math.floor(sorted.length / 2)];
const p95idx = Math.ceil(sorted.length * 0.95) - 1;
const p99idx = Math.ceil(sorted.length * 0.99) - 1;
console.log('\nPerformance');
console.log('-----------');
console.log(`Per-call latency (${sorted.length} calls):`);
console.log(` Min: ${sorted[0]}\u00B5s`);
console.log(` Max: ${sorted[sorted.length - 1]}\u00B5s`);
console.log(` Mean: ${Math.round(mean)}\u00B5s`);
console.log(` Median: ${Math.round(median)}\u00B5s`);
console.log(` P95: ${sorted[p95idx]}\u00B5s`);
console.log(` P99: ${sorted[p99idx]}\u00B5s`);
// Batch throughput: SPA_ALL
const batchAll = 10000;
const batchDate = makeDate(2025, 6, 21, 12, 0, 0);
const batchOpts = { timezone: -4, function: SPA_ALL };
const tAllStart = performance.now();
for (let i = 0; i < batchAll; i++) {
await spa(batchDate, 40.7128, -74.006, batchOpts);
}
const tAllEnd = performance.now();
const allMs = tAllEnd - tAllStart;
const allPerSec = Math.round(batchAll / (allMs / 1000));
// Batch throughput: SPA_ZA
const batchZaOpts = { timezone: -4, function: SPA_ZA };
const tZaStart = performance.now();
for (let i = 0; i < batchAll; i++) {
await spa(batchDate, 40.7128, -74.006, batchZaOpts);
}
const tZaEnd = performance.now();
const zaMs = tZaEnd - tZaStart;
const zaPerSec = Math.round(batchAll / (zaMs / 1000));
console.log('\nBatch throughput:');
console.log(` SPA_ALL: ${batchAll.toLocaleString()} calls in ${Math.round(allMs)}ms (${allPerSec.toLocaleString()} calls/sec)`);
console.log(` SPA_ZA: ${batchAll.toLocaleString()} calls in ${Math.round(zaMs)}ms (${zaPerSec.toLocaleString()} calls/sec)`);
// Init time measurement
// We already initialized, so measure a fresh module creation overhead
// This is approximate since we measure only the cached path
const tInitStart = performance.now();
await init();
const tInitEnd = performance.now();
console.log(`\nInit time: ${(tInitEnd - tInitStart).toFixed(1)}ms (cached; first init happened during warmup)`);
// Exit code
if (failCount > 0) {
process.exit(1);
}
}
runAll().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

BIN
wasm/spa-module.js Normal file

Binary file not shown.