Compare commits

...

11 commits
v2.0.1 ... main

Author SHA1 Message Date
Aric Camarata
f335a03f7c
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 30s
CI / Test (Node 22) (push) Failing after 41s
CI / Test (Node 24) (push) Failing after 41s
CI / Lint & Format (push) Failing after 34s
CI / Typecheck (push) Failing after 31s
CI / Pack Check (push) Failing after 34s
CI / Coverage (push) Failing after 3s
* add opt-in telemetry via @acamarata/telemetry (off by default)

* chore: update lockfile for @acamarata/telemetry devDep

* chore: fix prettier formatting on telemetry import
2026-06-30 15:56:57 -04:00
Aric Camarata
f28abeea65 build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:30:59 -04:00
Aric Camarata
6aabd304b0 ci: fix coverage corepack order, eslint parser devDeps, pack-check d.mts
- Move corepack enable before actions/setup-node in coverage job
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin as direct devDeps
- Add files pattern and parserOptions.project to eslint.config.mjs
- Change build:ts to cp dist/index.d.ts dist/index.d.mts so pack-check passes
- Run prettier format on src/ files
2026-05-31 08:46:54 -04:00
Aric Camarata
706b67d0f6 release: v2.0.2 — P1 standardization 2026-05-30 19:12:24 -04:00
Aric Camarata
79e2c03505 chore: refresh CI workflow + TypeDoc wiki (E5 conformance) 2026-05-30 18:37:50 -04:00
Aric Camarata
ffb720a7c8 docs: refresh TypeDoc API output (T-E8-03 QA-A verify) 2026-05-30 17:48:45 -04:00
Aric Camarata
3c848806ab docs: add TypeDoc API generation (typedoc@0.28.19 + typedoc-plugin-markdown@4.11.0)
Add typedoc and typedoc-plugin-markdown as devDependencies. Add typedoc.json config
targeting src/index.ts with markdown output to .github/wiki/api. Add docs script to
package.json. Generate initial API reference pages.

Part of T-E8-03 — TypeDoc automation for all 12 JS/TS packages.
2026-05-30 16:41:57 -04:00
Aric Camarata
aa0161a824 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:04:42 -04:00
Aric Camarata
b8da0c8717 ci(solar-spa): enable corepack before setup-node, emit d.mts 2026-05-29 20:06:49 -04:00
Aric Camarata
525bb15f5b chore: E6 polish wiki content (P1) 2026-05-29 07:15:56 -04:00
Aric Camarata
6bb7d729bb chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:40 -04:00
32 changed files with 1861 additions and 125 deletions

View file

@ -1 +0,0 @@
CLAUDE.md

View file

@ -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)

35
.github/wiki/api/README.md vendored Normal file
View file

@ -0,0 +1,35 @@
**solar-spa v2.0.1**
***
# solar-spa v2.0.1
## Interfaces
- [SpaFormattedResult](interfaces/SpaFormattedResult.md)
- [SpaOptions](interfaces/SpaOptions.md)
- [SpaResult](interfaces/SpaResult.md)
## Type Aliases
- [SpaFunctionCode](type-aliases/SpaFunctionCode.md)
## Variables
- [SPA\_ALL](variables/SPA_ALL.md)
- [SPA\_ZA](variables/SPA_ZA.md)
- [SPA\_ZA\_INC](variables/SPA_ZA_INC.md)
- [SPA\_ZA\_RTS](variables/SPA_ZA_RTS.md)
## Functions
- [formatTime](functions/formatTime.md)
- [init](functions/init.md)
- [spa](functions/spa.md)
- [spaFormatted](functions/spaFormatted.md)
## References
### default
Renames and re-exports [spa](functions/spa.md)

View file

@ -0,0 +1,40 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / formatTime
# Function: formatTime()
> **formatTime**(`hours`): `string`
Defined in: [index.ts:113](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/index.ts#L113)
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
## Parameters
### hours
`number`
Fractional hours (e.g. 6.5 for 06:30:00). Values >= 24 wrap.
## Returns
`string`
Formatted time string in HH:MM:SS, or "N/A" for invalid input.
## Example
```ts
formatTime(6.5) // "06:30:00"
formatTime(12) // "12:00:00"
formatTime(Infinity) // "N/A"
```

32
.github/wiki/api/functions/init.md vendored Normal file
View file

@ -0,0 +1,32 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / init
# Function: init()
> **init**(): `Promise`\<`void`\>
Defined in: [index.ts:60](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/index.ts#L60)
Purpose: Pre-initialize the WASM module before the first spa() call.
Inputs: none
Outputs: Promise<void>; 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`\<`void`\>
Promise that resolves when the WASM module is initialized.
## Example
```ts
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
```

72
.github/wiki/api/functions/spa.md vendored Normal file
View file

@ -0,0 +1,72 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / spa
# Function: spa()
> **spa**(`date`, `latitude`, `longitude`, `options?`): `Promise`\<[`SpaResult`](../interfaces/SpaResult.md)\>
Defined in: [index.ts:204](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/index.ts#L204)
Purpose: Compute solar position using the NREL SPA algorithm via WASM.
Inputs: date, latitude (-90..90), longitude (-180..180), optional SpaOptions
Outputs: Promise<SpaResult> 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
## Parameters
### date
`Date`
Date and time for the calculation
### latitude
`number`
Observer latitude in degrees (-90 to 90)
### longitude
`number`
Observer longitude in degrees (-180 to 180)
### options?
[`SpaOptions`](../interfaces/SpaOptions.md)
Optional observer and algorithm parameters (timezone, elevation, etc.)
## Returns
`Promise`\<[`SpaResult`](../interfaces/SpaResult.md)\>
Solar position result with all computed values
## Throws
If date is not a valid Date, or if latitude/longitude/option fields are not numbers
## Throws
If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
## Throws
If WASM memory allocation fails or SPA returns a non-zero error code
## Example
```ts
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
```

View file

@ -0,0 +1,72 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / spaFormatted
# Function: spaFormatted()
> **spaFormatted**(`date`, `latitude`, `longitude`, `options?`): `Promise`\<[`SpaFormattedResult`](../interfaces/SpaFormattedResult.md)\>
Defined in: [index.ts:290](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/index.ts#L290)
Purpose: Compute solar position with time fields formatted as HH:MM:SS strings.
Inputs: same as spa(): date, latitude, longitude, options
Outputs: Promise<SpaFormattedResult>; 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
## Parameters
### date
`Date`
Date and time for the calculation
### latitude
`number`
Observer latitude in degrees (-90 to 90)
### longitude
`number`
Observer longitude in degrees (-180 to 180)
### options?
[`SpaOptions`](../interfaces/SpaOptions.md)
Optional observer and algorithm parameters
## Returns
`Promise`\<[`SpaFormattedResult`](../interfaces/SpaFormattedResult.md)\>
Solar position result with sunrise, sunset, suntransit as HH:MM:SS strings
## Throws
If date is not a valid Date, or if latitude/longitude/option fields are not numbers
## Throws
If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
## Throws
If WASM memory allocation fails or SPA returns a non-zero error code
## Example
```ts
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"
```

View file

@ -0,0 +1,141 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SpaFormattedResult
# Interface: SpaFormattedResult
Defined in: [types.ts:65](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L65)
## Extends
- `Omit`\<[`SpaResult`](SpaResult.md), `"sunrise"` \| `"sunset"` \| `"suntransit"`\>
## Properties
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:48](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L48)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`azimuth`](SpaResult.md#azimuth)
***
### azimuth\_astro
> **azimuth\_astro**: `number`
Defined in: [types.ts:46](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L46)
Topocentric azimuth angle, westward from south (astronomical convention), in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`azimuth_astro`](SpaResult.md#azimuth_astro)
***
### eot
> **eot**: `number`
Defined in: [types.ts:60](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L60)
Equation of time in minutes.
#### Inherited from
[`SpaResult`](SpaResult.md).[`eot`](SpaResult.md#eot)
***
### error\_code
> **error\_code**: `number`
Defined in: [types.ts:62](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L62)
SPA error code. Always 0 on a successful return (non-zero throws).
#### Inherited from
[`SpaResult`](SpaResult.md).[`error_code`](SpaResult.md#error_code)
***
### incidence
> **incidence**: `number`
Defined in: [types.ts:50](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L50)
Surface incidence angle in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`incidence`](SpaResult.md#incidence)
***
### sun\_transit\_alt
> **sun\_transit\_alt**: `number`
Defined in: [types.ts:58](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L58)
Sun transit altitude in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`sun_transit_alt`](SpaResult.md#sun_transit_alt)
***
### sunrise
> **sunrise**: `string`
Defined in: [types.ts:67](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L67)
Local sunrise time as HH:MM:SS string. "N/A" during polar day/night.
***
### sunset
> **sunset**: `string`
Defined in: [types.ts:69](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L69)
Local sunset time as HH:MM:SS string. "N/A" during polar day/night.
***
### suntransit
> **suntransit**: `string`
Defined in: [types.ts:71](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L71)
Local sun transit time as HH:MM:SS string. "N/A" during polar day/night.
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:44](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L44)
Topocentric zenith angle in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`zenith`](SpaResult.md#zenith)

View file

@ -0,0 +1,110 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SpaOptions
# Interface: SpaOptions
Defined in: [types.ts:16](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L16)
## Properties
### atmos\_refract?
> `optional` **atmos\_refract?**: `number`
Defined in: [types.ts:37](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L37)
Atmospheric refraction at sunrise/sunset in degrees. Default: 0.5667.
***
### azm\_rotation?
> `optional` **azm\_rotation?**: `number`
Defined in: [types.ts:35](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L35)
Surface azimuth rotation in degrees from south. Default: 0.
***
### delta\_t?
> `optional` **delta\_t?**: `number`
Defined in: [types.ts:31](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L31)
TT-UTC difference in seconds. Default: 67.
***
### delta\_ut1?
> `optional` **delta\_ut1?**: `number`
Defined in: [types.ts:29](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L29)
UT1-UTC correction in seconds. Default: 0.
***
### elevation?
> `optional` **elevation?**: `number`
Defined in: [types.ts:23](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L23)
Observer elevation in meters above sea level. Default: 0.
***
### function?
> `optional` **function?**: [`SpaFunctionCode`](../type-aliases/SpaFunctionCode.md)
Defined in: [types.ts:39](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L39)
SPA function code. Default: SPA_ALL (3).
***
### pressure?
> `optional` **pressure?**: `number`
Defined in: [types.ts:25](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L25)
Atmospheric pressure in millibars. Default: 1013.25.
***
### slope?
> `optional` **slope?**: `number`
Defined in: [types.ts:33](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L33)
Surface slope in degrees from horizontal. Default: 0.
***
### temperature?
> `optional` **temperature?**: `number`
Defined in: [types.ts:27](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L27)
Temperature in degrees Celsius. Default: 15.
***
### timezone?
> `optional` **timezone?**: `number`
Defined in: [types.ts:21](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L21)
Hours from UTC. If omitted, derived from the Date object's local offset.
For historical dates or DST transitions, pass an explicit value.

109
.github/wiki/api/interfaces/SpaResult.md vendored Normal file
View file

@ -0,0 +1,109 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SpaResult
# Interface: SpaResult
Defined in: [types.ts:42](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L42)
## Properties
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:48](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L48)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
***
### azimuth\_astro
> **azimuth\_astro**: `number`
Defined in: [types.ts:46](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L46)
Topocentric azimuth angle, westward from south (astronomical convention), in degrees.
***
### eot
> **eot**: `number`
Defined in: [types.ts:60](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L60)
Equation of time in minutes.
***
### error\_code
> **error\_code**: `number`
Defined in: [types.ts:62](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L62)
SPA error code. Always 0 on a successful return (non-zero throws).
***
### incidence
> **incidence**: `number`
Defined in: [types.ts:50](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L50)
Surface incidence angle in degrees.
***
### sun\_transit\_alt
> **sun\_transit\_alt**: `number`
Defined in: [types.ts:58](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L58)
Sun transit altitude in degrees.
***
### sunrise
> **sunrise**: `number`
Defined in: [types.ts:52](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L52)
Local sunrise time as fractional hours.
***
### sunset
> **sunset**: `number`
Defined in: [types.ts:54](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L54)
Local sunset time as fractional hours.
***
### suntransit
> **suntransit**: `number`
Defined in: [types.ts:56](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L56)
Local sun transit time (solar noon) as fractional hours.
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:44](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L44)
Topocentric zenith angle in degrees.

View file

@ -0,0 +1,11 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SpaFunctionCode
# Type Alias: SpaFunctionCode
> **SpaFunctionCode** = *typeof* [`SPA_ZA`](../variables/SPA_ZA.md) \| *typeof* [`SPA_ZA_INC`](../variables/SPA_ZA_INC.md) \| *typeof* [`SPA_ZA_RTS`](../variables/SPA_ZA_RTS.md) \| *typeof* [`SPA_ALL`](../variables/SPA_ALL.md)
Defined in: [types.ts:10](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L10)

13
.github/wiki/api/variables/SPA_ALL.md vendored Normal file
View file

@ -0,0 +1,13 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SPA\_ALL
# Variable: SPA\_ALL
> `const` **SPA\_ALL**: `3`
Defined in: [types.ts:8](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L8)
Compute all outputs: zenith, azimuth, incidence, and rise/transit/set.

13
.github/wiki/api/variables/SPA_ZA.md vendored Normal file
View file

@ -0,0 +1,13 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SPA\_ZA
# Variable: SPA\_ZA
> `const` **SPA\_ZA**: `0`
Defined in: [types.ts:2](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L2)
Compute zenith and azimuth only.

View file

@ -0,0 +1,13 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SPA\_ZA\_INC
# Variable: SPA\_ZA\_INC
> `const` **SPA\_ZA\_INC**: `1`
Defined in: [types.ts:4](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L4)
Compute zenith, azimuth, and incidence angle.

View file

@ -0,0 +1,13 @@
[**solar-spa v2.0.1**](../README.md)
***
[solar-spa](../README.md) / SPA\_ZA\_RTS
# Variable: SPA\_ZA\_RTS
> `const` **SPA\_ZA\_RTS**: `2`
Defined in: [types.ts:6](https://github.com/acamarata/solar-spa/blob/3c848806ab852464d76baf7e3fc0ca58f5e42fd9/src/types.ts#L6)
Compute zenith, azimuth, and rise/transit/set times.

47
.github/wiki/benchmarks/index.md vendored Normal file
View file

@ -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)

View file

@ -0,0 +1,42 @@
# Example: Annual Daylight Hours
Compute total daylight hours for every day of a year and find the longest and shortest days.
```js
import { init, spa } from 'solar-spa';
const LAT = 51.5074; // London
const LON = -0.1278;
const TZ = 0;
await init();
const year = 2025;
const results = [];
for (let doy = 0; doy < 365; doy++) {
const date = new Date(Date.UTC(year, 0, 1 + doy, 12, 0, 0));
const r = await spa(date, LAT, LON, { timezone: TZ });
const daylight = isFinite(r.sunrise) ? r.sunset - r.sunrise : 0;
results.push({ date, daylight, sunrise: r.sunrise, sunset: r.sunset });
}
const longest = results.reduce((a, b) => a.daylight > b.daylight ? a : b);
const shortest = results.reduce((a, b) => a.daylight < b.daylight ? a : b);
console.log('London 2025');
console.log(`Longest day: ${longest.date.toISOString().slice(0, 10)} — ${longest.daylight.toFixed(2)} hours`);
console.log(`Shortest day: ${shortest.date.toISOString().slice(0, 10)} — ${shortest.daylight.toFixed(2)} hours`);
const total = results.reduce((sum, r) => sum + r.daylight, 0);
console.log(`Annual total: ${total.toFixed(0)} hours of daylight`);
```
Sample output:
```
London 2025
Longest day: 2025-06-21 — 16.44 hours
Shortest day: 2025-12-21 — 7.69 hours
Annual total: 4466 hours of daylight
```

41
.github/wiki/examples/solar-clock.md vendored Normal file
View file

@ -0,0 +1,41 @@
# Example: Solar Clock
A simple Node.js script that prints the current solar position and today's solar events for a given location.
```js
import { spa, spaFormatted, init } from 'solar-spa';
const LAT = 40.7128; // New York
const LON = -74.0060;
const TZ = -4; // EDT
await init();
const now = new Date();
const raw = await spa(now, LAT, LON, { timezone: TZ });
const formatted = await spaFormatted(now, LAT, LON, { timezone: TZ });
console.log('Solar position right now');
console.log(` Zenith: ${raw.zenith.toFixed(2)}°`);
console.log(` Azimuth: ${raw.azimuth.toFixed(2)}°`);
console.log('');
console.log('Today\'s solar events');
console.log(` Sunrise: ${formatted.sunrise}`);
console.log(` Solar noon: ${formatted.suntransit}`);
console.log(` Sunset: ${formatted.sunset}`);
console.log(` Day length: ${(raw.sunset - raw.sunrise).toFixed(2)} hours`);
```
Sample output for a summer day in New York:
```
Solar position right now
Zenith: 28.14°
Azimuth: 214.33°
Today's solar events
Sunrise: 05:25:12
Solar noon: 12:59:58
Sunset: 20:34:47
Day length: 15.16 hours
```

108
.github/wiki/guides/advanced.md vendored Normal file
View file

@ -0,0 +1,108 @@
# Advanced Usage
Edge cases, batch processing, and environment-specific notes.
## Timezone handling
`spa()` accepts a `timezone` option as a UTC offset in hours. If omitted, it reads from the `Date` object's built-in UTC offset.
```js
// Explicit UTC offset (recommended for server-side code)
const result = await spa(date, lat, lon, { timezone: -5 }); // EST
// Implicit — inferred from Date.getTimezoneOffset()
const result = await spa(date, lat, lon);
```
On servers, the system timezone is typically UTC. Pass an explicit `timezone` value when computing local solar events.
## Batch calculations
The WASM module is initialized once and reused across calls. Batch work is fast because there is no re-initialization overhead.
```js
import { init, spa } from 'solar-spa';
await init();
const dates = Array.from({ length: 365 }, (_, i) => {
const d = new Date('2025-01-01T12:00:00Z');
d.setUTCDate(d.getUTCDate() + i);
return d;
});
const results = await Promise.all(
dates.map(d => spa(d, 51.5074, -0.1278)), // London
);
const maxZenith = Math.min(...results.map(r => r.zenith));
console.log(`Lowest zenith (highest sun): ${maxZenith.toFixed(2)}°`);
```
## Polar scenarios
At high latitudes, sunrise or sunset may not occur. These fields return `NaN` in that case.
```js
import { spaFormatted } from 'solar-spa';
const result = await spaFormatted(
new Date('2025-12-21T12:00:00Z'),
89.0, // near North Pole
0,
);
console.log(result.sunrise); // "N/A" — polar night
console.log(result.sunset); // "N/A"
```
Use `isFinite(result.sunrise)` on the raw numeric result to detect polar conditions.
## Incidence angle calculation
The incidence angle (angle between solar beam and a tilted surface normal) requires slope and azimuth rotation inputs.
```js
const result = await spa(date, lat, lon, {
slope: 35, // surface tilt in degrees (0 = horizontal)
azm_rotation: 0, // surface azimuth deviation from south (degrees)
function: 3, // SPA_ALL — compute incidence angle
});
console.log(result.incidence); // degrees from surface normal
```
## Delta-T corrections
Delta-T (ΔT) is the difference between Terrestrial Time and Universal Time. The default (67 seconds) is accurate for dates near 2025. For historical or far-future dates, provide a more accurate value.
```js
// For 1900-01-01, ΔT ≈ -2.72 seconds
const result = await spa(new Date('1900-01-01T12:00:00Z'), lat, lon, {
delta_t: -2.72,
});
```
See the USNO delta-T tables for values outside the 2000-2050 range.
## Error handling
`spa()` throws `TypeError` for invalid inputs and `RangeError` for out-of-bounds values.
```js
try {
await spa(new Date('invalid'), 40, -74);
} catch (e) {
if (e instanceof TypeError) {
console.error('Invalid date');
}
}
```
A non-zero `error_code` in the result indicates an internal SPA calculation error, which should not occur with valid inputs.
## Related pages
- [Performance](../Performance) — benchmarks and optimization
- [Bundler Compatibility](../Bundler-Compatibility) — Webpack, Vite, Next.js notes
- [API Reference](../API-Reference) — full parameter documentation

73
.github/wiki/guides/quickstart.md vendored Normal file
View file

@ -0,0 +1,73 @@
# Quick Start
Five minutes from install to solar position.
## Install
```sh
npm install solar-spa
```
## Basic usage
```js
import { spa } from 'solar-spa';
const result = await spa(
new Date('2025-06-21T12:00:00Z'),
40.7128, // latitude
-74.0060, // longitude
);
console.log(result.zenith); // solar zenith angle in degrees
console.log(result.azimuth); // solar azimuth in degrees
console.log(result.sunrise); // fractional hours, e.g. 5.42
console.log(result.sunset); // fractional hours, e.g. 20.58
```
The module initializes the WASM binary on the first call. Subsequent calls reuse the same module instance.
## Formatted output
```js
import { spaFormatted } from 'solar-spa';
const result = await spaFormatted(
new Date('2025-06-21T12:00:00Z'),
40.7128,
-74.0060,
);
console.log(result.sunrise); // "05:25:12"
console.log(result.sunset); // "20:34:47"
console.log(result.suntransit); // "12:59:58"
```
## Options
```js
const result = await spa(date, lat, lon, {
elevation: 100, // metres above sea level
pressure: 1013.25, // millibars (default)
temperature: 15, // degrees Celsius (default)
delta_t: 67, // difference between UT1 and TT in seconds (default)
slope: 30, // surface slope in degrees (for incidence angle)
azm_rotation: 10, // surface azimuth rotation in degrees
});
```
## Eager initialization
Call `init()` at startup to avoid the first-call latency:
```js
import { init, spa } from 'solar-spa';
await init(); // WASM loads now, not on first spa() call
```
## Next steps
- [API Reference](../API-Reference) — full function signatures and return types
- [Architecture](../Architecture) — how the C/WASM/JS layers fit together
- [Advanced Guide](advanced) — batch calculations, custom options, timezone handling

View file

@ -15,14 +15,14 @@ jobs:
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile
@ -40,14 +40,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
@ -58,14 +58,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
@ -75,14 +75,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm run build:ts
@ -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: Enable corepack
run: corepack enable
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- 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

View file

@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.0.2] - 2026-05-30
### Changed
- Added `"type": "module"` to package.json per ADR-015 TypeScript source standard. The WASM compatibility issue from 2.0.1 is resolved: tsup now produces proper dual CJS/ESM dist files that Node resolves correctly via the exports map, so the package-level `type` field no longer conflicts with WASM module loading.
- Added `"./package.json"` exports entry for bundler compatibility.
- Added `"postbuild"` script to copy `dist/index.d.ts` to `dist/index.d.mts`.
- Adopted shared config packages (`@acamarata/tsconfig`, `@acamarata/eslint-config`, `@acamarata/prettier-config`).
## [2.0.1] - 2026-05-28
### Fixed

View file

@ -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
@ -57,6 +59,11 @@ This package includes the Solar Position Algorithm (SPA) developed at the Nation
> Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." *Solar Energy*, 76(5), 577-589.
## Telemetry
This package supports opt-in anonymous usage telemetry — off by default.
Enable: `ACAMARATA_TELEMETRY=1`. See [TELEMETRY.md](./TELEMETRY.md) for what is sent and how to disable.
## 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.

8
TELEMETRY.md Normal file
View file

@ -0,0 +1,8 @@
# Telemetry Disclosure
This package supports opt-in anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry).
Telemetry is **off by default**. No data is sent unless you set `ACAMARATA_TELEMETRY=1`.
Full disclosure (what is sent, where it goes, how to disable):
[github.com/acamarata/telemetry/blob/main/TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md)

View file

@ -1,12 +1,23 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
export default [
{
files: ['src/**/*.ts'],
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
...typescript.map((cfg) => ({ ...cfg, files: ['src/**/*.ts'] })),
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'wasm/', 'src/spa.c', 'src/spa.h', 'validate.mjs'],
},
);
];

View file

@ -1,6 +1,7 @@
{
"type": "module",
"name": "solar-spa",
"version": "2.0.1",
"version": "2.0.2",
"description": "NREL Solar Position Algorithm (SPA) compiled to WebAssembly. High-performance solar position, sunrise, sunset, and solar noon calculations.",
"author": "Aric Camarata",
"license": "MIT",
@ -28,7 +29,7 @@
],
"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:ts": "tsup && cp dist/index.d.ts dist/index.d.mts",
"build": "pnpm run build:wasm && pnpm run build:ts",
"typecheck": "tsc --noEmit",
"pretest": "pnpm run build:ts",
@ -37,8 +38,10 @@
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"validate": "node validate.mjs",
"prepublishOnly": "pnpm run build:ts",
"coverage": "c8 --reporter=lcov --reporter=text node --test"
"prepack": "pnpm run build:ts",
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
"docs": "typedoc --out .github/wiki/api src/index.ts",
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
},
"keywords": [
"solar",
@ -70,14 +73,24 @@
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^10.1.3",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.5.1",
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
"typescript-eslint": "^8.56.1",
"@acamarata/telemetry": "^0.1.0"
},
"packageManager": "pnpm@10.11.1"
"packageManager": "pnpm@10.11.1",
"prettier": "@acamarata/prettier-config"
}

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,17 @@
import type { SpaWasmModule, SpaResult, SpaFormattedResult, SpaOptions } from './types.js';
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';
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';
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.cjs');
const _loadModule = typeof __require === "function" ? __require : require;
const createSpaModule: () => Promise<SpaWasmModule> = _loadModule("../wasm/spa-module.cjs");
// Singleton: the WASM module initializes once, all calls share it.
let _module: SpaWasmModule | null = null;
@ -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<void>; 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<void> {
if (_module) return Promise.resolve();
@ -55,27 +64,27 @@ export function init(): Promise<void> {
_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',
_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;
_free = mod.cwrap("spa_free_result", null, ["number"]) as (ptr: number) => void;
_pending = null;
})
.catch((err: unknown) => {
@ -87,14 +96,22 @@ export function init(): Promise<void> {
}
/**
* 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';
if (!isFinite(hours) || hours < 0) return "N/A";
const totalSec = Math.round(hours * 3600);
const h = Math.floor(totalSec / 3600) % 24;
@ -102,7 +119,7 @@ export function formatTime(hours: number): string {
const s = totalSec % 60;
return (
String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0")
);
}
@ -110,16 +127,16 @@ export function formatTime(hours: number): string {
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'),
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;
@ -130,7 +147,7 @@ function readResult(ptr: number): SpaResult {
* @internal
*/
function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== 'number') {
if (typeof value !== "number") {
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value}`);
}
if (!isFinite(value)) {
@ -140,13 +157,13 @@ function assertFiniteNumber(value: unknown, name: string): asserts value is numb
/** Field names in SpaOptions that must be finite numbers when provided. */
const NUMERIC_OPTION_FIELDS = [
'elevation',
'pressure',
'temperature',
'delta_t',
'slope',
'azm_rotation',
'atmos_refract',
"elevation",
"pressure",
"temperature",
"delta_t",
"slope",
"azm_rotation",
"atmos_refract",
] as const;
/**
@ -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<SpaResult> 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,
@ -180,10 +209,10 @@ export async function spa(
): Promise<SpaResult> {
// Input validation
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError('SPA: date must be a valid Date object');
throw new TypeError("SPA: date must be a valid Date object");
}
assertFiniteNumber(latitude, 'latitude');
assertFiniteNumber(longitude, 'longitude');
assertFiniteNumber(latitude, "latitude");
assertFiniteNumber(longitude, "longitude");
if (latitude < -90 || latitude > 90) {
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
@ -223,26 +252,40 @@ export async function spa(
);
if (!ptr) {
throw new Error('SPA: memory allocation failed');
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 + ')');
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.
* Purpose: Compute solar position with time fields formatted as HH:MM:SS strings.
* Inputs: same as spa(): date, latitude, longitude, options
* Outputs: Promise<SpaFormattedResult>; 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,
@ -266,3 +309,12 @@ export async function spaFormatted(
}
export default spa;
// ── Opt-in anonymous telemetry ────────────────────────────────────────────────
// Off by default. Enable: ACAMARATA_TELEMETRY=1
// What is sent + how to disable: https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md
import("@acamarata/telemetry")
.then(({ track }) => track("load", { package: "solar-spa", version: "2.0.2" }))
.catch(() => {
// telemetry not installed or disabled — that is fine
});

View file

@ -62,7 +62,7 @@ export interface SpaResult {
error_code: number;
}
export interface SpaFormattedResult extends Omit<SpaResult, 'sunrise' | 'sunset' | 'suntransit'> {
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. */

View file

@ -1,19 +1,9 @@
{
"extends": "@acamarata/tsconfig/tsconfig.library.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"outDir": "dist",
"esModuleInterop": true,
"rootDir": "src",
"types": ["node"]
},

10
typedoc.json Normal file
View file

@ -0,0 +1,10 @@
{
"entryPoints": ["src/index.ts"],
"out": ".github/wiki/api",
"plugin": ["typedoc-plugin-markdown"],
"readme": "none",
"skipErrorChecking": false,
"excludePrivate": true,
"excludeProtected": true,
"includeVersion": true
}