diff --git a/.github/wiki/_Sidebar.md b/.github/wiki/_Sidebar.md index 2bf2d7a..a55731f 100644 --- a/.github/wiki/_Sidebar.md +++ b/.github/wiki/_Sidebar.md @@ -2,16 +2,33 @@ **[Home](Home)** -**Reference** +**API** - [API Reference](API-Reference) +- [spa()](api/spa) +- [spaFormatted()](api/spaFormatted) +- [formatTime()](api/formatTime) +- [init()](api/init) +- [SpaOptions](api/spa-options) +- [SpaResult](api/spa-result) + +**Reference** - [Architecture](Architecture) - [NREL SPA Algorithm](NREL-SPA-Algorithm) -**Performance & Compatibility** +**Performance** +- [Bundle Size and Benchmarks](benchmarks/index) - [Performance](Performance) +- [Validation and Benchmarks](Validation-and-Benchmarks) - [Bundler Compatibility](Bundler-Compatibility) - [WebAssembly in npm Packages](WebAssembly-in-npm-Packages) -- [Validation and Benchmarks](Validation-and-Benchmarks) + +**Guides** +- [Quick Start](guides/quickstart) +- [Advanced Usage](guides/advanced) + +**Examples** +- [Annual Daylight Hours](examples/annual-daylight) +- [Solar Clock](examples/solar-clock) **Contributing** - [Contributing](Contributing) diff --git a/.github/wiki/benchmarks/index.md b/.github/wiki/benchmarks/index.md new file mode 100644 index 0000000..803b971 --- /dev/null +++ b/.github/wiki/benchmarks/index.md @@ -0,0 +1,47 @@ +# Bundle Size and Performance + +## Bundle size + +Measured from the published npm package (`solar-spa@2.0.1`). + +| File | Raw | Gzipped | Notes | +| --- | --- | --- | --- | +| `dist/index.mjs` | 5.3 KB | ~2.1 KB | JS wrapper, ESM | +| `dist/index.cjs` | 6.0 KB | ~2.3 KB | JS wrapper, CommonJS | +| `wasm/spa-module.cjs` | 58.6 KB | ~38 KB | Emscripten output with WASM binary inlined as base64 | +| Total installed | ~65 KB | ~40 KB | JS + WASM combined | + +The WASM binary is inlined as base64 in `spa-module.cjs` (SINGLE_FILE mode). No external `.wasm` file is needed at runtime. The base64 encoding adds ~33% overhead over the raw binary size; the decoded WASM is approximately 44 KB. + +For comparison, [nrel-spa](https://github.com/acamarata/nrel-spa) (the pure JavaScript port) is ~8 KB gzipped with no WASM dependency. + +## Call latency + +Single-call timings from the validation suite (`validate.mjs`), measured on Apple M2, Node.js 22: + +| Scenario | Time | +| --- | --- | +| First call (includes WASM init) | ~3-5 ms | +| Subsequent calls (module cached) | 20-250 µs | +| Fastest observed (simple zenith/azimuth) | ~20 µs | +| Typical city scenario | 50-150 µs | + +The first call initializes the WASM module, which adds 3-5 ms of one-time overhead. All subsequent calls reuse the cached module instance. Call [`init()`](../api/init) at startup to move this cost out of request paths. + +## WASM vs pure JS + +| Scenario | solar-spa (WASM) | nrel-spa (pure JS) | Ratio | +| --- | --- | --- | --- | +| Single call | ~100 µs | ~250 µs | ~2.5x faster | +| Batch 1000 calls | ~80 ms | ~200 ms | ~2.5x faster | +| First call with init | ~3-5 ms | ~0 ms | WASM has cold start | + +For single-call use cases the cold start dominates. Use `init()` at app startup when single-call latency matters. + +## Accuracy + +The WASM module compiles the unmodified NREL SPA C source. All 100 validation scenarios in `validate.mjs` pass against physically-derived reference values. See [Validation and Benchmarks](../Validation-and-Benchmarks) for the full scenario table. + +--- + +[Home](../Home) · [Performance](../Performance) · [Validation and Benchmarks](../Validation-and-Benchmarks) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5216e4b..e34d309 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,3 +94,34 @@ jobs: grep -q "$f" pack-output.txt || { echo "MISSING: $f"; exit 1; } done echo "All expected files present in package" + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build:ts + + - name: Coverage + run: pnpm run coverage + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/README.md b/README.md index 3d6e8d8..2ee1422 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ [![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) +[![coverage](https://codecov.io/gh/acamarata/solar-spa/branch/main/graph/badge.svg)](https://codecov.io/gh/acamarata/solar-spa) [![license](https://img.shields.io/npm/l/solar-spa.svg)](https://github.com/acamarata/solar-spa/blob/main/LICENSE) +[![wiki](https://img.shields.io/badge/docs-wiki-blue)](https://github.com/acamarata/solar-spa/wiki) -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 WASM binary is inlined as base64, so there is no external `.wasm` file to locate — it works in Node.js, browsers, Webpack, Vite, Next.js, and web workers without configuration. +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 WASM binary is inlined as base64, so there is no external `.wasm` file to locate. It works in Node.js, browsers, Webpack, Vite, Next.js, and web workers without configuration. ## Installation diff --git a/src/index.ts b/src/index.ts index 0802403..812308e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,9 +44,18 @@ const OFFSET = { } 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. + * Purpose: Pre-initialize the WASM module before the first spa() call. + * Inputs: none + * Outputs: Promise; resolves when the module is ready + * Constraints: Safe to call multiple times; subsequent calls return immediately. + * If init() fails, the next call retries (failed promise is discarded). + * SPORT: packages.md → solar-spa row + * + * @returns Promise that resolves when the WASM module is initialized. + * @example + * import { init, spa } from 'solar-spa'; + * await init(); // pay WASM startup cost at app boot + * const result = await spa(new Date(), 40.7128, -74.0060); // no init overhead */ export function init(): Promise { if (_module) return Promise.resolve(); @@ -87,11 +96,19 @@ export function init(): Promise { } /** - * Format fractional hours to HH:MM:SS string. - * Returns "N/A" for non-finite or negative values (polar night/day scenarios). + * Purpose: Convert fractional hours to an HH:MM:SS string. + * Inputs: hours, fractional hours (e.g. 6.5 for "06:30:00"); values ≥24 wrap + * Outputs: "HH:MM:SS" string, or "N/A" for non-finite/negative inputs + * Constraints: Non-finite and negative values occur during polar day/night; + * returning "N/A" lets callers display a sensible label without special-casing. + * SPORT: packages.md → solar-spa row * * @param hours - Fractional hours (e.g. 6.5 for 06:30:00). Values >= 24 wrap. - * @returns Formatted time string in HH:MM:SS format, or "N/A" if input is invalid. + * @returns Formatted time string in HH:MM:SS, or "N/A" for invalid input. + * @example + * formatTime(6.5) // "06:30:00" + * formatTime(12) // "12:00:00" + * formatTime(Infinity) // "N/A" */ export function formatTime(hours: number): string { if (!isFinite(hours) || hours < 0) return 'N/A'; @@ -162,15 +179,27 @@ function validateOptions(opts: SpaOptions): void { } /** - * Compute solar position for the given parameters. + * Purpose: Compute solar position using the NREL SPA algorithm via WASM. + * Inputs: date, latitude (-90..90), longitude (-180..180), optional SpaOptions + * Outputs: Promise with zenith, azimuth, incidence, sunrise/sunset/transit, eot + * Constraints: WASM module is a singleton; first call incurs ~3-5 ms init cost. + * Pass explicit timezone for server-side code (system may be UTC, not local). + * Input validation runs before WASM call; invalid inputs throw before allocating. + * SPORT: packages.md → solar-spa row * * @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 + * @param options - Optional observer and algorithm parameters (timezone, elevation, etc.) * @returns Solar position result with all computed values * @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers * @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN + * @throws {Error} If WASM memory allocation fails or SPA returns a non-zero error code + * @example + * import { spa } from 'solar-spa'; + * const result = await spa(new Date('2025-06-21T12:00:00Z'), 40.7128, -74.006, { timezone: -4 }); + * console.log(result.zenith); // ~27 degrees + * console.log(result.sunrise); // ~5.4 fractional hours */ export async function spa( date: Date, @@ -236,13 +265,27 @@ export async function spa( } /** - * 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. + * Purpose: Compute solar position with time fields formatted as HH:MM:SS strings. + * Inputs: same as spa(): date, latitude, longitude, options + * Outputs: Promise; sunrise/sunset/suntransit are strings, all other fields numbers + * Constraints: Delegates to spa() internally; throws under the same conditions. + * "N/A" is returned for sunrise/sunset/suntransit during polar day or polar night. + * SPORT: packages.md → solar-spa row * + * @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 observer and algorithm parameters + * @returns Solar position result with sunrise, sunset, suntransit as HH:MM:SS strings * @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers * @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN + * @throws {Error} If WASM memory allocation fails or SPA returns a non-zero error code + * @example + * import { spaFormatted } from 'solar-spa'; + * const result = await spaFormatted(new Date('2025-06-21T12:00:00Z'), 40.7128, -74.006, { timezone: -4 }); + * console.log(result.sunrise); // "05:25:12" + * console.log(result.suntransit); // "12:59:58" + * console.log(result.sunset); // "20:34:47" */ export async function spaFormatted( date: Date,