nrel-spa/.wiki/Architecture.md
Aric Camarata 51dcf89d63 nrel-spa v2.0.1: validation, NaN returns, overloads, wiki comparison
Fixed:
- calcSpa with empty angles array no longer crashes (consistent guard with getSpa)
- getSpa with SPA_ZA/SPA_ZA_INC now returns NaN for sunrise/solarNoon/sunset
  instead of misleading 0; calcSpa returns "N/A" for those fields
- lib/spa.js header comment corrected from dist/spa.js to lib/spa.js
- dist/spa.js removed (file moved to lib/spa.js in v2.0.0, stale copy deleted)
- wiki-sync.yml handles first-run when GitHub Wiki repo does not yet exist
- CI pack-check grep uses word-boundary pattern to prevent false prefix matches
- Removed package-import-method=hardlink from .npmrc (pnpm default, caused npm warn)

Added:
- options.function validated before calculation; invalid code throws RangeError
- angles with non-RTS function code throws RangeError (requires suntransit)
- TypeScript function overloads for getSpa and calcSpa; angles typed as
  [number, ...number[]] non-empty tuple, narrows return type automatically
- SpaFormattedAnglesResult interface, consistent with SpaAnglesResult
- CI jobs declare explicit permissions: contents: read
- Wiki: Implementation Comparison page with accuracy table (8 locations vs C
  reference, max delta 0.49 s) and performance benchmarks (nrel-spa vs solar-spa
  vs C, both SPA_ZA_RTS and SPA_ZA modes, 200k iterations on Node v24.6.0)
- Wiki: API Reference updated with named types, all throws, Named Types block
- Wiki: Architecture updated with all exported interfaces
2026-02-25 11:54:03 -05:00

4.8 KiB

Architecture

Package Structure

nrel-spa/
├── src/
│   ├── index.ts       <- Public API: getSpa, calcSpa, formatTime
│   └── types.ts       <- TypeScript interfaces and SPA function code constants
├── lib/
│   └── spa.js         <- Core SPA algorithm (JS port of NREL C source, tracked in git)
├── dist/              <- Built output (generated by tsup, gitignored)
│   ├── index.cjs      <- CommonJS build
│   ├── index.mjs      <- ESM build
│   ├── index.d.ts     <- CJS type declarations
│   └── index.d.mts    <- ESM type declarations
├── test.mjs           <- ESM test suite (61 assertions)
└── test-cjs.cjs       <- CJS test suite (17 assertions)

Layers

lib/spa.js is the core mathematical engine. It is a direct JavaScript port of the NREL C source (spa.c), preserving function names, variable names, and the algorithm structure. The file is checked into git and shipped in the npm package.

src/index.ts is a thin TypeScript wrapper. It:

  • Loads lib/spa.js at runtime
  • Validates input parameters, throwing TypeError or RangeError for invalid values
  • Validates options.function and throws RangeError for out-of-range codes
  • Returns NaN for sunrise, solarNoon, and sunset when the function code does not include RTS (SPA_ZA or SPA_ZA_INC)
  • Maps the flat SpaData structure to a clean output object
  • Implements adjustForCustomAngle() internally for twilight calculations
  • Provides formatTime() as a standalone export

src/types.ts exports all public TypeScript interfaces and constants:

  • SpaOptions — atmospheric and calculation parameters
  • SpaResult / SpaFormattedResult — base return types for getSpa / calcSpa
  • SpaAnglesResult / SpaFormattedAnglesResult — per-angle entries in the angles array
  • SpaResultWithAngles / SpaFormattedResultWithAngles — return types when angles is passed
  • SpaFunctionCode — union type 0 | 1 | 2 | 3
  • SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL — function code constants

tsup compiles src/index.ts to both CJS (dist/index.cjs) and ESM (dist/index.mjs) with TypeScript declarations. The lib/spa.js module is kept external (not bundled) and resolved at runtime via a createRequire shim in the ESM build.

Loading Strategy

The ESM build uses a createRequire banner injected by tsup:

import { createRequire as __cr } from 'node:module';
const __require = __cr(import.meta.url);

This anchors require() resolution to the file's own location rather than the calling context, so ../lib/spa.js resolves correctly regardless of where the caller imports the package.

In the CJS build, standard require() is used directly.

Why lib/spa.js Is External

Unlike a typical npm package where all source is compiled away, lib/spa.js ships as a separate file rather than being inlined into dist/. This is an intentional choice:

  1. The algorithm is the canonical reference. Keeping it as a readable JS file allows inspection without source maps.
  2. The file is large (989 lines). Bundling it into both CJS and ESM outputs would double its footprint for no functional gain.
  3. It matches the structure of solar-spa, which keeps its Emscripten WASM module in a separate wasm/ directory.

Date Handling

The wrapper uses UTC components from the Date object (getUTCFullYear(), etc.) and accepts an explicit timezone offset. This avoids the ambiguity of getFullYear() which returns local time and can produce wrong results when the caller's timezone differs from the target location.

// Always pass UTC date + explicit timezone
const date = new Date('2025-06-21T00:00:00Z');
getSpa(date, lat, lng, -4); // -4 for EDT

Polar Conditions

When the sun does not rise or set (polar day/polar night), lib/spa.js sets sunrise, sunset, and suntransit to -99999. The wrapper propagates these raw values from getSpa(). calcSpa() passes them through formatTime(), which returns "N/A" for negative values.

Custom angle calculations (adjustForCustomAngle) return NaN sunrise/sunset when cosH0 < -1 || cosH0 > 1, which indicates no crossing of that zenith angle.

Validation

Input validation runs before any SPA calculation:

  • date must be a valid Date (not Invalid Date)
  • latitude and longitude must be finite numbers in their valid ranges
  • Invalid values throw before the SPA data structure is populated

The internal spa_calculate() function also validates its inputs and returns non-zero error codes for out-of-range values. The wrapper throws Error on any non-zero return.


Home . API Reference . Twilight Calculations . NREL SPA Algorithm