Compare commits

...

10 commits
v2.0.2 ... main

Author SHA1 Message Date
Aric Camarata
74b6f609f8
add opt-in anonymous telemetry (#1)
Some checks failed
CI / test (20) (push) Failing after 27s
CI / test (22) (push) Failing after 31s
CI / test (24) (push) Failing after 40s
CI / typecheck (push) Failing after 32s
CI / Lint & Format (push) Failing after 33s
CI / pack-check (push) Failing after 42s
CI / coverage (push) Failing after 2s
* add Forgejo CI mirror and telemetry disclosure

Mirrors .github/workflows/ci.yml to .forgejo/workflows/ for self-hosted
runner on git.ariccamarata.com. Adds failure-reporting hook stub (server
registration via nself sentry ci enable is a server-side step). Adds
telemetry disclosure section to README.

* 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
35128dda86 build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:30:51 -04:00
Aric Camarata
70da984a3b ci: fix eslint parser devDeps, flat config files pattern, untrack coverage
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin as
  direct devDependencies (were only peer deps of @acamarata/eslint-config,
  causing ERR_MODULE_NOT_FOUND in CI)
- Add files: ['**/*.ts'] to eslint.config.mjs so ESLint 10 flat config
  picks up TypeScript files; add parserOptions.project for type-aware rules
- Run prettier --write src/ to fix formatting after config changes
- Add coverage/ to .gitignore and untrack previously committed coverage files
2026-05-31 08:48:01 -04:00
Aric Camarata
1e75709789 chore: P1 standardization — finalize src/wiki/ci 2026-05-30 18:48:57 -04:00
Aric Camarata
6be2c20113 docs: refresh TypeDoc API output (T-E8-03 QA-A verify) 2026-05-30 17:48:45 -04:00
Aric Camarata
b52802f94b 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
dea28b9262 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:03:07 -04:00
Aric Camarata
8a8efd69ad ci: corepack before setup-node, scope prettier to src/, emit d.mts 2026-05-29 20:05:22 -04:00
Aric Camarata
a2a4564731 chore: E6 polish wiki content (P1) 2026-05-29 07:15:55 -04:00
Aric Camarata
37f2823e9d chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:40 -04:00
36 changed files with 2089 additions and 79 deletions

View file

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

165
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,165 @@
# Forgejo CI mirror — git.ariccamarata.com
# Mirrors .github/workflows/ci.yml for the self-hosted Forgejo Actions runner.
# Keep in sync with the GitHub workflow; only addition is the nSentry failure step.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
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
- run: pnpm install --frozen-lockfile
- name: Build TypeScript
run: pnpm run build
- name: Run tests (ESM)
run: node --test test.mjs
- name: Run tests (CJS)
run: node --test test-cjs.cjs
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
typecheck:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
lint:
name: Lint & Format
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
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
pack-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- 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 lib/spa.js README.md CHANGELOG.md LICENSE; do
grep -qE "(^|[[:space:]])${f}([[:space:]]|$)" pack-output.txt || { echo "MISSING: $f"; exit 1; }
done
echo "All expected files present in package"
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
coverage:
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
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- 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
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2

View file

@ -2,6 +2,17 @@
**[Home](Home)** **[Home](Home)**
**Guides**
- [Quick Start](guides/quickstart)
- [Advanced Usage](guides/advanced)
**API**
- [getSpa](api/getSpa)
- [calcSpa](api/calcSpa)
- [formatTime](api/formatTime)
- [SpaOptions](api/SpaOptions)
- [SpaFunctionCode](api/SpaFunctionCode)
**Reference** **Reference**
- [API Reference](API-Reference) - [API Reference](API-Reference)
- [Architecture](Architecture) - [Architecture](Architecture)
@ -10,6 +21,11 @@
**Deep Dives** **Deep Dives**
- [Implementation Comparison](Implementation-Comparison) - [Implementation Comparison](Implementation-Comparison)
- [Twilight Calculations](Twilight-Calculations) - [Twilight Calculations](Twilight-Calculations)
- [Benchmarks](benchmarks/index)
**Examples**
- [Solar Position Logger](examples/solar-position)
- [Twilight Times](examples/twilight-times)
**Contributing** **Contributing**
- [Contributing](Contributing) - [Contributing](Contributing)

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

@ -0,0 +1,32 @@
**nrel-spa v2.0.2**
***
# nrel-spa v2.0.2
## Interfaces
- [SpaAnglesResult](interfaces/SpaAnglesResult.md)
- [SpaFormattedAnglesResult](interfaces/SpaFormattedAnglesResult.md)
- [SpaFormattedResult](interfaces/SpaFormattedResult.md)
- [SpaFormattedResultWithAngles](interfaces/SpaFormattedResultWithAngles.md)
- [SpaOptions](interfaces/SpaOptions.md)
- [SpaResult](interfaces/SpaResult.md)
- [SpaResultWithAngles](interfaces/SpaResultWithAngles.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
- [calcSpa](functions/calcSpa.md)
- [formatTime](functions/formatTime.md)
- [getSpa](functions/getSpa.md)

113
.github/wiki/api/functions/calcSpa.md vendored Normal file
View file

@ -0,0 +1,113 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / calcSpa
# Function: calcSpa()
## Call Signature
> **calcSpa**(`date`, `latitude`, `longitude`, `timezone?`, `options?`): [`SpaFormattedResult`](../interfaces/SpaFormattedResult.md)
Defined in: [index.ts:307](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/index.ts#L307)
Same as getSpa(), but formats sunrise, solarNoon, and sunset as HH:MM:SS strings.
Returns "N/A" for time fields during polar day or polar night.
### Parameters
#### date
`Date`
JavaScript Date object (uses UTC components)
#### latitude
`number`
Observer latitude in degrees (-90 to 90, negative = south)
#### longitude
`number`
Observer longitude in degrees (-180 to 180, negative = west)
#### timezone?
`number` \| `null`
Hours from UTC (e.g., -4 for EDT). Default: 0
#### options?
[`SpaOptions`](../interfaces/SpaOptions.md) \| `null`
Optional atmospheric and calculation parameters
### Returns
[`SpaFormattedResult`](../interfaces/SpaFormattedResult.md)
Formatted solar position result with HH:MM:SS time strings
### Throws
If date, latitude, longitude, timezone, or options numeric fields are not finite numbers
### Throws
If latitude, longitude, timezone, function code, or angle values are out of range
### See
[Wiki: calcSpa](https://github.com/acamarata/nrel-spa/wiki/api/calcSpa)
## Call Signature
> **calcSpa**(`date`, `latitude`, `longitude`, `timezone`, `options`, `angles`): [`SpaFormattedResultWithAngles`](../interfaces/SpaFormattedResultWithAngles.md)
Defined in: [index.ts:321](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/index.ts#L321)
Same as getSpa() with custom angles, but formats all time values as HH:MM:SS strings.
Returns "N/A" for time fields during polar day or polar night.
### Parameters
#### date
`Date`
#### latitude
`number`
#### longitude
`number`
#### timezone
`number` \| `null` \| `undefined`
#### options
[`SpaOptions`](../interfaces/SpaOptions.md) \| `null` \| `undefined`
#### angles
\[`number`, `...number[]`\]
### Returns
[`SpaFormattedResultWithAngles`](../interfaces/SpaFormattedResultWithAngles.md)
### Throws
If date, latitude, longitude, timezone, or options numeric fields are not finite numbers
### Throws
If latitude, longitude, timezone, function code, or angle values are out of range

View file

@ -0,0 +1,32 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / formatTime
# Function: formatTime()
> **formatTime**(`hours`): `string`
Defined in: [index.ts:87](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/index.ts#L87)
Format fractional hours to HH:MM:SS string.
Returns "N/A" for non-finite or negative values (polar night/day scenarios).
## Parameters
### hours
`number`
Fractional hours (e.g., 12.5 for 12:30:00)
## Returns
`string`
Formatted time string in HH:MM:SS format, or "N/A"
## See
[Wiki: formatTime](https://github.com/acamarata/nrel-spa/wiki/api/formatTime)

117
.github/wiki/api/functions/getSpa.md vendored Normal file
View file

@ -0,0 +1,117 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / getSpa
# Function: getSpa()
## Call Signature
> **getSpa**(`date`, `latitude`, `longitude`, `timezone?`, `options?`): [`SpaResult`](../interfaces/SpaResult.md)
Defined in: [index.ts:145](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/index.ts#L145)
Compute solar position for the given parameters.
### Parameters
#### date
`Date`
JavaScript Date object (uses UTC components)
#### latitude
`number`
Observer latitude in degrees (-90 to 90, negative = south)
#### longitude
`number`
Observer longitude in degrees (-180 to 180, negative = west)
#### timezone?
`number` \| `null`
Hours from UTC (e.g., -4 for EDT). Default: 0
#### options?
[`SpaOptions`](../interfaces/SpaOptions.md) \| `null`
Optional atmospheric and calculation parameters
### Returns
[`SpaResult`](../interfaces/SpaResult.md)
Solar position result with raw numerical values
### Throws
If date, latitude, longitude, timezone, or options numeric fields are not finite numbers
### Throws
If latitude, longitude, timezone, function code, or angle values are out of range
### See
[Wiki: getSpa](https://github.com/acamarata/nrel-spa/wiki/api/getSpa)
## Call Signature
> **getSpa**(`date`, `latitude`, `longitude`, `timezone`, `options`, `angles`): [`SpaResultWithAngles`](../interfaces/SpaResultWithAngles.md)
Defined in: [index.ts:163](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/index.ts#L163)
Compute solar position and resolve custom zenith angles (e.g., twilight).
### Parameters
#### date
`Date`
JavaScript Date object (uses UTC components)
#### latitude
`number`
Observer latitude in degrees (-90 to 90, negative = south)
#### longitude
`number`
Observer longitude in degrees (-180 to 180, negative = west)
#### timezone
`number` \| `null` \| `undefined`
Hours from UTC (e.g., -4 for EDT). Default: 0
#### options
[`SpaOptions`](../interfaces/SpaOptions.md) \| `null` \| `undefined`
Atmospheric and calculation parameters (pass null for defaults)
#### angles
\[`number`, `...number[]`\]
Custom zenith angles in degrees. Common: 96 civil, 102 nautical, 108 astronomical
### Returns
[`SpaResultWithAngles`](../interfaces/SpaResultWithAngles.md)
Solar position result including an angles array

View file

@ -0,0 +1,29 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaAnglesResult
# Interface: SpaAnglesResult
Defined in: [types.ts:78](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L78)
## Properties
### sunrise
> **sunrise**: `number`
Defined in: [types.ts:80](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L80)
Sunrise time for this custom zenith angle.
***
### sunset
> **sunset**: `number`
Defined in: [types.ts:82](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L82)
Sunset time for this custom zenith angle.

View file

@ -0,0 +1,29 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaFormattedAnglesResult
# Interface: SpaFormattedAnglesResult
Defined in: [types.ts:90](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L90)
## Properties
### sunrise
> **sunrise**: `string`
Defined in: [types.ts:92](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L92)
Sunrise time for this custom zenith angle, formatted as HH:MM:SS.
***
### sunset
> **sunset**: `string`
Defined in: [types.ts:94](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L94)
Sunset time for this custom zenith angle, formatted as HH:MM:SS.

View file

@ -0,0 +1,63 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaFormattedResult
# Interface: SpaFormattedResult
Defined in: [types.ts:65](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L65)
## Extended by
- [`SpaFormattedResultWithAngles`](SpaFormattedResultWithAngles.md)
## Properties
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:69](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L69)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
***
### solarNoon
> **solarNoon**: `string`
Defined in: [types.ts:73](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L73)
Local sun transit time as HH:MM:SS string. "N/A" during polar day/night.
***
### sunrise
> **sunrise**: `string`
Defined in: [types.ts:71](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L71)
Local sunrise time as HH:MM:SS string. "N/A" during polar day/night.
***
### sunset
> **sunset**: `string`
Defined in: [types.ts:75](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L75)
Local sunset time as HH:MM:SS string. "N/A" during polar day/night.
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:67](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L67)
Topocentric zenith angle in degrees.

View file

@ -0,0 +1,93 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaFormattedResultWithAngles
# Interface: SpaFormattedResultWithAngles
Defined in: [types.ts:97](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L97)
## Extends
- [`SpaFormattedResult`](SpaFormattedResult.md)
## Properties
### angles
> **angles**: [`SpaFormattedAnglesResult`](SpaFormattedAnglesResult.md)[]
Defined in: [types.ts:99](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L99)
Custom angle results with formatted times.
***
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:69](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L69)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
#### Inherited from
[`SpaFormattedResult`](SpaFormattedResult.md).[`azimuth`](SpaFormattedResult.md#azimuth)
***
### solarNoon
> **solarNoon**: `string`
Defined in: [types.ts:73](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L73)
Local sun transit time as HH:MM:SS string. "N/A" during polar day/night.
#### Inherited from
[`SpaFormattedResult`](SpaFormattedResult.md).[`solarNoon`](SpaFormattedResult.md#solarnoon)
***
### sunrise
> **sunrise**: `string`
Defined in: [types.ts:71](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L71)
Local sunrise time as HH:MM:SS string. "N/A" during polar day/night.
#### Inherited from
[`SpaFormattedResult`](SpaFormattedResult.md).[`sunrise`](SpaFormattedResult.md#sunrise)
***
### sunset
> **sunset**: `string`
Defined in: [types.ts:75](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L75)
Local sunset time as HH:MM:SS string. "N/A" during polar day/night.
#### Inherited from
[`SpaFormattedResult`](SpaFormattedResult.md).[`sunset`](SpaFormattedResult.md#sunset)
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:67](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L67)
Topocentric zenith angle in degrees.
#### Inherited from
[`SpaFormattedResult`](SpaFormattedResult.md).[`zenith`](SpaFormattedResult.md#zenith)

View file

@ -0,0 +1,99 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaOptions
# Interface: SpaOptions
Defined in: [types.ts:31](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L31)
## Properties
### atmos\_refract?
> `optional` **atmos\_refract?**: `number`
Defined in: [types.ts:47](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L47)
Atmospheric refraction at sunrise/sunset in degrees. Default: 0.5667.
***
### azm\_rotation?
> `optional` **azm\_rotation?**: `number`
Defined in: [types.ts:45](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L45)
Surface azimuth rotation in degrees from south. Default: 0.
***
### delta\_t?
> `optional` **delta\_t?**: `number`
Defined in: [types.ts:41](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L41)
TT-UTC difference in seconds. Default: 67.
***
### delta\_ut1?
> `optional` **delta\_ut1?**: `number`
Defined in: [types.ts:39](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L39)
UT1-UTC correction in seconds. Default: 0.
***
### elevation?
> `optional` **elevation?**: `number`
Defined in: [types.ts:33](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L33)
Observer elevation in meters above sea level. Default: 0.
***
### function?
> `optional` **function?**: [`SpaFunctionCode`](../type-aliases/SpaFunctionCode.md)
Defined in: [types.ts:49](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L49)
SPA function code. Default: SPA_ZA_RTS (2).
***
### pressure?
> `optional` **pressure?**: `number`
Defined in: [types.ts:35](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L35)
Atmospheric pressure in millibars. Default: 1013.
***
### slope?
> `optional` **slope?**: `number`
Defined in: [types.ts:43](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L43)
Surface slope in degrees from horizontal. Default: 0.
***
### temperature?
> `optional` **temperature?**: `number`
Defined in: [types.ts:37](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L37)
Temperature in degrees Celsius. Default: 15.

View file

@ -0,0 +1,63 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaResult
# Interface: SpaResult
Defined in: [types.ts:52](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L52)
## Extended by
- [`SpaResultWithAngles`](SpaResultWithAngles.md)
## Properties
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:56](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L56)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
***
### solarNoon
> **solarNoon**: `number`
Defined in: [types.ts:60](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L60)
Local sun transit time (solar noon) as fractional hours.
***
### sunrise
> **sunrise**: `number`
Defined in: [types.ts:58](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L58)
Local sunrise time as fractional hours.
***
### sunset
> **sunset**: `number`
Defined in: [types.ts:62](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L62)
Local sunset time as fractional hours.
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:54](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L54)
Topocentric zenith angle in degrees.

View file

@ -0,0 +1,93 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SpaResultWithAngles
# Interface: SpaResultWithAngles
Defined in: [types.ts:85](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L85)
## Extends
- [`SpaResult`](SpaResult.md)
## Properties
### angles
> **angles**: [`SpaAnglesResult`](SpaAnglesResult.md)[]
Defined in: [types.ts:87](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L87)
Custom angle results, one per angle in the input array.
***
### azimuth
> **azimuth**: `number`
Defined in: [types.ts:56](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L56)
Topocentric azimuth angle, eastward from north (navigational convention), in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`azimuth`](SpaResult.md#azimuth)
***
### solarNoon
> **solarNoon**: `number`
Defined in: [types.ts:60](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L60)
Local sun transit time (solar noon) as fractional hours.
#### Inherited from
[`SpaResult`](SpaResult.md).[`solarNoon`](SpaResult.md#solarnoon)
***
### sunrise
> **sunrise**: `number`
Defined in: [types.ts:58](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L58)
Local sunrise time as fractional hours.
#### Inherited from
[`SpaResult`](SpaResult.md).[`sunrise`](SpaResult.md#sunrise)
***
### sunset
> **sunset**: `number`
Defined in: [types.ts:62](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L62)
Local sunset time as fractional hours.
#### Inherited from
[`SpaResult`](SpaResult.md).[`sunset`](SpaResult.md#sunset)
***
### zenith
> **zenith**: `number`
Defined in: [types.ts:54](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L54)
Topocentric zenith angle in degrees.
#### Inherited from
[`SpaResult`](SpaResult.md).[`zenith`](SpaResult.md#zenith)

View file

@ -0,0 +1,11 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-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:25](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L25)

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

@ -0,0 +1,14 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SPA\_ALL
# Variable: SPA\_ALL
> `const` **SPA\_ALL**: `3`
Defined in: [types.ts:23](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L23)
Compute all outputs: zenith, azimuth, incidence angle, sunrise, sunset,
and sun transit. Combines SPA_ZA_INC and SPA_ZA_RTS.

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

@ -0,0 +1,14 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SPA\_ZA
# Variable: SPA\_ZA
> `const` **SPA\_ZA**: `0`
Defined in: [types.ts:5](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L5)
Compute topocentric zenith and azimuth angles only.
Does not compute sunrise, sunset, or solar noon.

View file

@ -0,0 +1,14 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SPA\_ZA\_INC
# Variable: SPA\_ZA\_INC
> `const` **SPA\_ZA\_INC**: `1`
Defined in: [types.ts:11](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L11)
Compute zenith, azimuth, and incidence angle for a tilted surface.
Requires slope and azm_rotation in SpaOptions.

View file

@ -0,0 +1,14 @@
[**nrel-spa v2.0.2**](../README.md)
***
[nrel-spa](../README.md) / SPA\_ZA\_RTS
# Variable: SPA\_ZA\_RTS
> `const` **SPA\_ZA\_RTS**: `2`
Defined in: [types.ts:17](https://github.com/acamarata/nrel-spa/blob/b52802f94b8c28a03228118f51c17ce21d4c14b3/src/types.ts#L17)
Compute sunrise, sunset, and sun transit (solar noon) in addition to
zenith and azimuth. This is the default function code.

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

@ -0,0 +1,70 @@
# Benchmarks
Performance and bundle size measurements for nrel-spa, with comparisons to solar-spa.
## Bundle Size
Measured from `dist/` after `pnpm build`.
| File | Raw | Gzipped |
|------------------|-------|---------|
| `index.mjs` (ESM) | 5.8 KB | 2.0 KB |
| `index.cjs` (CJS) | 6.5 KB | 2.2 KB |
The package also includes `lib/spa.cjs` (the core algorithm), which adds ~38 KB raw. The full `nrel-spa` npm pack total is under 100 KB uncompressed.
solar-spa bundles a compiled WASM binary. Total npm pack size is approximately 60 KB gzipped, compared to ~40 KB for nrel-spa.
## Throughput
**Environment:** Node.js v24.6.0, macOS. 200,000 iterations per measurement with a 2,000-iteration warm-up. Test case: New York summer solstice, `SPA_ZA_RTS` (full rise/set calculation).
| Implementation | ns/call | calls/s |
|----------------|---------|----------|
| nrel-spa | 84,497 | 11,835 |
| solar-spa | 45,139 | 22,154 |
**solar-spa WASM is 1.5-1.9x faster** for sustained throughput. The WASM binary is compiled from the same C source with `-O2`.
Switching to `SPA_ZA` (zenith and azimuth only, no rise/set) removes the three-day interpolation step:
| Implementation | Mode | ns/call | calls/s |
|----------------|---------------|---------|---------|
| nrel-spa | SPA_ZA_RTS | 84,497 | 11,835 |
| nrel-spa | SPA_ZA | 9,284 | 107,711 |
| solar-spa | SPA_ZA_RTS | 45,139 | 22,154 |
| solar-spa | SPA_ZA | 6,112 | 163,616 |
`SPA_ZA` is roughly 9x faster in nrel-spa. Use it when rise/set times are not needed.
## When Each Package Fits
| Scenario | Recommendation |
|---|---|
| Serverless, edge workers, middleware | nrel-spa (synchronous, zero init, smaller) |
| Single request in a Node server | nrel-spa (no async overhead) |
| Batch pre-computation (100K+ calls) | solar-spa (1.5-1.9x faster after init) |
| Custom twilight angles | nrel-spa (solar-spa does not support custom zenith angles) |
## Methodology
Throughput numbers above come from the manual benchmark in [`bin/`](https://github.com/acamarata/nrel-spa/tree/main/bin) and the accuracy analysis in [Implementation Comparison](../Implementation-Comparison). To reproduce:
```sh
pnpm build
node bin/bench.mjs # or see bin/README.md for full setup
```
Bundle sizes are reproducible with:
```sh
pnpm build
du -sh dist/index.mjs dist/index.cjs
gzip -c dist/index.mjs | wc -c # gzipped ESM
gzip -c dist/index.cjs | wc -c # gzipped CJS
```
## See Also
- [Implementation Comparison](../Implementation-Comparison) - accuracy table and full API convention analysis
- [Architecture](../Architecture) - module structure and algorithm overview

40
.github/wiki/examples/solar-position.md vendored Normal file
View file

@ -0,0 +1,40 @@
# Example: Solar Position Logger
Print solar position every 30 minutes throughout a day.
```js
import { calcSpa } from 'nrel-spa';
const LAT = 40.7128; // New York
const LON = -74.0060;
const TZ = -4; // EDT
const date = new Date('2025-06-21T00:00:00-04:00');
console.log('Time (local) Zenith Azimuth');
console.log('──────────── ──────── ────────');
for (let hour = 4; hour <= 21; hour++) {
for (const min of [0, 30]) {
const d = new Date(date);
d.setHours(hour, min, 0, 0);
const r = calcSpa(d, LAT, LON, TZ);
const timeStr = `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
const zenith = Number(r.zenith).toFixed(1).padStart(7);
const azimuth = Number(r.azimuth).toFixed(1).padStart(7);
console.log(`${timeStr} ${zenith}° ${azimuth}°`);
}
}
```
Sample output (abbreviated):
```
Time (local) Zenith Azimuth
──────────── ──────── ────────
05:30 89.3° 60.2°
06:00 83.1° 68.4°
12:00 24.6° 185.1°
13:00 26.1° 212.3°
20:00 83.8° 293.7°
```

33
.github/wiki/examples/twilight-times.md vendored Normal file
View file

@ -0,0 +1,33 @@
# Example: Twilight Times
Compute civil, nautical, and astronomical twilight for a location.
```js
import { getSpa, formatTime } from 'nrel-spa';
const LAT = 34.0522; // Los Angeles
const LON = -118.2437;
const TZ = -7; // PDT
const date = new Date('2025-09-15T12:00:00-07:00');
const r = getSpa(date, LAT, LON, TZ, null, [90.833, 96, 102, 108]);
const labels = ['Sunrise/Sunset', 'Civil', 'Nautical', 'Astronomical'];
for (let i = 0; i < r.angles.length; i++) {
const ca = r.angles[i];
const rise = formatTime(ca.sunrise);
const set = formatTime(ca.sunset);
console.log(`${labels[i].padEnd(16)} dawn: ${rise} dusk: ${set}`);
}
```
Sample output:
```
Sunrise/Sunset dawn: 06:30:42 dusk: 19:12:18
Civil dawn: 06:04:11 dusk: 19:38:49
Nautical dawn: 05:33:07 dusk: 20:09:53
Astronomical dawn: 05:01:19 dusk: 20:41:41
```

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

@ -0,0 +1,78 @@
# Advanced Usage
## Twilight calculations
Pass an array of custom zenith angles to compute additional rise/set events beyond the default solar disk (90.833°).
```js
import { getSpa } from 'nrel-spa';
const result = getSpa(date, lat, lon, tz, {}, [96, 102, 108]);
for (const ca of result.customAngles) {
const label = ca.angle === 96 ? 'Civil' : ca.angle === 102 ? 'Nautical' : 'Astronomical';
console.log(`${label} dawn: ${ca.sunrise.toFixed(4)} hours`);
console.log(`${label} dusk: ${ca.sunset.toFixed(4)} hours`);
}
```
Zenith reference:
- 90.833° — standard solar disk (default)
- 96° — civil twilight
- 102° — nautical twilight
- 108° — astronomical twilight
## Batch processing
`getSpa` is synchronous, making batch work straightforward without async coordination.
```js
import { getSpa } from 'nrel-spa';
const lat = 51.5074; // London
const lon = -0.1278;
const tz = 0;
let totalDaylight = 0;
for (let doy = 0; doy < 365; doy++) {
const d = new Date(Date.UTC(2025, 0, 1 + doy, 12, 0, 0));
const r = getSpa(d, lat, lon, tz);
if (isFinite(r.sunrise)) {
totalDaylight += r.sunset - r.sunrise;
}
}
console.log(`Annual daylight: ${totalDaylight.toFixed(0)} hours`);
```
## Polar scenarios
At high latitudes, `sunrise` and `sunset` are `NaN` when the sun does not cross the horizon. Check with `isFinite()`.
```js
const r = getSpa(new Date('2025-12-21T12:00:00Z'), 89, 0, 0);
console.log(isFinite(r.sunrise)); // false — polar night
```
`calcSpa` returns `"N/A"` strings in these cases.
## vs solar-spa
Both packages implement the same NREL SPA algorithm. Key differences:
| | nrel-spa | solar-spa |
|---|---|---|
| Runtime | Pure JS | WebAssembly |
| API | Synchronous | Async (Promise) |
| Custom zenith | Yes | No |
| Bundle size | ~38 KB | ~60 KB |
| Init latency | None | First call |
Use `nrel-spa` when you need synchronous calls or custom twilight angles. Use `solar-spa` when throughput for very large batches matters.
## Delta-T
The default `delta_t` is 67 seconds, accurate for dates near 2025. For historical dates or high-precision work, provide a value from the IERS or USNO tables.
```js
getSpa(new Date('1900-01-01T12:00:00Z'), lat, lon, tz, { delta_t: -2.72 });
```

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

@ -0,0 +1,77 @@
# Quick Start
Five minutes from install to solar position.
## Install
```sh
npm install nrel-spa
```
## Basic usage
```js
import { getSpa } from 'nrel-spa';
const result = getSpa(
new Date('2025-06-21T12:00:00Z'),
40.7128, // latitude
-74.0060, // longitude
-4, // UTC offset in hours (EDT)
);
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
console.log(result.solarNoon); // fractional hours, e.g. 13.00
```
`getSpa` is synchronous. There is no initialization step, no WASM loading, no async overhead.
## Formatted output
```js
import { calcSpa } from 'nrel-spa';
const result = calcSpa(
new Date('2025-06-21T12:00:00Z'),
40.7128,
-74.0060,
-4,
);
console.log(result.sunrise); // "05:25:12"
console.log(result.sunset); // "20:34:47"
console.log(result.solarNoon); // "12:59:58"
```
## Custom zenith angles (twilight)
```js
import { getSpa } from 'nrel-spa';
const civil = getSpa(date, lat, lon, tz, {}, [96]); // civil twilight
const nautical = getSpa(date, lat, lon, tz, {}, [102]); // nautical twilight
const astro = getSpa(date, lat, lon, tz, {}, [108]); // astronomical twilight
console.log(civil.customAngles[0].sunrise); // civil dawn
console.log(nautical.customAngles[0].sunrise); // nautical dawn
```
## Options
```js
const result = getSpa(date, lat, lon, tz, {
elevation: 100, // metres above sea level
pressure: 1013.25, // millibars
temperature: 15, // Celsius
delta_t: 67, // ΔT in seconds
});
```
## Next steps
- [API Reference](../API-Reference) — full function signatures and return types
- [Architecture](../Architecture) — module structure and algorithm notes
- [Advanced Guide](advanced) — twilight, batch calculations, polar scenarios

View file

@ -16,14 +16,14 @@ jobs:
node-version: [20, 22, 24] node-version: [20, 22, 24]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: pnpm cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
@ -42,14 +42,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run typecheck - run: pnpm run typecheck
@ -59,12 +59,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run lint - run: pnpm run lint
- run: pnpm run format:check - run: pnpm run format:check
@ -75,14 +75,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Enable corepack
run: corepack enable
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run build - run: pnpm run build
@ -94,3 +94,30 @@ jobs:
grep -qE "(^|[[:space:]])${f}([[:space:]]|$)" pack-output.txt || { echo "MISSING: $f"; exit 1; } grep -qE "(^|[[:space:]])${f}([[:space:]]|$)" pack-output.txt || { echo "MISSING: $f"; exit 1; }
done done
echo "All expected files present in package" echo "All expected files present in package"
coverage:
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
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- 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

@ -25,7 +25,15 @@ jobs:
- name: Sync wiki pages - name: Sync wiki pages
run: | run: |
# Copy root wiki pages
cp .github/wiki/*.md .wiki-remote/ cp .github/wiki/*.md .wiki-remote/
# Copy subdirectory pages (api/, benchmarks/, guides/, examples/)
for dir in api benchmarks guides examples; do
if [ -d ".github/wiki/$dir" ]; then
mkdir -p ".wiki-remote/$dir"
cp ".github/wiki/$dir"/*.md ".wiki-remote/$dir/" 2>/dev/null || true
fi
done
cd .wiki-remote cd .wiki-remote
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
coverage/
*.tgz *.tgz
*.log *.log
.DS_Store .DS_Store

View file

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

View file

@ -3,6 +3,7 @@
[![npm version](https://img.shields.io/npm/v/nrel-spa.svg)](https://www.npmjs.com/package/nrel-spa) [![npm version](https://img.shields.io/npm/v/nrel-spa.svg)](https://www.npmjs.com/package/nrel-spa)
[![CI](https://github.com/acamarata/nrel-spa/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/nrel-spa/actions/workflows/ci.yml) [![CI](https://github.com/acamarata/nrel-spa/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/nrel-spa/actions/workflows/ci.yml)
[![license](https://img.shields.io/npm/l/nrel-spa.svg)](./LICENSE) [![license](https://img.shields.io/npm/l/nrel-spa.svg)](./LICENSE)
[![wiki](https://img.shields.io/badge/docs-wiki-blue)](https://github.com/acamarata/nrel-spa/wiki)
Pure JavaScript implementation of the NREL Solar Position Algorithm (SPA). Computes solar zenith angle, azimuth, sunrise, sunset, and solar noon for any location and date. Zero dependencies, synchronous. Validated to produce identical results to the original NREL C reference implementation. Pure JavaScript implementation of the NREL Solar Position Algorithm (SPA). Computes solar zenith angle, azimuth, sunrise, sunset, and solar noon for any location and date. Zero dependencies, synchronous. Validated to produce identical results to the original NREL C reference implementation.
@ -65,3 +66,7 @@ The core algorithm is a JavaScript port of the NREL SPA by Ibrahim Reda and Afsh
## License ## License
MIT (TypeScript wrapper and build tooling). The core algorithm in `lib/spa.js` is a port of NREL's SPA C source, subject to its own terms. See [LICENSE](./LICENSE). MIT (TypeScript wrapper and build tooling). The core algorithm in `lib/spa.js` is a port of NREL's SPA C source, subject to its own terms. See [LICENSE](./LICENSE).
## Telemetry
This package supports optional, anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry). It is **off by default**. See [TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md) for what is collected and how to enable or disable it.

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,33 @@
import eslint from '@eslint/js'; import tsParser from '@typescript-eslint/parser';
import tseslint from 'typescript-eslint'; import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslintConfigPrettier from 'eslint-config-prettier'; import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default tseslint.config( export default [
eslint.configs.recommended, {
...tseslint.configs.recommended, files: ['**/*.ts', '**/*.tsx'],
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
},
},
},
...typescript.map((c) => ({
...c,
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
...(c.languageOptions ?? {}),
parser: tsParser,
parserOptions: {
...((c.languageOptions ?? {}).parserOptions ?? {}),
project: './tsconfig.json',
},
},
})),
eslintConfigPrettier, eslintConfigPrettier,
{ {
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'lib/'], ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'lib/'],
}, },
); ];

View file

@ -34,8 +34,10 @@
"lint": "eslint src/", "lint": "eslint src/",
"format": "prettier --write src/", "format": "prettier --write src/",
"format:check": "prettier --check src/", "format:check": "prettier --check src/",
"prepublishOnly": "tsup", "prepack": "pnpm run build",
"coverage": "c8 --reporter=lcov --reporter=text node --test" "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": [ "keywords": [
"solar", "solar",
@ -67,14 +69,24 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"devDependencies": { "devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/telemetry": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.3.0", "@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": "^10.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1" "typescript-eslint": "^8.56.1"
}, },
"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

@ -7,11 +7,11 @@ export type {
SpaResultWithAngles, SpaResultWithAngles,
SpaFormattedResultWithAngles, SpaFormattedResultWithAngles,
SpaFunctionCode, SpaFunctionCode,
} from './types.js'; } from "./types.js";
export { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './types.js'; export { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from "./types.js";
import { SPA_ZA_RTS } from './types.js'; import { SPA_ZA_RTS } from "./types.js";
import type { import type {
SpaOptions, SpaOptions,
SpaResult, SpaResult,
@ -19,7 +19,7 @@ import type {
SpaResultWithAngles, SpaResultWithAngles,
SpaFormattedResultWithAngles, SpaFormattedResultWithAngles,
SpaFormattedAnglesResult, SpaFormattedAnglesResult,
} from './types.js'; } from "./types.js";
/** Degrees-to-radians conversion factor. */ /** Degrees-to-radians conversion factor. */
const DEG = Math.PI / 180; const DEG = Math.PI / 180;
@ -28,8 +28,8 @@ const DEG = Math.PI / 180;
// In ESM builds, tsup injects a createRequire-based __require shim via the banner // 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. // option (see tsup.config.ts). In CJS builds, require() is natively available.
declare const __require: NodeRequire; declare const __require: NodeRequire;
const _load = typeof __require === 'function' ? __require : require; const _load = typeof __require === "function" ? __require : require;
const spa = _load('../lib/spa.cjs') as { const spa = _load("../lib/spa.cjs") as {
SpaData: new () => SpaDataInstance; SpaData: new () => SpaDataInstance;
SPA_ZA_RTS: number; SPA_ZA_RTS: number;
spa_calculate: (data: SpaDataInstance) => number; spa_calculate: (data: SpaDataInstance) => number;
@ -69,9 +69,9 @@ interface SpaDataInstance {
* @internal * @internal
*/ */
function assertFiniteNumber(value: unknown, name: string): asserts value is number { function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== 'number' || !isFinite(value)) { if (typeof value !== "number" || !isFinite(value)) {
throw new TypeError( throw new TypeError(
`SPA: ${name} must be a finite number, got ${typeof value === 'number' ? value : typeof value}`, `SPA: ${name} must be a finite number, got ${typeof value === "number" ? value : typeof value}`,
); );
} }
} }
@ -82,9 +82,10 @@ function assertFiniteNumber(value: unknown, name: string): asserts value is numb
* *
* @param hours - Fractional hours (e.g., 12.5 for 12:30:00) * @param hours - Fractional hours (e.g., 12.5 for 12:30:00)
* @returns Formatted time string in HH:MM:SS format, or "N/A" * @returns Formatted time string in HH:MM:SS format, or "N/A"
* @see {@link https://github.com/acamarata/nrel-spa/wiki/api/formatTime Wiki: formatTime}
*/ */
export function formatTime(hours: number): string { 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 totalSec = Math.round(hours * 3600);
// Wrap at 24h: values near midnight can round to 24:00:00 // Wrap at 24h: values near midnight can round to 24:00:00
@ -94,7 +95,7 @@ export function formatTime(hours: number): string {
const s = rem - m * 60; const s = rem - m * 60;
return ( 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")
); );
} }
@ -139,6 +140,7 @@ function adjustForCustomAngle(
* @returns Solar position result with raw numerical values * @returns Solar position result with raw numerical values
* @throws {TypeError} If date, latitude, longitude, timezone, or options numeric fields are not finite numbers * @throws {TypeError} If date, latitude, longitude, timezone, or options numeric fields are not finite numbers
* @throws {RangeError} If latitude, longitude, timezone, function code, or angle values are out of range * @throws {RangeError} If latitude, longitude, timezone, function code, or angle values are out of range
* @see {@link https://github.com/acamarata/nrel-spa/wiki/api/getSpa Wiki: getSpa}
*/ */
export function getSpa( export function getSpa(
date: Date, date: Date,
@ -175,10 +177,10 @@ export function getSpa(
angles?: number[], angles?: number[],
): SpaResult | SpaResultWithAngles { ): SpaResult | SpaResultWithAngles {
if (!(date instanceof Date) || isNaN(date.getTime())) { 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(latitude, "latitude");
assertFiniteNumber(longitude, 'longitude'); assertFiniteNumber(longitude, "longitude");
if (latitude < -90 || latitude > 90) { if (latitude < -90 || latitude > 90) {
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`); throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
@ -188,7 +190,7 @@ export function getSpa(
} }
const tz = timezone ?? 0; const tz = timezone ?? 0;
assertFiniteNumber(tz, 'timezone'); assertFiniteNumber(tz, "timezone");
if (tz < -18 || tz > 18) { if (tz < -18 || tz > 18) {
throw new RangeError(`SPA: timezone must be between -18 and 18, got ${tz}`); throw new RangeError(`SPA: timezone must be between -18 and 18, got ${tz}`);
} }
@ -196,13 +198,13 @@ export function getSpa(
const opts = options ?? {}; const opts = options ?? {};
const optNumericFields = [ const optNumericFields = [
'elevation', "elevation",
'pressure', "pressure",
'temperature', "temperature",
'delta_t', "delta_t",
'slope', "slope",
'azm_rotation', "azm_rotation",
'atmos_refract', "atmos_refract",
] as const; ] as const;
for (const field of optNumericFields) { for (const field of optNumericFields) {
if (opts[field] !== undefined) { if (opts[field] !== undefined) {
@ -221,9 +223,9 @@ export function getSpa(
if (angles && angles.length > 0) { if (angles && angles.length > 0) {
for (let i = 0; i < angles.length; i++) { for (let i = 0; i < angles.length; i++) {
const a = angles[i]; const a = angles[i];
if (typeof a !== 'number' || !isFinite(a)) { if (typeof a !== "number" || !isFinite(a)) {
throw new TypeError( throw new TypeError(
`SPA: angles[${i}] must be a finite number, got ${typeof a === 'number' ? a : typeof a}`, `SPA: angles[${i}] must be a finite number, got ${typeof a === "number" ? a : typeof a}`,
); );
} }
if (a < 0 || a > 180) { if (a < 0 || a > 180) {
@ -235,7 +237,7 @@ export function getSpa(
// Custom angle calculations depend on suntransit, which requires an RTS function code. // Custom angle calculations depend on suntransit, which requires an RTS function code.
if (angles && angles.length > 0 && fnCode !== 2 && fnCode !== 3) { if (angles && angles.length > 0 && fnCode !== 2 && fnCode !== 3) {
throw new RangeError( throw new RangeError(
'SPA: custom zenith angle calculations require an RTS function code (SPA_ZA_RTS or SPA_ALL)', "SPA: custom zenith angle calculations require an RTS function code (SPA_ZA_RTS or SPA_ALL)",
); );
} }
@ -292,8 +294,15 @@ export function getSpa(
* Same as getSpa(), but formats sunrise, solarNoon, and sunset as HH:MM:SS strings. * Same as getSpa(), but formats sunrise, solarNoon, and sunset as HH:MM:SS strings.
* Returns "N/A" for time fields during polar day or polar night. * Returns "N/A" for time fields during polar day or polar night.
* *
* @param date - JavaScript Date object (uses UTC components)
* @param latitude - Observer latitude in degrees (-90 to 90, negative = south)
* @param longitude - Observer longitude in degrees (-180 to 180, negative = west)
* @param timezone - Hours from UTC (e.g., -4 for EDT). Default: 0
* @param options - Optional atmospheric and calculation parameters
* @returns Formatted solar position result with HH:MM:SS time strings
* @throws {TypeError} If date, latitude, longitude, timezone, or options numeric fields are not finite numbers * @throws {TypeError} If date, latitude, longitude, timezone, or options numeric fields are not finite numbers
* @throws {RangeError} If latitude, longitude, timezone, function code, or angle values are out of range * @throws {RangeError} If latitude, longitude, timezone, function code, or angle values are out of range
* @see {@link https://github.com/acamarata/nrel-spa/wiki/api/calcSpa Wiki: calcSpa}
*/ */
export function calcSpa( export function calcSpa(
date: Date, date: Date,
@ -358,3 +367,12 @@ export function calcSpa(
sunset: formatTime(raw.sunset), sunset: formatTime(raw.sunset),
}; };
} }
// ── 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: "nrel-spa", version: "2.0.2" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

View file

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