v2.0.0: TypeScript rewrite with dual CJS/ESM build

Complete modernization of the package. The core SPA algorithm is
unchanged and validated; everything else is rebuilt to match current
JavaScript ecosystem standards.

Changes:
- TypeScript wrapper in src/ with full type definitions
- Dual CJS/ESM build via tsup (dist/index.cjs, dist/index.mjs)
- Core algorithm moved from dist/spa.js to lib/spa.js (same code)
- Input validation with descriptive TypeError/RangeError messages
- formatTime() and SPA function code constants as named exports
- getSpa() / calcSpa() accept null for optional args (tz, options)
- Test suite: 61 ESM assertions and 17 CJS assertions
- GitHub Actions CI: Node 20/22/24 matrix, typecheck, pack-check
- GitHub Wiki: Home, API Reference, Architecture, Twilight, NREL SPA
- NREL attribution in LICENSE and README per their license terms
- package.json: exports map, files, engines >=20, sideEffects: false
- Author corrected to Aric Camarata; repository.url uses git+https://
- LICENSE year corrected to 2023-2026
- Removed: index.js, test.js, dist/spa.js (superseded by above)
This commit is contained in:
Aric Camarata 2026-02-25 11:01:38 -05:00
parent b1c7f638ae
commit b44d9a958b
27 changed files with 3478 additions and 228 deletions

BIN
.DS_Store vendored

Binary file not shown.

14
.editorconfig Normal file
View file

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

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

@ -0,0 +1,78 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build TypeScript
run: pnpm run build
- name: Run tests (ESM)
run: node test.mjs
- name: Run tests (CJS)
run: node test-cjs.cjs
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
pack-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- 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 -q "$f" pack-output.txt || { echo "MISSING: $f"; exit 1; }
done
echo "All expected files present in package"

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

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

26
.gitignore vendored
View file

@ -1,8 +1,28 @@
node_modules/
.env
dist/
*.tgz
*.log
.DS_Store
# Ignore NREL SPA C sources and binaries
# Environment
.env
.env.*
!.env.example
# AI Agents
.claude/
.cursor/
.copilot/
.github/copilot/
.aider*
.codeium/
.tabnine/
.windsurf/
.cody/
.sourcegraph/
# NREL SPA C sources and binaries (download separately for reference testing)
/bin/spa
/bin/spa_cli
/bin/*.c
/bin/*.h
/bin/*.h

1
.npmrc Normal file
View file

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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

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

@ -0,0 +1,131 @@
# API Reference
## Functions
### `getSpa(date, latitude, longitude, timezone?, options?, angles?)`
Computes solar position for the given date and location. Returns raw numerical values.
**Parameters:**
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `date` | `Date` | Yes | UTC date and time. Uses UTC components internally. |
| `latitude` | `number` | Yes | Observer latitude, -90 to 90. Negative = south. |
| `longitude` | `number` | Yes | Observer longitude, -180 to 180. Negative = west. |
| `timezone` | `number \| null` | No | Hours from UTC (e.g., -4 for EDT). Default: `0`. |
| `options` | `SpaOptions \| null` | No | Atmospheric and calculation parameters. |
| `angles` | `number[]` | No | Custom zenith angles in degrees. See [Twilight Calculations](Twilight-Calculations). |
**Returns:** `SpaResult`
```typescript
interface SpaResult {
zenith: number; // topocentric zenith angle (degrees)
azimuth: number; // topocentric azimuth, eastward from north (degrees)
sunrise: number; // local sunrise (fractional hours, e.g. 5.417 = 05:25)
solarNoon: number; // local solar noon (fractional hours)
sunset: number; // local sunset (fractional hours)
}
```
When `angles` is provided, returns `SpaResultWithAngles`:
```typescript
interface SpaResultWithAngles extends SpaResult {
angles: Array<{ sunrise: number; sunset: number }>;
}
```
**Throws:**
- `TypeError` if `date` is not a valid Date, or `latitude`/`longitude` are not finite numbers
- `RangeError` if `latitude` is outside [-90, 90] or `longitude` outside [-180, 180]
- `Error` if the internal SPA calculation returns a non-zero error code
---
### `calcSpa(date, latitude, longitude, timezone?, options?, angles?)`
Same parameters as `getSpa()`. Formats `sunrise`, `solarNoon`, and `sunset` as `HH:MM:SS` strings.
**Returns:** `SpaFormattedResult`
```typescript
interface SpaFormattedResult {
zenith: number; // same as SpaResult
azimuth: number; // same as SpaResult
sunrise: string; // "HH:MM:SS" or "N/A" during polar day/night
solarNoon: string; // "HH:MM:SS" or "N/A"
sunset: string; // "HH:MM:SS" or "N/A"
}
```
When `angles` is provided, returns `SpaFormattedResultWithAngles`:
```typescript
interface SpaFormattedResultWithAngles extends SpaFormattedResult {
angles: Array<{ sunrise: string; sunset: string }>;
}
```
---
### `formatTime(hours)`
Converts a fractional hour value to `HH:MM:SS` format.
```javascript
formatTime(5.417489) // "05:25:03"
formatTime(12) // "12:00:00"
formatTime(23.9997) // "23:59:59"
formatTime(-1) // "N/A"
formatTime(NaN) // "N/A"
formatTime(Infinity) // "N/A"
```
---
## SpaOptions
All options are optional. Defaults match the NREL C reference implementation.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `elevation` | `number` | `0` | Observer elevation above sea level in meters. |
| `pressure` | `number` | `1013` | Annual average atmospheric pressure in millibars. |
| `temperature` | `number` | `15` | Annual average temperature in degrees Celsius. |
| `delta_ut1` | `number` | `0` | Fractional second difference between UTC and UT1. Valid range: (-1, 1). |
| `delta_t` | `number` | `67` | Difference between Terrestrial Time and UTC in seconds. |
| `slope` | `number` | `0` | Surface slope from horizontal in degrees. Used for incidence angle (SPA_ZA_INC, SPA_ALL). |
| `azm_rotation` | `number` | `0` | Surface azimuth rotation from south in degrees. |
| `atmos_refract` | `number` | `0.5667` | Atmospheric refraction at sunrise/sunset in degrees. |
| `function` | `SpaFunctionCode` | `SPA_ZA_RTS` | Which outputs to compute. |
---
## Function Codes
```javascript
import { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from 'nrel-spa';
```
| Constant | Value | Outputs Computed |
| --- | --- | --- |
| `SPA_ZA` | `0` | `zenith`, `azimuth` |
| `SPA_ZA_INC` | `1` | `zenith`, `azimuth`, incidence angle (for solar panels on sloped surfaces) |
| `SPA_ZA_RTS` | `2` | `zenith`, `azimuth`, `sunrise`, `solarNoon`, `sunset` |
| `SPA_ALL` | `3` | All outputs from SPA_ZA_INC and SPA_ZA_RTS combined |
The default is `SPA_ZA_RTS`. Use `SPA_ZA` for zenith/azimuth-only calculations if you do not need rise/set times; it skips the three-day calculation that rise/set requires and is slightly faster.
---
## SpaFunctionCode Type
```typescript
type SpaFunctionCode = 0 | 1 | 2 | 3;
```
---
[Home](Home) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

83
.wiki/Architecture.md Normal file
View file

@ -0,0 +1,83 @@
# Architecture
## Package Structure
```
nrel-spa/
├── src/
│ ├── index.ts <- Public API: getSpa, calcSpa, formatTime
│ └── types.ts <- TypeScript interfaces and SPA function code constants
├── lib/
│ └── spa.js <- Core SPA algorithm (JS port of NREL C source, tracked in git)
├── dist/ <- Built output (generated by tsup, gitignored)
│ ├── index.cjs <- CommonJS build
│ ├── index.mjs <- ESM build
│ ├── index.d.ts <- CJS type declarations
│ └── index.d.mts <- ESM type declarations
├── test.mjs <- ESM test suite (61 assertions)
└── test-cjs.cjs <- CJS test suite (17 assertions)
```
## Layers
**lib/spa.js** is the core mathematical engine. It is a direct JavaScript port of the NREL C source (`spa.c`), preserving function names, variable names, and the algorithm structure. The file is checked into git and shipped in the npm package.
**src/index.ts** is a thin TypeScript wrapper. It:
- Loads `lib/spa.js` at runtime
- Validates input parameters, throwing `TypeError` or `RangeError` for invalid values
- Maps the flat SpaData structure to a clean output object
- Implements `adjustForCustomAngle()` for twilight calculations
- Provides `formatTime()` as a standalone export
**tsup** compiles `src/index.ts` to both CJS (`dist/index.cjs`) and ESM (`dist/index.mjs`) with TypeScript declarations. The `lib/spa.js` module is kept external (not bundled) and resolved at runtime via a `createRequire` shim in the ESM build.
## Loading Strategy
The ESM build uses a `createRequire` banner injected by tsup:
```javascript
import { createRequire as __cr } from 'node:module';
const __require = __cr(import.meta.url);
```
This anchors `require()` resolution to the file's own location rather than the calling context, so `../lib/spa.js` resolves correctly regardless of where the caller imports the package.
In the CJS build, standard `require()` is used directly.
## Why lib/spa.js Is External
Unlike a typical npm package where all source is compiled away, `lib/spa.js` ships as a separate file rather than being inlined into `dist/`. This is an intentional choice:
1. The algorithm is the canonical reference. Keeping it as a readable JS file allows inspection without source maps.
2. The file is large (989 lines). Bundling it into both CJS and ESM outputs would double its footprint for no functional gain.
3. It matches the structure of [solar-spa](https://www.npmjs.com/package/solar-spa), which keeps its Emscripten WASM module in a separate `wasm/` directory.
## Date Handling
The wrapper uses UTC components from the `Date` object (`getUTCFullYear()`, etc.) and accepts an explicit `timezone` offset. This avoids the ambiguity of `getFullYear()` which returns local time and can produce wrong results when the caller's timezone differs from the target location.
```javascript
// Always pass UTC date + explicit timezone
const date = new Date('2025-06-21T00:00:00Z');
getSpa(date, lat, lng, -4); // -4 for EDT
```
## Polar Conditions
When the sun does not rise or set (polar day/polar night), `lib/spa.js` sets sunrise, sunset, and suntransit to `-99999`. The wrapper propagates these raw values from `getSpa()`. `calcSpa()` passes them through `formatTime()`, which returns `"N/A"` for negative values.
Custom angle calculations (`adjustForCustomAngle`) return `NaN` sunrise/sunset when `cosH0 < -1 || cosH0 > 1`, which indicates no crossing of that zenith angle.
## Validation
Input validation runs before any SPA calculation:
- `date` must be a valid `Date` (not `Invalid Date`)
- `latitude` and `longitude` must be finite numbers in their valid ranges
- Invalid values throw before the SPA data structure is populated
The internal `spa_calculate()` function also validates its inputs and returns non-zero error codes for out-of-range values. The wrapper throws `Error` on any non-zero return.
---
[Home](Home) . [API Reference](API-Reference) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

44
.wiki/Home.md Normal file
View file

@ -0,0 +1,44 @@
# nrel-spa
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. Validated to produce identical output to the original NREL C reference implementation.
## Overview
**Package:** [nrel-spa on npm](https://www.npmjs.com/package/nrel-spa)
**Repository:** [acamarata/nrel-spa on GitHub](https://github.com/acamarata/nrel-spa)
**License:** MIT (wrapper). NREL SPA C source: see LICENSE for third-party notice.
## Pages
- [API Reference](API-Reference) - Full function signatures, parameters, return types
- [Architecture](Architecture) - How the algorithm is structured and validated
- [Twilight Calculations](Twilight-Calculations) - Custom zenith angles for civil, nautical, astronomical twilight
- [NREL SPA Algorithm](NREL-SPA-Algorithm) - The algorithm background, accuracy, and reference
## Quick Example
```javascript
import { calcSpa } from 'nrel-spa';
const result = calcSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, // New York latitude
-74.006, // New York longitude
-4, // EDT (UTC-4)
);
console.log(result.sunrise); // "05:25:03"
console.log(result.solarNoon); // "12:57:56"
console.log(result.sunset); // "20:30:35"
```
## Key Facts
- Zero runtime dependencies
- Synchronous: no async, no WASM, no loading delay
- Dual CJS and ESM, full TypeScript definitions
- Matches NREL C reference output within one second across all tested locations
---
[API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

View file

@ -0,0 +1,76 @@
# NREL SPA Algorithm
## Background
The Solar Position Algorithm (SPA) was developed by Ibrahim Reda and Afshin Andreas at the National Renewable Energy Laboratory (NREL) and published in 2004. It is the reference algorithm for solar position calculation in scientific and engineering applications.
**Citation:**
> Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." Solar Energy, 76(5), 577-589. https://doi.org/10.1016/j.solener.2003.12.003
**Original C source:** https://midcdmz.nrel.gov/spa/
The C source has not changed since its last release in September 2014. This JavaScript port is validated against that version.
## Accuracy
The algorithm achieves uncertainty of +/- 0.0003 degrees in solar zenith and azimuth angles. For sunrise, solar noon, and sunset, results match the C reference to within one second across all tested locations and dates.
Validation test cases from `bin/test.js`:
| Location | Date | Sunrise (C) | Sunrise (JS) | Noon (C) | Noon (JS) | Sunset (C) | Sunset (JS) |
| --- | --- | --- | --- | --- | --- | --- | --- |
| New York | 2025-06-21 | 05:25:03 | 05:25:03 | 12:57:56 | 12:57:56 | 20:30:35 | 20:30:35 |
| New York | 2025-12-21 | 07:16:41 | 07:16:41 | 11:54:19 | 11:54:19 | 16:31:56 | 16:31:56 |
| London | 2025-06-21 | 04:43:07 | 04:43:07 | 13:02:22 | 13:02:22 | 21:21:37 | 21:21:37 |
| London | 2025-12-21 | 08:03:52 | 08:03:52 | 11:58:42 | 11:58:42 | 15:53:32 | 15:53:32 |
| Tokyo | 2025-06-21 | 04:25:52 | 04:25:52 | 11:43:00 | 11:43:00 | 19:00:22 | 19:00:22 |
| Sydney | 2025-06-21 | 07:00:12 | 07:00:12 | 11:56:56 | 11:56:56 | 16:53:52 | 16:53:52 |
| Reykjavik | 2025-06-21 | 02:55:10 | 02:55:10 | 13:29:38 | 13:29:38 | 00:03:54 | 00:03:54 |
| Cape Town | 2025-12-21 | 05:31:55 | 05:31:55 | 12:44:28 | 12:44:28 | 19:57:01 | 19:57:01 |
| Quito | 2025-03-20 | 06:17:54 | 06:17:54 | 12:21:10 | 12:21:10 | 18:24:25 | 18:24:25 |
| Tromso | 2025-12-21 | N/A | N/A | N/A | N/A | N/A | N/A |
Zero drift in all cases with sun (Tromso is polar night in December).
## Algorithm Outline
The SPA computes solar position through a chain of coordinate transformations:
1. **Julian Day (JD)** from the input date and time, accounting for the timezone offset and DUT1 correction.
2. **Earth Heliocentric Coordinates** using Variations Seculaires des Orbites Planetaires (VSOP87) truncated to 63 periodic terms for longitude (L), 5 terms for latitude (B), and 40 terms for radius (R).
3. **Geocentric Coordinates** by converting heliocentric L to geocentric longitude (theta) and negating the latitude (beta = -B).
4. **Nutation** in longitude (del_psi) and obliquity (del_epsilon) from 63 periodic terms in the IAU 1980 nutation model.
5. **Apparent Sun Longitude** by adding aberration correction and nutation to theta.
6. **Greenwich Sidereal Time** and then the **Observer Hour Angle** (H).
7. **Topocentric Correction** using the observer's elevation, converting geocentric right ascension and declination to topocentric values (alpha_prime, delta_prime, h_prime).
8. **Zenith and Azimuth** from topocentric elevation angle corrected for atmospheric refraction.
9. **Rise/Transit/Set** via a three-day calculation: the algorithm solves for sunrise, solar noon, and sunset by interpolating right ascension and declination across the previous, current, and next day, then iterating to correct the approximate times.
## Key Parameters
**delta_t** (TT-UTC, default 67 seconds): the accumulated difference between Terrestrial Time (an ideal clock) and UTC (subject to leap seconds). The NREL bulletin value for 2025 is approximately 68-70 seconds. The default of 67 is suitable for dates within a few years of 2020.
**delta_ut1** (UT1-UTC, default 0): a sub-second correction published by the IERS. For most applications, the default of 0 is acceptable.
**atmos_refract** (default 0.5667 degrees): atmospheric refraction at the horizon. This shifts the apparent sunrise earlier and sunset later than geometric calculations. The NREL default matches standard atmospheric conditions.
## Comparison with solar-spa
[solar-spa](https://www.npmjs.com/package/solar-spa) compiles the same NREL C source to WebAssembly via Emscripten. The two packages share the same algorithm and produce the same results. The practical difference:
- **nrel-spa** is synchronous, has no loading delay, and is simpler to use in most contexts
- **solar-spa** is asynchronous (WASM initialization), but can achieve higher throughput for batch calculations (219,000 calls/sec for zenith-only vs. nrel-spa's ~100,000/sec)
For single-call or per-request use cases, nrel-spa is the better choice. For batch pre-computation of thousands of time steps, solar-spa's WASM throughput becomes relevant.
---
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations)

View file

@ -0,0 +1,89 @@
# Twilight Calculations
Standard sunrise and sunset use a zenith angle of approximately 90.833 degrees (90 degrees plus atmospheric refraction and solar disc radius). Twilight is defined by the sun's position below the horizon, expressed as the zenith angle it occupies.
## Standard Twilight Definitions
| Type | Zenith Angle | Description |
| --- | --- | --- |
| Sunrise / Sunset | ~90.833 | Center of sun at horizon (accounting for refraction) |
| Civil twilight | 96 | Sufficient light for outdoor activities without artificial light |
| Nautical twilight | 102 | Horizon visible; used for celestial navigation |
| Astronomical twilight | 108 | Sky fully dark enough for astronomical observation |
## Usage
Pass an array of zenith angles as the sixth argument to `getSpa()` or `calcSpa()`. The function returns an `angles` array, one entry per input angle, each with `sunrise` and `sunset` for that zenith threshold.
```javascript
import { calcSpa } from 'nrel-spa';
const result = calcSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, // New York
-74.006,
-4, // EDT
{}, // default options
[96, 102, 108], // civil, nautical, astronomical
);
console.log(result.sunrise); // "05:25:03" (standard)
console.log(result.angles[0].sunrise); // civil twilight begin
console.log(result.angles[1].sunrise); // nautical twilight begin
console.log(result.angles[2].sunrise); // astronomical twilight begin
```
Expected output for New York, June 21, 2025:
| Event | Time |
| --- | --- |
| Astronomical twilight begin | ~03:57 |
| Nautical twilight begin | ~04:28 |
| Civil twilight begin | ~04:53 |
| Sunrise | ~05:25 |
| Sunset | ~20:30 |
| Civil twilight end | ~21:02 |
| Nautical twilight end | ~21:27 |
| Astronomical twilight end | ~21:58 |
## How It Works
The base `getSpa()` calculation computes the sun's declination (`delta`) and solar noon (`suntransit`) for the given date and location. For each custom zenith angle `Z`, the function solves the hour angle equation:
```
cos(H0) = (cos(Z) - sin(lat) * sin(delta)) / (cos(lat) * cos(delta))
```
where `H0` is the hour angle at the zenith crossing. The rise and set times follow from:
```
sunrise = suntransit - H0 / 15 (H0 in degrees, result in hours)
sunset = suntransit + H0 / 15
```
If `|cos(H0)| > 1`, the sun never crosses that zenith angle at the given latitude and date. The function returns `NaN` for sunrise and sunset in that case.
## Polar Cases
At high latitudes during summer (midnight sun), the sun may not set even at the standard horizon. It certainly will not cross deeper zenith angles like 96 or 102 degrees. Check for `NaN` or `isFinite()` on the result:
```javascript
import { getSpa } from 'nrel-spa';
const r = getSpa(
new Date('2025-06-21T00:00:00Z'),
71, 25, 2, {}, [96],
);
if (r.angles && !isFinite(r.angles[0].sunrise)) {
console.log('No civil twilight at this latitude/date');
}
```
## Islamic Prayer Application
This twilight mechanism is used by [pray-calc](https://www.npmjs.com/package/pray-calc) to compute Fajr and Isha prayer times, which are defined by the sun's position below the horizon at specific angles (typically 15-18 degrees below, equivalent to zenith angles of 105-108 degrees).
---
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

View file

@ -1,23 +1,47 @@
# Changelog
All notable changes to this project will be documented in this file.
## [2.0.0] - 2026-02-25
### Added
- TypeScript wrapper (`src/index.ts`, `src/types.ts`) with full type definitions
- Dual CJS and ESM builds via tsup (`dist/index.cjs`, `dist/index.mjs`)
- TypeScript declaration files (`dist/index.d.ts`, `dist/index.d.mts`)
- `formatTime()` utility export for converting fractional hours to `HH:MM:SS`
- Input validation with descriptive `TypeError` and `RangeError` messages
- Function code exports: `SPA_ZA`, `SPA_ZA_INC`, `SPA_ZA_RTS`, `SPA_ALL`
- Test suite: 61 ESM assertions and 17 CJS assertions
- GitHub Actions CI workflow (Node 20/22/24 matrix, typecheck, pack-check)
- GitHub Wiki with architecture, API reference, twilight calculations, and algorithm documentation
- NREL attribution in `LICENSE` and `README`
- `pnpm-workspace.yaml`, `.editorconfig`, `.npmrc`, `.nvmrc` config files
### Changed
- Core algorithm moved from `dist/spa.js` to `lib/spa.js` (same code, clearer location)
- `package.json` rewritten: proper `exports` map, `files`, `engines`, `sideEffects`, all required fields
- Author corrected to "Aric Camarata"
- `repository.url` corrected to use `git+https://` prefix (no npm publish warnings)
- `engines.node` set to `>=20`
- Description expanded with full keyword coverage
- LICENSE year corrected to `2023-2026`
- README rewritten with badges, full API tables, quick start, and NREL acknowledgments
## [1.3.0] - 2025-05-04
- Major fix for discrepancies between this implementation and the original NREL C reference
- Added `bin/` folder for compiling and testing against the C reference executable
- All 10 global test cases now produce identical output to the C reference
## [1.2.2] - 2023-11-12
- Moved timezone to main function arguments and changed default behavior
- Updated test cases and README
## [1.1.0] - 2023-11-11
- Committed `dist/` folder (core algorithm) to git
## [1.0.0] - 2023-11-11
- Initial release
## [1.1.0] - 2023-11-11
- Unignored "dist" folder in git (major)
## [1.2.2] - 2023-11-12
- Moved timezone to main args and changed default behavior (major)
- Updated test cases and readme to reflect new usage (minor)
## [1.3.0] - 2025-05-04
- Major update to fix discrepancies between original C and this implementation
- Folder "bin" added to compile and test against original C version
- This NPM now gives the exact same results as the original NREL-SPA

46
LICENSE
View file

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

190
README.md
View file

@ -1,6 +1,10 @@
# nrel-spa
NREL SPA (Solar Position Algorithm) native implementation in JavaScript. This package allows for precise calculations of solar positions and phases based on geographical coordinates and time.
[![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)
[![license](https://img.shields.io/npm/l/nrel-spa.svg)](./LICENSE)
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. Validated to produce identical results to the original NREL C reference implementation.
## Installation
@ -8,57 +12,165 @@ NREL SPA (Solar Position Algorithm) native implementation in JavaScript. This pa
npm install nrel-spa
```
## Usage
Basic usage examples:
## Quick Start
```javascript
const { getSpa, calcSpa } = require('nrel-spa');
import { getSpa, calcSpa } from 'nrel-spa';
const date = new Date();
console.log(date)
const date = new Date('2025-06-21T00:00:00Z'); // UTC date/time
// NYC - minimum params
const city = "New York"
const lat = 40.7128;
const lng = -74.006;
const tz = null // optional
const params = null // optional
const angles = [] // optional
// Minimum required parameters
const raw = getSpa(date, 40.7128, -74.006, -4); // New York, EDT (UTC-4)
console.log(raw.sunrise); // 5.417 (fractional hours)
console.log(raw.solarNoon); // 12.965
console.log(raw.sunset); // 20.510
/* Jakarta - all params
const city = "Jakarta"
const lat = -6.2088
const lng = 106.8456
const tz = 0
const elevation = 18
const temperature = 26.56
const pressure = 1017
const params = {elevation, temperature, pressure}
const angles = [63.435] */
// Formatted output — same parameters, HH:MM:SS strings
const fmt = calcSpa(date, 40.7128, -74.006, -4);
console.log(fmt.sunrise); // "05:25:03"
console.log(fmt.solarNoon); // "12:57:56"
console.log(fmt.sunset); // "20:30:35"
// Get results
const get = getSpa(date, lat, lng); // minimum args
const calc = calcSpa(date, lat, lng, tz, params, angles);
// Print results
console.log(`\nTest: ${city} with current Date():\n`)
console.log("getSpa =", get, "\n");
console.log("calcSpa =", calc, "\n");
// With atmospheric parameters and custom zenith angles (twilight)
const result = calcSpa(
date,
40.7128, // latitude (degrees, negative = south)
-74.006, // longitude (degrees, negative = west)
-4, // timezone offset in hours from UTC
{
elevation: 10, // meters above sea level
pressure: 1013, // millibars
temperature: 20 // degrees Celsius
},
[96, 102, 108], // civil, nautical, astronomical twilight zenith angles
);
console.log(result.sunrise); // "05:25:03"
console.log(result.angles[0]); // { sunrise: "04:53:...", sunset: "20:02:..." }
```
## API
Exporting getSpa, calcSpa, and fractalTime. Only date, lat, lng are required but if timezone (tz) is null, will return in UTC.
### `getSpa(date, latitude, longitude, timezone?, options?, angles?)`
- getSpa(date, lat, lng, tz, params, angles) // returns SPA results in fractal times
- getSpa(date, lat, lng, tz, params, angles) // returns SPA results in formatted times
- fractalTime(fractionalHour) // formats time from 10.767407706732804 to 10:46:02.667
Returns raw numerical values. Sunrise, solarNoon, and sunset are fractional hours (e.g., `5.417` for 05:25).
## Contributing
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `date` | `Date` | Yes | UTC date and time for the calculation |
| `latitude` | `number` | Yes | Observer latitude in degrees (-90 to 90) |
| `longitude` | `number` | Yes | Observer longitude in degrees (-180 to 180) |
| `timezone` | `number \| null` | No | Hours from UTC. Default: `0` |
| `options` | `SpaOptions \| null` | No | Atmospheric and calculation parameters |
| `angles` | `number[]` | No | Custom zenith angles in degrees for twilight |
Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md).
**Returns:** `SpaResult` (or `SpaResultWithAngles` when `angles` is provided)
```typescript
interface SpaResult {
zenith: number; // topocentric zenith angle (degrees)
azimuth: number; // topocentric azimuth, eastward from north (degrees)
sunrise: number; // local sunrise time (fractional hours)
solarNoon: number; // local solar noon (fractional hours)
sunset: number; // local sunset time (fractional hours)
}
```
### `calcSpa(date, latitude, longitude, timezone?, options?, angles?)`
Same as `getSpa()`, but formats sunrise, solarNoon, and sunset as `HH:MM:SS` strings. Returns `"N/A"` for those fields during polar day or polar night.
### `formatTime(hours)`
Converts fractional hours to `HH:MM:SS` format. Returns `"N/A"` for negative or non-finite values.
```javascript
import { formatTime } from 'nrel-spa';
formatTime(5.417489); // "05:25:03"
formatTime(-1); // "N/A"
```
### `SpaOptions`
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `elevation` | `number` | `0` | Observer elevation in meters |
| `pressure` | `number` | `1013` | Atmospheric pressure in millibars |
| `temperature` | `number` | `15` | Temperature in degrees Celsius |
| `delta_ut1` | `number` | `0` | UT1-UTC correction in seconds |
| `delta_t` | `number` | `67` | TT-UTC difference in seconds |
| `slope` | `number` | `0` | Surface slope from horizontal (degrees) |
| `azm_rotation` | `number` | `0` | Surface azimuth rotation from south (degrees) |
| `atmos_refract` | `number` | `0.5667` | Atmospheric refraction at sunrise/sunset (degrees) |
| `function` | `SpaFunctionCode` | `SPA_ZA_RTS` | Which outputs to compute |
### Function Codes
```javascript
import { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from 'nrel-spa';
```
| Code | Value | Computes |
| --- | --- | --- |
| `SPA_ZA` | `0` | Zenith and azimuth only |
| `SPA_ZA_INC` | `1` | Zenith, azimuth, and incidence angle |
| `SPA_ZA_RTS` | `2` | Zenith, azimuth, sunrise, noon, sunset (default) |
| `SPA_ALL` | `3` | All outputs |
## Architecture
The core algorithm in `lib/spa.js` is a direct port of the NREL SPA C source to JavaScript, preserving the same mathematical structure: 63 periodic nutation terms, full heliocentric coordinate calculation, topocentric correction, and atmospheric refraction. It has been validated to produce output identical to the C reference within rounding.
The TypeScript wrapper in `src/` provides the public API, input validation, and the `formatTime` utility. The package ships dual CJS and ESM builds via tsup, with full TypeScript definitions.
See the [wiki](https://github.com/acamarata/nrel-spa/wiki) for a detailed breakdown of the algorithm and architecture.
## Compatibility
- **Node.js:** 20, 22, 24 (CI tested)
- **ESM:** `import { getSpa } from 'nrel-spa'`
- **CommonJS:** `const { getSpa } = require('nrel-spa')`
- **TypeScript:** Full type definitions included
Bundlers (Vite, Webpack, esbuild, Rollup) work via the `exports` map in package.json.
## TypeScript
```typescript
import {
getSpa,
calcSpa,
formatTime,
SPA_ZA_RTS,
type SpaResult,
type SpaFormattedResult,
type SpaOptions,
} from 'nrel-spa';
```
## Documentation
Full documentation is available on the [GitHub Wiki](https://github.com/acamarata/nrel-spa/wiki):
- [API Reference](https://github.com/acamarata/nrel-spa/wiki/API-Reference)
- [Architecture](https://github.com/acamarata/nrel-spa/wiki/Architecture)
- [Twilight Calculations](https://github.com/acamarata/nrel-spa/wiki/Twilight-Calculations)
- [NREL SPA Algorithm](https://github.com/acamarata/nrel-spa/wiki/NREL-SPA-Algorithm)
## Related
Other packages in this collection:
- [solar-spa](https://www.npmjs.com/package/solar-spa): WASM build of the same algorithm, async, for high-throughput batch calculations
- [pray-calc](https://www.npmjs.com/package/pray-calc): Islamic prayer times built on nrel-spa
## Acknowledgments
The core algorithm is a JavaScript port of the Solar Position Algorithm (SPA) developed by Ibrahim Reda and Afshin Andreas at the National Renewable Energy Laboratory (NREL):
> Reda, I., Andreas, A. (2004). "Solar Position Algorithm for Solar Radiation Applications." Solar Energy, 76(5), 577-589. [https://doi.org/10.1016/j.solener.2003.12.003](https://doi.org/10.1016/j.solener.2003.12.003)
Original source: [https://midcdmz.nrel.gov/spa/](https://midcdmz.nrel.gov/spa/)
## License
This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details.
MIT (TypeScript wrapper and build tooling). The core algorithm in `lib/spa.js` is a port of NREL's SPA C source, which is subject to its own terms. See the [LICENSE](./LICENSE) file for the full notice.

118
index.js
View file

@ -1,118 +0,0 @@
// index.js
const spa = require('./dist/spa');
/**
* Convert fractional hours to HH:MM:SS.mmm (rounding total seconds)
*/
function fractalTime(fractionalHour) {
const totalSec = Math.round(fractionalHour * 3600);
const H = Math.floor(totalSec / 3600);
const rem = totalSec - H * 3600;
const M = Math.floor(rem / 60);
const S = rem - M * 60;
const ms = Math.round((fractionalHour * 3600 - Math.floor(fractionalHour * 3600)) * 1000);
return `${H.toString().padStart(2,'0')}:` +
`${M.toString().padStart(2,'0')}:` +
`${S.toString().padStart(2,'0')}.` +
`${ms.toString().padStart(3,'0')}`;
}
/**
* Re-solve hour-angle for a custom zenith angle Zdeg (in degrees)
*/
function adjustForCustomAngle(base, Zdeg) {
const φ = base.latitude * Math.PI/180;
const δ = base.delta * Math.PI/180;
const Z = Zdeg * Math.PI/180;
const cosH0 = (Math.cos(Z) - Math.sin(φ) * Math.sin(δ)) /
(Math.cos(φ) * Math.cos(δ));
if (cosH0 < -1 || cosH0 > 1) {
return { ...base, sunrise: NaN, sunset: NaN };
}
const H0h = (Math.acos(cosH0) * 180/Math.PI) / 15;
return {
...base,
sunrise: base.suntransit - H0h,
sunset: base.suntransit + H0h
};
}
/**
* Core SPA data calculation (raw fractional hours)
* @param {Date} date - JavaScript Date (UTC)
* @param {number} lat
* @param {number} lng
* @param {number} tz - timezone offset in hours (e.g. -4 for EDT)
* @param {object} params - { elevation, pressure, temperature, delta_ut1, delta_t, slope, azm_rotation, atmos_refract }
* @param {number[]} angles - custom zenith angles (deg) for twilight
*/
function getSpa(date, lat, lng, tz = 0, params = {}, angles = []) {
const d = new spa.SpaData();
// Use UTC components and explicit tz
d.year = date.getUTCFullYear();
d.month = date.getUTCMonth() + 1;
d.day = date.getUTCDate();
d.hour = date.getUTCHours();
d.minute = date.getUTCMinutes();
d.second = date.getUTCSeconds();
d.longitude = lng;
d.latitude = lat;
d.timezone = tz;
// Align defaults to reference C code
d.elevation = params.elevation ?? 0;
d.pressure = params.pressure ?? 1013;
d.temperature = params.temperature ?? 15;
d.delta_ut1 = params.delta_ut1 ?? 0;
d.delta_t = params.delta_t ?? 67;
d.slope = params.slope ?? 0;
d.azm_rotation = params.azm_rotation ?? 0;
d.atmos_refract= params.atmos_refract?? 0.5667;
// Only compute ZA and rise/transit/set
d.function = spa.SPA_ZA_RTS;
const rc = spa.spa_calculate(d);
if (rc !== 0) {
throw new Error(`SPA calculation failed with code ${rc}`);
}
// Base outputs
const output = {
zenith: d.zenith,
azimuth: d.azimuth,
sunrise: d.sunrise,
solarNoon: d.suntransit,
sunset: d.sunset
};
// Custom angles (twilight)
if (angles.length) {
output.angles = angles.map(Z => {
const c = adjustForCustomAngle(d, Z);
return { sunrise: c.sunrise, sunset: c.sunset };
});
}
return output;
}
/**
* Same as getSpa, but formats sunrise/noon/sunset to strings
*/
function calcSpa(date, lat, lng, tz = 0, params = {}, angles = []) {
const raw = getSpa(date, lat, lng, tz, params, angles);
return {
zenith: raw.zenith,
azimuth: raw.azimuth,
sunrise: fractalTime(raw.sunrise),
solarNoon: fractalTime(raw.solarNoon),
sunset: fractalTime(raw.sunset),
angles: raw.angles ? raw.angles.map(a => ({
sunrise: fractalTime(a.sunrise),
sunset: fractalTime(a.sunset)
})) : undefined
};
}
module.exports = { getSpa, calcSpa, fractalTime, adjustForCustomAngle };

989
lib/spa.js Normal file
View file

@ -0,0 +1,989 @@
// dist/spa.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.spa_calculate = exports.topocentric_azimuth_angle = exports.topocentric_azimuth_angle_astro = exports.topocentric_zenith_angle = exports.topocentric_elevation_angle_corrected = exports.atmospheric_refraction_correction = exports.topocentric_elevation_angle = exports.topocentric_local_hour_angle = exports.topocentric_right_ascension = exports.right_ascension_parallax_and_topocentric_dec = exports.observer_hour_angle = exports.geocentric_declination = exports.geocentric_right_ascension = exports.third_order_polynomial = exports.limit_degrees = exports.rad2deg = exports.deg2rad = exports.SpaData = exports.SPA_ALL = exports.SPA_ZA_RTS = exports.SPA_ZA_INC = exports.SPA_ZA = exports.OutCode = void 0;
var OutCode;
(function (OutCode) {
OutCode[OutCode["SPA_ZA"] = 0] = "SPA_ZA";
OutCode[OutCode["SPA_ZA_INC"] = 1] = "SPA_ZA_INC";
OutCode[OutCode["SPA_ZA_RTS"] = 2] = "SPA_ZA_RTS";
OutCode[OutCode["SPA_ALL"] = 3] = "SPA_ALL"; //calculate all SPA output values
})(OutCode = exports.OutCode || (exports.OutCode = {}));
//For external reference as per original C definition
exports.SPA_ZA = OutCode.SPA_ZA;
exports.SPA_ZA_INC = OutCode.SPA_ZA_INC;
exports.SPA_ZA_RTS = OutCode.SPA_ZA_RTS;
exports.SPA_ALL = OutCode.SPA_ALL;
var PI = Math.PI;
var SUN_RADIUS = 0.26667;
var L_COUNT = 6;
var B_COUNT = 2;
var R_COUNT = 5;
var Y_COUNT = 63;
var TermsA;
(function (TermsA) {
TermsA[TermsA["TERM_A"] = 0] = "TERM_A";
TermsA[TermsA["TERM_B"] = 1] = "TERM_B";
TermsA[TermsA["TERM_C"] = 2] = "TERM_C";
TermsA[TermsA["TERM_COUNT"] = 3] = "TERM_COUNT";
})(TermsA || (TermsA = {}));
var TermsX;
(function (TermsX) {
TermsX[TermsX["TERM_X0"] = 0] = "TERM_X0";
TermsX[TermsX["TERM_X1"] = 1] = "TERM_X1";
TermsX[TermsX["TERM_X2"] = 2] = "TERM_X2";
TermsX[TermsX["TERM_X3"] = 3] = "TERM_X3";
TermsX[TermsX["TERM_X4"] = 4] = "TERM_X4";
TermsX[TermsX["TERM_X_COUNT"] = 5] = "TERM_X_COUNT";
})(TermsX || (TermsX = {}));
var TermsPE;
(function (TermsPE) {
TermsPE[TermsPE["TERM_PSI_A"] = 0] = "TERM_PSI_A";
TermsPE[TermsPE["TERM_PSI_B"] = 1] = "TERM_PSI_B";
TermsPE[TermsPE["TERM_EPS_C"] = 2] = "TERM_EPS_C";
TermsPE[TermsPE["TERM_EPS_D"] = 3] = "TERM_EPS_D";
TermsPE[TermsPE["TERM_PE_COUNT"] = 4] = "TERM_PE_COUNT";
})(TermsPE || (TermsPE = {}));
var JDSign;
(function (JDSign) {
JDSign[JDSign["JD_MINUS"] = 0] = "JD_MINUS";
JDSign[JDSign["JD_ZERO"] = 1] = "JD_ZERO";
JDSign[JDSign["JD_PLUS"] = 2] = "JD_PLUS";
JDSign[JDSign["JD_COUNT"] = 3] = "JD_COUNT";
})(JDSign || (JDSign = {}));
var SunState;
(function (SunState) {
SunState[SunState["SUN_TRANSIT"] = 0] = "SUN_TRANSIT";
SunState[SunState["SUN_RISE"] = 1] = "SUN_RISE";
SunState[SunState["SUN_SET"] = 2] = "SUN_SET";
SunState[SunState["SUN_COUNT"] = 3] = "SUN_COUNT";
})(SunState || (SunState = {}));
var TERM_Y_COUNT = TermsX.TERM_X_COUNT;
var l_subcount = [64, 34, 20, 7, 3, 1];
var b_subcount = [5, 2];
var r_subcount = [40, 10, 6, 2, 1];
var SpaData = /** @class */ (function () {
function SpaData() {
//--------------------------Input Valuse
this.year = 0; // 4-digit year, valid range: -2000 to 6000, error code: 1
this.month = 0; // 2-digit month, valid range: 1 to 12, error code: 2
this.day = 0; // 2-digit day, valid range: 1 to 31, error code: 3
this.hour = 0; // Observer local hour, valid range: 0 to 24, error code: 4
this.minute = 0; // Observer local minute, valid range: 0 to 59, error code: 5
this.second = 0.0; // Observer local second, valid range: 0 to <60, error code: 6
this.delta_ut1 = 0.0; // Fractional second difference between UTC and UT which is used
// to adjust UTC for earth's irregular rotation rate and is derived
// from observation only and is reported in this bulletin:
// http://maia.usno.navy.mil/ser7/ser7.dat,
// where delta_ut1 = DUT1
// valid range: -1 to 1 second (exclusive), error code 17
this.delta_t = 0.0; // Difference between earth rotation time and terrestrial time
// It is derived from observation only and is reported in this
// bulletin: http://maia.usno.navy.mil/ser7/ser7.dat,
// where delta_t = 32.184 + (TAI-UTC) - DUT1
// valid range: -8000 to 8000 seconds, error code: 7
this.timezone = 0.0; // Observer time zone (negative west of Greenwich)
// valid range: -18 to 18 hours, error code: 8
this.longitude = 0.0; // Observer longitude (negative west of Greenwich)
// valid range: -180 to 180 degrees, error code: 9
this.latitude = 0.0; // Observer latitude (negative south of equator)
// valid range: -90 to 90 degrees, error code: 10
this.elevation = 0.0; // Observer elevation [meters]
// valid range: -6500000 or higher meters, error code: 11
this.pressure = 0.0; // Annual average local pressure [millibars]
// valid range: 0 to 5000 millibars, error code: 12
this.temperature = 0.0; // Annual average local temperature [degrees Celsius]
// valid range: -273 to 6000 degrees Celsius, error code 13
this.slope = 0.0; // Surface slope (measured from the horizontal plane)
// valid range: -360 to 360 degrees, error code: 14
this.azm_rotation = 0.0; // Surface azimuth rotation (measured from south to projection of
// surface normal on horizontal plane, negative east)
// valid range: -360 to 360 degrees, error code: 15
this.atmos_refract = 0.0; // Atmospheric refraction at sunrise and sunset (0.5667 deg is typical)
// valid range: -5 to 5 degrees, error code: 16
this.function = 0; // Switch to choose functions for desired output (from enumeration)
//-----------------ermediate OUTPUT VALUES--------------------
this.jd = 0.0; //Julian day
this.jc = 0.0; //Julian century
this.jde = 0.0; //Julian ephemeris day
this.jce = 0.0; //Julian ephemeris century
this.jme = 0.0; //Julian ephemeris millennium
this.l = 0.0; //earth heliocentric longitude [degrees]
this.b = 0.0; //earth heliocentric latitude [degrees]
this.r = 0.0; //earth radius vector [Astronomical Units, AU]
this.theta = 0.0; //geocentric longitude [degrees]
this.beta = 0.0; //geocentric latitude [degrees]
this.x0 = 0.0; //mean elongation (moon-sun) [degrees]
this.x1 = 0.0; //mean anomaly (sun) [degrees]
this.x2 = 0.0; //mean anomaly (moon) [degrees]
this.x3 = 0.0; //argument latitude (moon) [degrees]
this.x4 = 0.0; //ascending longitude (moon) [degrees]
this.del_psi = 0.0; //nutation longitude [degrees]
this.del_epsilon = 0.0; //nutation obliquity [degrees]
this.epsilon0 = 0.0; //ecliptic mean obliquity [arc seconds]
this.epsilon = 0.0; //ecliptic true obliquity [degrees]
this.del_tau = 0.0; //aberration correction [degrees]
this.lamda = 0.0; //apparent sun longitude [degrees]
this.nu0 = 0.0; //Greenwich mean sidereal time [degrees]
this.nu = 0.0; //Greenwich sidereal time [degrees]
this.alpha = 0.0; //geocentric sun right ascension [degrees]
this.delta = 0.0; //geocentric sun declination [degrees]
this.h = 0.0; //observer hour angle [degrees]
this.xi = 0.0; //sun equatorial horizontal parallax [degrees]
this.del_alpha = 0.0; //sun right ascension parallax [degrees]
this.delta_prime = 0.0; //topocentric sun declination [degrees]
this.alpha_prime = 0.0; //topocentric sun right ascension [degrees]
this.h_prime = 0.0; //topocentric local hour angle [degrees]
this.e0 = 0.0; //topocentric elevation angle (uncorrected) [degrees]
this.del_e = 0.0; //atmospheric refraction correction [degrees]
this.e = 0.0; //topocentric elevation angle (corrected) [degrees]
this.eot = 0.0; //equation of time [minutes]
this.srha = 0.0; //sunrise hour angle [degrees]
this.ssha = 0.0; //sunset hour angle [degrees]
this.sta = 0.0; //sun transit altitude [degrees]
//---------------------Final OUTPUT VALUES------------------------
this.zenith = 0.0; //topocentric zenith angle [degrees]
this.azimuth_astro = 0.0; //topocentric azimuth angle (westward from south) [for astronomers]
this.azimuth = 0.0; //topocentric azimuth angle (eastward from north) [for navigators and solar radiation]
this.incidence = 0.0; //surface incidence angle [degrees]
this.suntransit = 0.0; //local sun transit time (or solar noon) [fractional hour]
this.sunrise = 0.0; //local sunrise time (+/- 30 seconds) [fractional hour]
this.sunset = 0.0; //local sunset time (+/- 30 seconds) [fractional hour]
}
return SpaData;
}());
exports.SpaData = SpaData;
var copySPA = function (src) {
return Array.isArray(src)
? src.map(function (i) { return copySPA(i); })
: typeof src === 'object'
? Object.getOwnPropertyNames(src).reduce(function (o, prop) {
Object.defineProperty(o, prop, Object.getOwnPropertyDescriptor(src, prop));
o[prop] = copySPA(src[prop]);
return o;
}, Object.create(Object.getPrototypeOf(src)))
: src;
};
//===================Earth Periodic Terms===================
var L_TERMS = [
[
[175347046.0, 0, 0],
[3341656.0, 4.6692568, 6283.07585],
[34894.0, 4.6261, 12566.1517],
[3497.0, 2.7441, 5753.3849],
[3418.0, 2.8289, 3.5231],
[3136.0, 3.6277, 77713.7715],
[2676.0, 4.4181, 7860.4194],
[2343.0, 6.1352, 3930.2097],
[1324.0, 0.7425, 11506.7698],
[1273.0, 2.0371, 529.691],
[1199.0, 1.1096, 1577.3435],
[990, 5.233, 5884.927],
[902, 2.045, 26.298],
[857, 3.508, 398.149],
[780, 1.179, 5223.694],
[753, 2.533, 5507.553],
[505, 4.583, 18849.228],
[492, 4.205, 775.523],
[357, 2.92, 0.067],
[317, 5.849, 11790.629],
[284, 1.899, 796.298],
[271, 0.315, 10977.079],
[243, 0.345, 5486.778],
[206, 4.806, 2544.314],
[205, 1.869, 5573.143],
[202, 2.458, 6069.777],
[156, 0.833, 213.299],
[132, 3.411, 2942.463],
[126, 1.083, 20.775],
[115, 0.645, 0.98],
[103, 0.636, 4694.003],
[102, 0.976, 15720.839],
[102, 4.267, 7.114],
[99, 6.21, 2146.17],
[98, 0.68, 155.42],
[86, 5.98, 161000.69],
[85, 1.3, 6275.96],
[85, 3.67, 71430.7],
[80, 1.81, 17260.15],
[79, 3.04, 12036.46],
[75, 1.76, 5088.63],
[74, 3.5, 3154.69],
[74, 4.68, 801.82],
[70, 0.83, 9437.76],
[62, 3.98, 8827.39],
[61, 1.82, 7084.9],
[57, 2.78, 6286.6],
[56, 4.39, 14143.5],
[56, 3.47, 6279.55],
[52, 0.19, 12139.55],
[52, 1.33, 1748.02],
[51, 0.28, 5856.48],
[49, 0.49, 1194.45],
[41, 5.37, 8429.24],
[41, 2.4, 19651.05],
[39, 6.17, 10447.39],
[37, 6.04, 10213.29],
[37, 2.57, 1059.38],
[36, 1.71, 2352.87],
[36, 1.78, 6812.77],
[33, 0.59, 17789.85],
[30, 0.44, 83996.85],
[30, 2.74, 1349.87],
[25, 3.16, 4690.48]
],
[
[628331966747.0, 0, 0],
[206059.0, 2.678235, 6283.07585],
[4303.0, 2.6351, 12566.1517],
[425.0, 1.59, 3.523],
[119.0, 5.796, 26.298],
[109.0, 2.966, 1577.344],
[93, 2.59, 18849.23],
[72, 1.14, 529.69],
[68, 1.87, 398.15],
[67, 4.41, 5507.55],
[59, 2.89, 5223.69],
[56, 2.17, 155.42],
[45, 0.4, 796.3],
[36, 0.47, 775.52],
[29, 2.65, 7.11],
[21, 5.34, 0.98],
[19, 1.85, 5486.78],
[19, 4.97, 213.3],
[17, 2.99, 6275.96],
[16, 0.03, 2544.31],
[16, 1.43, 2146.17],
[15, 1.21, 10977.08],
[12, 2.83, 1748.02],
[12, 3.26, 5088.63],
[12, 5.27, 1194.45],
[12, 2.08, 4694],
[11, 0.77, 553.57],
[10, 1.3, 6286.6],
[10, 4.24, 1349.87],
[9, 2.7, 242.73],
[9, 5.64, 951.72],
[8, 5.3, 2352.87],
[6, 2.65, 9437.76],
[6, 4.67, 4690.48]
],
[
[52919.0, 0, 0],
[8720.0, 1.0721, 6283.0758],
[309.0, 0.867, 12566.152],
[27, 0.05, 3.52],
[16, 5.19, 26.3],
[16, 3.68, 155.42],
[10, 0.76, 18849.23],
[9, 2.06, 77713.77],
[7, 0.83, 775.52],
[5, 4.66, 1577.34],
[4, 1.03, 7.11],
[4, 3.44, 5573.14],
[3, 5.14, 796.3],
[3, 6.05, 5507.55],
[3, 1.19, 242.73],
[3, 6.12, 529.69],
[3, 0.31, 398.15],
[3, 2.28, 553.57],
[2, 4.38, 5223.69],
[2, 3.75, 0.98]
],
[
[289.0, 5.844, 6283.076],
[35, 0, 0],
[17, 5.49, 12566.15],
[3, 5.2, 155.42],
[1, 4.72, 3.52],
[1, 5.3, 18849.23],
[1, 5.97, 242.73]
],
[
[114.0, 3.142, 0],
[8, 4.13, 6283.08],
[1, 3.84, 12566.15]
],
[
[1, 3.14, 0]
]
];
var B_TERMS = [
[
[280.0, 3.199, 84334.662],
[102.0, 5.422, 5507.553],
[80, 3.88, 5223.69],
[44, 3.7, 2352.87],
[32, 4, 1577.34]
],
[
[9, 3.9, 5507.55],
[6, 1.73, 5223.69]
]
];
var R_TERMS = [
[
[100013989.0, 0, 0],
[1670700.0, 3.0984635, 6283.07585],
[13956.0, 3.05525, 12566.1517],
[3084.0, 5.1985, 77713.7715],
[1628.0, 1.1739, 5753.3849],
[1576.0, 2.8469, 7860.4194],
[925.0, 5.453, 11506.77],
[542.0, 4.564, 3930.21],
[472.0, 3.661, 5884.927],
[346.0, 0.964, 5507.553],
[329.0, 5.9, 5223.694],
[307.0, 0.299, 5573.143],
[243.0, 4.273, 11790.629],
[212.0, 5.847, 1577.344],
[186.0, 5.022, 10977.079],
[175.0, 3.012, 18849.228],
[110.0, 5.055, 5486.778],
[98, 0.89, 6069.78],
[86, 5.69, 15720.84],
[86, 1.27, 161000.69],
[65, 0.27, 17260.15],
[63, 0.92, 529.69],
[57, 2.01, 83996.85],
[56, 5.24, 71430.7],
[49, 3.25, 2544.31],
[47, 2.58, 775.52],
[45, 5.54, 9437.76],
[43, 6.01, 6275.96],
[39, 5.36, 4694],
[38, 2.39, 8827.39],
[37, 0.83, 19651.05],
[37, 4.9, 12139.55],
[36, 1.67, 12036.46],
[35, 1.84, 2942.46],
[33, 0.24, 7084.9],
[32, 0.18, 5088.63],
[32, 1.78, 398.15],
[28, 1.21, 6286.6],
[28, 1.9, 6279.55],
[26, 4.59, 10447.39]
],
[
[103019.0, 1.10749, 6283.07585],
[1721.0, 1.0644, 12566.1517],
[702.0, 3.142, 0],
[32, 1.02, 18849.23],
[31, 2.84, 5507.55],
[25, 1.32, 5223.69],
[18, 1.42, 1577.34],
[10, 5.91, 10977.08],
[9, 1.42, 6275.96],
[9, 0.27, 5486.78]
],
[
[4359.0, 5.7846, 6283.0758],
[124.0, 5.579, 12566.152],
[12, 3.14, 0],
[9, 3.63, 77713.77],
[6, 1.87, 5573.14],
[3, 5.47, 18849.23]
],
[
[145.0, 4.273, 6283.076],
[7, 3.92, 12566.15]
],
[
[4, 2.56, 6283.08]
]
];
//===================Periodic Terms for the nutation in longitude and obliquity===================
var Y_TERMS = [
[0, 0, 0, 0, 1],
[-2, 0, 0, 2, 2],
[0, 0, 0, 2, 2],
[0, 0, 0, 0, 2],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[-2, 1, 0, 2, 2],
[0, 0, 0, 2, 1],
[0, 0, 1, 2, 2],
[-2, -1, 0, 2, 2],
[-2, 0, 1, 0, 0],
[-2, 0, 0, 2, 1],
[0, 0, -1, 2, 2],
[2, 0, 0, 0, 0],
[0, 0, 1, 0, 1],
[2, 0, -1, 2, 2],
[0, 0, -1, 0, 1],
[0, 0, 1, 2, 1],
[-2, 0, 2, 0, 0],
[0, 0, -2, 2, 1],
[2, 0, 0, 2, 2],
[0, 0, 2, 2, 2],
[0, 0, 2, 0, 0],
[-2, 0, 1, 2, 2],
[0, 0, 0, 2, 0],
[-2, 0, 0, 2, 0],
[0, 0, -1, 2, 1],
[0, 2, 0, 0, 0],
[2, 0, -1, 0, 1],
[-2, 2, 0, 2, 2],
[0, 1, 0, 0, 1],
[-2, 0, 1, 0, 1],
[0, -1, 0, 0, 1],
[0, 0, 2, -2, 0],
[2, 0, -1, 2, 1],
[2, 0, 1, 2, 2],
[0, 1, 0, 2, 2],
[-2, 1, 1, 0, 0],
[0, -1, 0, 2, 2],
[2, 0, 0, 2, 1],
[2, 0, 1, 0, 0],
[-2, 0, 2, 2, 2],
[-2, 0, 1, 2, 1],
[2, 0, -2, 0, 1],
[2, 0, 0, 0, 1],
[0, -1, 1, 0, 0],
[-2, -1, 0, 2, 1],
[-2, 0, 0, 0, 1],
[0, 0, 2, 2, 1],
[-2, 0, 2, 0, 1],
[-2, 1, 0, 2, 1],
[0, 0, 1, -2, 0],
[-1, 0, 1, 0, 0],
[-2, 1, 0, 0, 0],
[1, 0, 0, 0, 0],
[0, 0, 1, 2, 0],
[0, 0, -2, 2, 2],
[-1, -1, 1, 0, 0],
[0, 1, 1, 0, 0],
[0, -1, 1, 2, 2],
[2, -1, -1, 2, 2],
[0, 0, 3, 2, 2],
[2, -1, 0, 2, 2],
];
var PE_TERMS = [
[-171996, -174.2, 92025, 8.9],
[-13187, -1.6, 5736, -3.1],
[-2274, -0.2, 977, -0.5],
[2062, 0.2, -895, 0.5],
[1426, -3.4, 54, -0.1],
[712, 0.1, -7, 0],
[-517, 1.2, 224, -0.6],
[-386, -0.4, 200, 0],
[-301, 0, 129, -0.1],
[217, -0.5, -95, 0.3],
[-158, 0, 0, 0],
[129, 0.1, -70, 0],
[123, 0, -53, 0],
[63, 0, 0, 0],
[63, 0.1, -33, 0],
[-59, 0, 26, 0],
[-58, -0.1, 32, 0],
[-51, 0, 27, 0],
[48, 0, 0, 0],
[46, 0, -24, 0],
[-38, 0, 16, 0],
[-31, 0, 13, 0],
[29, 0, 0, 0],
[29, 0, -12, 0],
[26, 0, 0, 0],
[-22, 0, 0, 0],
[21, 0, -10, 0],
[17, -0.1, 0, 0],
[16, 0, -8, 0],
[-16, 0.1, 7, 0],
[-15, 0, 9, 0],
[-13, 0, 7, 0],
[-12, 0, 6, 0],
[11, 0, 0, 0],
[-10, 0, 5, 0],
[-8, 0, 3, 0],
[7, 0, -3, 0],
[-7, 0, 0, 0],
[-7, 0, 3, 0],
[-7, 0, 3, 0],
[6, 0, 0, 0],
[6, 0, -3, 0],
[6, 0, -3, 0],
[-6, 0, 3, 0],
[-6, 0, 3, 0],
[5, 0, 0, 0],
[-5, 0, 3, 0],
[-5, 0, 3, 0],
[-5, 0, 3, 0],
[4, 0, 0, 0],
[4, 0, 0, 0],
[4, 0, 0, 0],
[-4, 0, 0, 0],
[-4, 0, 0, 0],
[-4, 0, 0, 0],
[3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
[-3, 0, 0, 0],
];
//=================== Utility functions for other applications (such as NREL's SAMPA) --------------
function deg2rad(degrees) {
return (PI / 180.0) * degrees;
}
exports.deg2rad = deg2rad;
function rad2deg(radians) {
return (180.0 / PI) * radians;
}
exports.rad2deg = rad2deg;
function limit_degrees(degrees) {
degrees /= 360;
var limited = 360 * (degrees - Math.floor(degrees));
if (limited < 0) {
limited += 360;
}
return limited;
}
exports.limit_degrees = limit_degrees;
function third_order_polynomial(a, b, c, d, x) {
return ((a * x + b) + c) * x + d;
}
exports.third_order_polynomial = third_order_polynomial;
function geocentric_right_ascension(lamda, epsilon, beta) {
var lambdaRad = deg2rad(lamda);
var epsilonRad = deg2rad(epsilon);
return limit_degrees(rad2deg(Math.atan2(Math.sin(lambdaRad) * Math.cos(epsilonRad) -
Math.tan(deg2rad(beta)) * Math.sin(epsilonRad), Math.cos(lambdaRad))));
}
exports.geocentric_right_ascension = geocentric_right_ascension;
function geocentric_declination(beta, epsilon, lamda) {
var betaRad = deg2rad(beta);
var epsilonRad = deg2rad(epsilon);
return rad2deg(Math.asin(Math.sin(betaRad) * Math.cos(epsilonRad) +
Math.cos(betaRad) * Math.sin(epsilonRad) * Math.sin(deg2rad(lamda))));
}
exports.geocentric_declination = geocentric_declination;
function observer_hour_angle(nu, longitude, alpha_deg) {
return limit_degrees(nu + longitude - alpha_deg);
}
exports.observer_hour_angle = observer_hour_angle;
function right_ascension_parallax_and_topocentric_dec(latitude, elevation, xi, h, delta, dltap) {
var delta_alpha_rad = 0;
var lat_rad = deg2rad(latitude);
var xi_rad = deg2rad(xi);
var h_rad = deg2rad(h);
var delta_rad = deg2rad(delta);
var u = Math.atan(0.99664719 * Math.tan(lat_rad));
var y = 0.99664719 * Math.sin(u) + elevation * Math.sin(lat_rad) / 6378140.0;
var x = Math.cos(u) + elevation * Math.cos(lat_rad) / 6378140.0;
delta_alpha_rad = Math.atan2(-x * Math.sin(xi_rad) * Math.sin(h_rad), Math.cos(delta_rad) - x * Math.sin(xi_rad) * Math.cos(h_rad));
dltap.delta_prime = rad2deg(Math.atan2((Math.sin(delta_rad) - y * Math.sin(xi_rad)) * Math.cos(delta_alpha_rad), Math.cos(delta_rad) - x * Math.sin(xi_rad) * Math.cos(h_rad)));
dltap.delta_alpha = rad2deg(delta_alpha_rad);
}
exports.right_ascension_parallax_and_topocentric_dec = right_ascension_parallax_and_topocentric_dec;
function topocentric_right_ascension(alpha_deg, delta_alpha) {
return alpha_deg + delta_alpha;
}
exports.topocentric_right_ascension = topocentric_right_ascension;
function topocentric_local_hour_angle(h, delta_alpha) {
return h - delta_alpha;
}
exports.topocentric_local_hour_angle = topocentric_local_hour_angle;
function topocentric_elevation_angle(latitude, delta_prime, h_prime) {
var latRad = deg2rad(latitude);
var deltaPrimeRad = deg2rad(delta_prime);
return rad2deg(Math.asin(Math.sin(latRad) * Math.sin(deltaPrimeRad) +
Math.cos(latRad) * Math.cos(deltaPrimeRad) * Math.cos(deg2rad(h_prime))));
}
exports.topocentric_elevation_angle = topocentric_elevation_angle;
function atmospheric_refraction_correction(pressure, temperature, atmos_refract, e0) {
var delE = 0;
if (e0 >= -1 * (SUN_RADIUS + atmos_refract))
delE = (pressure / 1010.0) * (283.0 / (273.0 + temperature)) *
1.02 / (60.0 * Math.tan(deg2rad(e0 + 10.3 / (e0 + 5.11))));
return delE;
}
exports.atmospheric_refraction_correction = atmospheric_refraction_correction;
function topocentric_elevation_angle_corrected(e0, delta_e) {
return e0 + delta_e;
}
exports.topocentric_elevation_angle_corrected = topocentric_elevation_angle_corrected;
function topocentric_zenith_angle(e) {
return 90.0 - e;
}
exports.topocentric_zenith_angle = topocentric_zenith_angle;
function topocentric_azimuth_angle_astro(h_prime, latitude, delta_prime) {
var h_prime_rad = deg2rad(h_prime);
var lat_rad = deg2rad(latitude);
return limit_degrees(rad2deg(Math.atan2(Math.sin(h_prime_rad), Math.cos(h_prime_rad) * Math.sin(lat_rad) - Math.tan(deg2rad(delta_prime)) * Math.cos(lat_rad))));
}
exports.topocentric_azimuth_angle_astro = topocentric_azimuth_angle_astro;
function topocentric_azimuth_angle(azimuth_astro) {
return limit_degrees(azimuth_astro + 180.0);
}
exports.topocentric_azimuth_angle = topocentric_azimuth_angle;
//=================== Local Utility functions ===================
function integer(val) {
return Math.floor(val);
}
//===============================================================
function validate_inputs(spa) {
if ((spa.year < -2000) || (spa.year > 6000))
return 1;
if ((spa.month < 1) || (spa.month > 12))
return 2;
if ((spa.day < 1) || (spa.day > 31))
return 3;
if ((spa.hour < 0) || (spa.hour > 24))
return 4;
if ((spa.minute < 0) || (spa.minute > 59))
return 5;
if ((spa.second < 0) || (spa.second >= 60))
return 6;
if ((spa.pressure < 0) || (spa.pressure > 5000))
return 12;
if ((spa.temperature <= -273) || (spa.temperature > 6000))
return 13;
if ((spa.delta_ut1 <= -1) || (spa.delta_ut1 >= 1))
return 17;
if ((spa.hour == 24) && (spa.minute > 0))
return 5;
if ((spa.hour == 24) && (spa.second > 0))
return 6;
if (Math.abs(spa.delta_t) > 8000)
return 7;
if (Math.abs(spa.timezone) > 18)
return 8;
if (Math.abs(spa.longitude) > 180)
return 9;
if (Math.abs(spa.latitude) > 90)
return 10;
if (Math.abs(spa.atmos_refract) > 5)
return 16;
if (spa.elevation < -6500000)
return 11;
return 0;
}
//===============================================================
function julian_day(year, month, day, hour, minute, second, dut1, tz) {
var day_decimal = 0.0;
var julian_day = 0.0;
var a = 0.0;
day_decimal = day + (hour - tz + (minute + (second + dut1) / 60.0) / 60.0) / 24.0;
if (month < 3) {
month += 12;
year--;
}
julian_day = integer(365.25 * (year + 4716.0)) + integer(30.6001 * (month + 1)) + day_decimal - 1524.5;
if (julian_day > 2299160.0) {
a = integer(year / 100);
julian_day += (2 - a + integer(a / 4));
}
return julian_day;
}
function julian_century(jd) {
return (jd - 2451545.0) / 36525.0;
}
function mean_elongation_moon_sun(jce) {
return third_order_polynomial(1.0 / 189474.0, -0.0019142, 445267.11148, 297.85036, jce);
}
function julian_ephemeris_day(jd, delta_t) {
return jd + delta_t / 86400.0;
}
function julian_ephemeris_century(jde) {
return (jde - 2451545.0) / 36525.0;
}
function julian_ephemeris_millennium(jce) {
return (jce / 10.0);
}
function earth_periodic_term_summation(terms, count, jme) {
var sum = 0;
for (var i = 0; i < count; i++)
sum += terms[i][TermsA.TERM_A]
* Math.cos(terms[i][TermsA.TERM_B]
+ terms[i][TermsA.TERM_C] * jme);
return sum;
}
function earth_values(term_sum, count, jme) {
var sum = 0;
for (var i = 0; i < count; i++)
sum += term_sum[i] * Math.pow(jme, i);
sum /= 1.0e8;
return sum;
}
function earth_heliocentric_longitude(jme) {
var sum = [];
for (var i = 0; i < L_COUNT; i++)
sum[i] = earth_periodic_term_summation(L_TERMS[i], l_subcount[i], jme);
return limit_degrees(rad2deg(earth_values(sum, L_COUNT, jme)));
}
function earth_heliocentric_latitude(jme) {
var sum = [];
for (var i = 0; i < B_COUNT; i++)
sum[i] = earth_periodic_term_summation(B_TERMS[i], b_subcount[i], jme);
return rad2deg(earth_values(sum, B_COUNT, jme));
}
function earth_radius_vector(jme) {
var sum = [];
for (var i = 0; i < R_COUNT; i++)
sum[i] = earth_periodic_term_summation(R_TERMS[i], r_subcount[i], jme);
return earth_values(sum, R_COUNT, jme);
}
function geocentric_longitude(l) {
var theta = l + 180.0;
if (theta >= 360.0)
theta -= 360.0;
return theta;
}
function geocentric_latitude(b) {
return -b;
}
function mean_anomaly_sun(jce) {
return third_order_polynomial(-1.0 / 300000.0, -0.0001603, 35999.05034, 357.52772, jce);
}
function mean_anomaly_moon(jce) {
return third_order_polynomial(1.0 / 56250.0, 0.0086972, 477198.867398, 134.96298, jce);
}
function argument_latitude_moon(jce) {
return third_order_polynomial(1.0 / 327270.0, -0.0036825, 483202.017538, 93.27191, jce);
}
function ascending_longitude_moon(jce) {
return third_order_polynomial(1.0 / 450000.0, 0.0020708, -1934.136261, 125.04452, jce);
}
function xy_term_summation(i, x) {
var sum = 0;
for (var j = 0; j < TERM_Y_COUNT; j++)
sum += x[j] * Y_TERMS[i][j];
return sum;
}
function nutation_longitude_and_obliquity(jce, x, spa) {
var xy_term_sum;
var sum_psi = 0;
var sum_epsilon = 0;
for (var i = 0; i < Y_COUNT; i++) {
xy_term_sum = deg2rad(xy_term_summation(i, x));
sum_psi += (PE_TERMS[i][TermsPE.TERM_PSI_A] + jce * PE_TERMS[i][TermsPE.TERM_PSI_B]) * Math.sin(xy_term_sum);
sum_epsilon += (PE_TERMS[i][TermsPE.TERM_EPS_C] + jce * PE_TERMS[i][TermsPE.TERM_EPS_D]) * Math.cos(xy_term_sum);
}
spa.del_psi = sum_psi / 36000000.0;
spa.del_epsilon = sum_epsilon / 36000000.0;
}
function ecliptic_mean_obliquity(jme) {
var u = jme / 10.0;
return 84381.448 + u * (-4680.93 + u * (-1.55 + u * (1999.25 + u * (-51.38 + u * (-249.67 +
u * (-39.05 + u * (7.12 + u * (27.87 + u * (5.79 + u * 2.45)))))))));
}
function ecliptic_true_obliquity(delta_epsilon, epsilon0) {
return delta_epsilon + epsilon0 / 3600.0;
}
function aberration_correction(r) {
return -20.4898 / (3600.0 * r);
}
function apparent_sun_longitude(theta, delta_psi, delta_tau) {
return theta + delta_psi + delta_tau;
}
function greenwich_mean_sidereal_time(jd, jc) {
return limit_degrees(280.46061837 + 360.98564736629 * (jd - 2451545.0) +
jc * jc * (0.000387933 - jc / 38710000.0));
}
function greenwich_sidereal_time(nu0, delta_psi, epsilon) {
return nu0 + delta_psi * Math.cos(deg2rad(epsilon));
}
function sun_equatorial_horizontal_parallax(r) {
return 8.794 / (3600.0 * r);
}
function surface_incidence_angle(zenith, azimuth_astro, azm_rotation, slope) {
var zenith_rad = deg2rad(zenith);
var slope_rad = deg2rad(slope);
return rad2deg(Math.acos(Math.cos(zenith_rad) * Math.cos(slope_rad) +
Math.sin(slope_rad) * Math.sin(zenith_rad) * Math.cos(deg2rad(azimuth_astro - azm_rotation))));
}
function sun_mean_longitude(jme) {
return limit_degrees(280.4664567 + jme * (360007.6982779 + jme * (0.03032028 +
jme * (1 / 49931.0 + jme * (-1 / 15300.0 + jme * (-1 / 2000000.0))))));
}
function limit_minutes(minutes) {
var limited = minutes;
if (limited < -20.0)
limited += 1440.0;
else if (limited > 20.0)
limited -= 1440.0;
return limited;
}
function eot(m, alpha, del_psi, epsilon) {
return limit_minutes(4.0 * (m - 0.0057183 - alpha + del_psi * Math.cos(deg2rad(epsilon))));
}
function approx_sun_transit_time(alpha_zero, longitude, nu) {
return (alpha_zero - longitude - nu) / 360.0;
}
function sun_hour_angle_at_rise_set(latitude, delta_zero, h0_prime) {
var h0 = -99999;
var latitude_rad = deg2rad(latitude);
var delta_zero_rad = deg2rad(delta_zero);
var argument = (Math.sin(deg2rad(h0_prime)) - Math.sin(latitude_rad) * Math.sin(delta_zero_rad)) /
(Math.cos(latitude_rad) * Math.cos(delta_zero_rad));
if (Math.abs(argument) <= 1)
h0 = limit_degrees180(rad2deg(Math.acos(argument)));
return h0;
}
function limit_zero2one(value) {
var limited = value - Math.floor(value);
if (limited < 0)
limited += 1.0;
return limited;
}
function approx_sun_rise_and_set(m_rts, h0) {
var h0_dfrac = h0 / 360.0;
m_rts[SunState.SUN_RISE] = limit_zero2one(m_rts[SunState.SUN_TRANSIT] - h0_dfrac);
m_rts[SunState.SUN_SET] = limit_zero2one(m_rts[SunState.SUN_TRANSIT] + h0_dfrac);
m_rts[SunState.SUN_TRANSIT] = limit_zero2one(m_rts[SunState.SUN_TRANSIT]);
}
function rts_alpha_delta_prime(ad, n) {
var a = ad[JDSign.JD_ZERO] - ad[JDSign.JD_MINUS];
var b = ad[JDSign.JD_PLUS] - ad[JDSign.JD_ZERO];
if (Math.abs(a) >= 2.0)
a = limit_zero2one(a);
if (Math.abs(b) >= 2.0)
b = limit_zero2one(b);
return ad[JDSign.JD_ZERO] + n * (a + b + (b - a) * n) / 2.0;
}
function limit_degrees180pm(degrees) {
var limited;
degrees /= 360.0;
limited = 360.0 * (degrees - Math.floor(degrees));
if (limited < -180.0)
limited += 360.0;
else if (limited > 180.0)
limited -= 360.0;
return limited;
}
function limit_degrees180(degrees) {
var limited;
degrees /= 180.0;
limited = 180.0 * (degrees - Math.floor(degrees));
if (limited < 0)
limited += 180.0;
return limited;
}
function rts_sun_altitude(latitude, delta_prime, h_prime) {
var latitude_rad = deg2rad(latitude);
var delta_prime_rad = deg2rad(delta_prime);
return rad2deg(Math.asin(Math.sin(latitude_rad) * Math.sin(delta_prime_rad) +
Math.cos(latitude_rad) * Math.cos(delta_prime_rad) * Math.cos(deg2rad(h_prime))));
}
function sun_rise_and_set(m_rts, h_rts, delta_prime, latitude, h_prime, h0_prime, sun) {
return m_rts[sun] + (h_rts[sun] - h0_prime) /
(360.0 * Math.cos(deg2rad(delta_prime[sun])) * Math.cos(deg2rad(latitude)) * Math.sin(deg2rad(h_prime[sun])));
}
function dayfrac_to_local_hr(dayfrac, timezone) {
return 24.0 * limit_zero2one(dayfrac + timezone / 24.0);
}
////////////////////////////////////////////////////////////////////////
// Calculate Equation of Time (EOT) and Sun Rise, Transit, & Set (RTS)
////////////////////////////////////////////////////////////////////////
function calculate_eot_and_sun_rise_transit_set(spa) {
var nu = 0;
var m = 0;
var h0 = 0;
var n = 0;
var alpha = [];
var delta = [];
var m_rts = [];
var nu_rts = [];
var h_rts = [];
var alpha_prime = [];
var delta_prime = [];
var h_prime = [];
var h0_prime = -1 * (SUN_RADIUS + spa.atmos_refract);
var sun_rts = copySPA(spa);
sun_rts.hour = sun_rts.minute = sun_rts.second = 0;
sun_rts.delta_ut1 = sun_rts.timezone = 0.0;
sun_rts.jd = julian_day(sun_rts.year, sun_rts.month, sun_rts.day, sun_rts.hour, sun_rts.minute, sun_rts.second, sun_rts.delta_ut1, sun_rts.timezone);
m = sun_mean_longitude(spa.jme);
spa.eot = eot(m, spa.alpha, spa.del_psi, spa.epsilon);
calculate_geocentric_sun_right_ascension_and_declination(sun_rts);
nu = sun_rts.nu;
sun_rts.delta_t = 0;
sun_rts.jd--;
for (var i = 0; i < JDSign.JD_COUNT; i++) {
calculate_geocentric_sun_right_ascension_and_declination(sun_rts);
alpha[i] = sun_rts.alpha;
delta[i] = sun_rts.delta;
sun_rts.jd++;
}
m_rts[SunState.SUN_TRANSIT] = approx_sun_transit_time(alpha[JDSign.JD_ZERO], spa.longitude, nu);
h0 = sun_hour_angle_at_rise_set(spa.latitude, delta[JDSign.JD_ZERO], h0_prime);
if (h0 >= 0) {
approx_sun_rise_and_set(m_rts, h0);
for (var i = 0; i < SunState.SUN_COUNT; i++) {
nu_rts[i] = nu + 360.985647 * m_rts[i];
n = m_rts[i] + spa.delta_t / 86400.0;
alpha_prime[i] = rts_alpha_delta_prime(alpha, n);
delta_prime[i] = rts_alpha_delta_prime(delta, n);
h_prime[i] = limit_degrees180pm(nu_rts[i] + spa.longitude - alpha_prime[i]);
h_rts[i] = rts_sun_altitude(spa.latitude, delta_prime[i], h_prime[i]);
}
spa.srha = h_prime[SunState.SUN_RISE];
spa.ssha = h_prime[SunState.SUN_SET];
spa.sta = h_rts[SunState.SUN_TRANSIT];
spa.suntransit = dayfrac_to_local_hr(m_rts[SunState.SUN_TRANSIT] - h_prime[SunState.SUN_TRANSIT] / 360.0, spa.timezone);
spa.sunrise = dayfrac_to_local_hr(sun_rise_and_set(m_rts, h_rts, delta_prime, spa.latitude, h_prime, h0_prime, SunState.SUN_RISE), spa.timezone);
spa.sunset = dayfrac_to_local_hr(sun_rise_and_set(m_rts, h_rts, delta_prime, spa.latitude, h_prime, h0_prime, SunState.SUN_SET), spa.timezone);
}
else
spa.srha = spa.ssha = spa.sta = spa.suntransit = spa.sunrise = spa.sunset = -99999;
}
////////////////////////////////////////////////////////////////////////////////////////////////
// Calculate required SPA parameters to get the right ascension (alpha) and declination (delta)
// Note: JD must be already calculated and in structure
////////////////////////////////////////////////////////////////////////////////////////////////
function calculate_geocentric_sun_right_ascension_and_declination(spa) {
spa.jc = julian_century(spa.jd);
spa.jde = julian_ephemeris_day(spa.jd, spa.delta_t);
spa.jce = julian_ephemeris_century(spa.jde);
spa.jme = julian_ephemeris_millennium(spa.jce);
spa.l = earth_heliocentric_longitude(spa.jme);
spa.b = earth_heliocentric_latitude(spa.jme);
spa.r = earth_radius_vector(spa.jme);
spa.theta = geocentric_longitude(spa.l);
spa.beta = geocentric_latitude(spa.b);
var x = [];
x[TermsX.TERM_X0] = spa.x0 = mean_elongation_moon_sun(spa.jce);
x[TermsX.TERM_X1] = spa.x1 = mean_anomaly_sun(spa.jce);
x[TermsX.TERM_X2] = spa.x2 = mean_anomaly_moon(spa.jce);
x[TermsX.TERM_X3] = spa.x3 = argument_latitude_moon(spa.jce);
x[TermsX.TERM_X4] = spa.x4 = ascending_longitude_moon(spa.jce);
nutation_longitude_and_obliquity(spa.jce, x, spa);
spa.epsilon0 = ecliptic_mean_obliquity(spa.jme);
spa.epsilon = ecliptic_true_obliquity(spa.del_epsilon, spa.epsilon0);
spa.del_tau = aberration_correction(spa.r);
spa.lamda = apparent_sun_longitude(spa.theta, spa.del_psi, spa.del_tau);
spa.nu0 = greenwich_mean_sidereal_time(spa.jd, spa.jc);
spa.nu = greenwich_sidereal_time(spa.nu0, spa.del_psi, spa.epsilon);
spa.alpha = geocentric_right_ascension(spa.lamda, spa.epsilon, spa.beta);
spa.delta = geocentric_declination(spa.beta, spa.epsilon, spa.lamda);
}
//Calculate SPA output values (in structure) based on input values passed in structure
function spa_calculate(spa) {
var result = validate_inputs(spa);
if (result == 0) {
spa.jd = julian_day(spa.year, spa.month, spa.day, spa.hour, spa.minute, spa.second, spa.delta_ut1, spa.timezone);
calculate_geocentric_sun_right_ascension_and_declination(spa);
spa.h = observer_hour_angle(spa.nu, spa.longitude, spa.alpha);
spa.xi = sun_equatorial_horizontal_parallax(spa.r);
var dltap = { delta_alpha: spa.del_alpha, delta_prime: spa.delta_prime };
right_ascension_parallax_and_topocentric_dec(spa.latitude, spa.elevation, spa.xi, spa.h, spa.delta, dltap);
spa.del_alpha = dltap.delta_alpha;
spa.delta_prime = dltap.delta_prime;
spa.alpha_prime = topocentric_right_ascension(spa.alpha, spa.del_alpha);
spa.h_prime = topocentric_local_hour_angle(spa.h, spa.del_alpha);
spa.e0 = topocentric_elevation_angle(spa.latitude, spa.delta_prime, spa.h_prime);
spa.del_e = atmospheric_refraction_correction(spa.pressure, spa.temperature, spa.atmos_refract, spa.e0);
spa.e = topocentric_elevation_angle_corrected(spa.e0, spa.del_e);
spa.zenith = topocentric_zenith_angle(spa.e);
spa.azimuth_astro = topocentric_azimuth_angle_astro(spa.h_prime, spa.latitude, spa.delta_prime);
spa.azimuth = topocentric_azimuth_angle(spa.azimuth_astro);
if ((spa.function == exports.SPA_ZA_INC) || (spa.function == exports.SPA_ALL))
spa.incidence = surface_incidence_angle(spa.zenith, spa.azimuth_astro, spa.azm_rotation, spa.slope);
if ((spa.function == exports.SPA_ZA_RTS) || (spa.function == exports.SPA_ALL))
calculate_eot_and_sun_rise_transit_set(spa);
}
return result;
}
exports.spa_calculate = spa_calculate;

View file

@ -1,25 +1,71 @@
{
"name": "nrel-spa",
"version": "1.3.0",
"description": "NREL SPA native implementation in JS",
"main": "index.js",
"version": "2.0.0",
"description": "Pure JavaScript implementation of the NREL Solar Position Algorithm (SPA). Calculates solar zenith, azimuth, sunrise, sunset, and solar noon for any location and date.",
"author": "Aric Camarata",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"files": [
"dist/",
"lib/",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"test": "node test.js"
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"prepublishOnly": "tsup"
},
"keywords": [
"solar",
"spa",
"nrel",
"solar",
"calculator"
"sunrise",
"sunset",
"solar-noon",
"zenith",
"azimuth",
"solar-position",
"astronomy",
"twilight",
"javascript"
],
"author": "Ali Camarata",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/acamarata/nrel-spa.git"
"url": "git+https://github.com/acamarata/nrel-spa.git"
},
"homepage": "https://github.com/acamarata/nrel-spa#readme",
"bugs": {
"url": "https://github.com/acamarata/nrel-spa/issues"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@types/node": "^25.3.0",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
}
}

925
pnpm-lock.yaml Normal file
View file

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

2
pnpm-workspace.yaml Normal file
View file

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

240
src/index.ts Normal file
View file

@ -0,0 +1,240 @@
export type {
SpaOptions,
SpaResult,
SpaFormattedResult,
SpaAnglesResult,
SpaResultWithAngles,
SpaFormattedResultWithAngles,
SpaFunctionCode,
} 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 type {
SpaOptions,
SpaResult,
SpaFormattedResult,
SpaResultWithAngles,
SpaFormattedResultWithAngles,
} from './types.js';
// The core SPA algorithm lives in lib/spa.js (the JS port of the NREL C source).
// 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 _load = typeof __require === 'function' ? __require : require;
const spa = _load('../lib/spa.js') as {
SpaData: new () => SpaDataInstance;
SPA_ZA_RTS: number;
spa_calculate: (data: SpaDataInstance) => number;
};
/** Internal SpaData instance shape from lib/spa.js */
interface SpaDataInstance {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
timezone: number;
longitude: number;
latitude: number;
elevation: number;
pressure: number;
temperature: number;
delta_ut1: number;
delta_t: number;
slope: number;
azm_rotation: number;
atmos_refract: number;
function: number;
zenith: number;
azimuth: number;
sunrise: number;
sunset: number;
suntransit: number;
delta: number;
[key: string]: number;
}
/**
* Validate that a value is a finite number, throwing a clear error if not.
* @internal
*/
function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== 'number' || !isFinite(value)) {
throw new TypeError(
`SPA: ${name} must be a finite number, got ${typeof value === 'number' ? value : typeof value}`,
);
}
}
/**
* Format fractional hours to HH:MM:SS string.
* Returns "N/A" for non-finite or negative values (polar night/day scenarios).
*/
export function formatTime(hours: number): string {
if (!isFinite(hours) || hours < 0) return 'N/A';
const totalSec = Math.round(hours * 3600);
// Wrap at 24h: values near midnight can round to 24:00:00
const h = Math.floor(totalSec / 3600) % 24;
const rem = totalSec - Math.floor(totalSec / 3600) * 3600;
const m = Math.floor(rem / 60);
const s = rem - m * 60;
return (
String(h).padStart(2, '0') + ':' +
String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0')
);
}
/**
* Re-solve hour angles for a custom zenith angle (e.g., twilight calculations).
*
* Common angles: civil twilight 96, nautical twilight 102, astronomical twilight 108.
*
* @param base - SpaData instance with computed delta and suntransit
* @param zenithAngle - Custom zenith angle in degrees
* @returns Object with sunrise and sunset for the custom angle (NaN if no rise/set)
* @internal
*/
function adjustForCustomAngle(
base: SpaDataInstance,
zenithAngle: number,
): { sunrise: number; sunset: number } {
const phi = base.latitude * Math.PI / 180;
const delta = base.delta * Math.PI / 180;
const Z = zenithAngle * Math.PI / 180;
const cosH0 =
(Math.cos(Z) - Math.sin(phi) * Math.sin(delta)) /
(Math.cos(phi) * Math.cos(delta));
if (cosH0 < -1 || cosH0 > 1) {
return { sunrise: NaN, sunset: NaN };
}
const H0h = (Math.acos(cosH0) * 180 / Math.PI) / 15;
return {
sunrise: base.suntransit - H0h,
sunset: base.suntransit + H0h,
};
}
/**
* Compute solar position for the given parameters.
*
* @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
* @param angles - Custom zenith angles in degrees for twilight calculations
* @returns Solar position result with computed values
*/
export function getSpa(
date: Date,
latitude: number,
longitude: number,
timezone?: number | null,
options?: SpaOptions | null,
angles?: number[],
): SpaResult | SpaResultWithAngles {
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError('SPA: date must be a valid Date object');
}
assertFiniteNumber(latitude, 'latitude');
assertFiniteNumber(longitude, 'longitude');
if (latitude < -90 || latitude > 90) {
throw new RangeError(`SPA: latitude must be between -90 and 90, got ${latitude}`);
}
if (longitude < -180 || longitude > 180) {
throw new RangeError(`SPA: longitude must be between -180 and 180, got ${longitude}`);
}
const tz = timezone ?? 0;
const opts = options ?? {};
const d = new spa.SpaData();
d.year = date.getUTCFullYear();
d.month = date.getUTCMonth() + 1;
d.day = date.getUTCDate();
d.hour = date.getUTCHours();
d.minute = date.getUTCMinutes();
d.second = date.getUTCSeconds();
d.longitude = longitude;
d.latitude = latitude;
d.timezone = tz;
d.elevation = opts.elevation ?? 0;
d.pressure = opts.pressure ?? 1013;
d.temperature = opts.temperature ?? 15;
d.delta_ut1 = opts.delta_ut1 ?? 0;
d.delta_t = opts.delta_t ?? 67;
d.slope = opts.slope ?? 0;
d.azm_rotation = opts.azm_rotation ?? 0;
d.atmos_refract = opts.atmos_refract ?? 0.5667;
d.function = opts.function ?? SPA_ZA_RTS;
const rc = spa.spa_calculate(d);
if (rc !== 0) {
throw new Error(`SPA: calculation failed (error code ${rc})`);
}
const result: SpaResult = {
zenith: d.zenith,
azimuth: d.azimuth,
sunrise: d.sunrise,
solarNoon: d.suntransit,
sunset: d.sunset,
};
if (angles && angles.length > 0) {
const angleResults = angles.map((Z) => adjustForCustomAngle(d, Z));
return {
...result,
angles: angleResults,
} as SpaResultWithAngles;
}
return result;
}
/**
* 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.
*/
export function calcSpa(
date: Date,
latitude: number,
longitude: number,
timezone?: number | null,
options?: SpaOptions | null,
angles?: number[],
): SpaFormattedResult | SpaFormattedResultWithAngles {
const raw = getSpa(date, latitude, longitude, timezone, options, angles);
const formatted: SpaFormattedResult = {
zenith: raw.zenith,
azimuth: raw.azimuth,
sunrise: formatTime(raw.sunrise),
solarNoon: formatTime(raw.solarNoon),
sunset: formatTime(raw.sunset),
};
if ('angles' in raw && raw.angles) {
return {
...formatted,
angles: raw.angles.map((a) => ({
sunrise: formatTime(a.sunrise),
sunset: formatTime(a.sunset),
})),
} as SpaFormattedResultWithAngles;
}
return formatted;
}

71
src/types.ts Normal file
View file

@ -0,0 +1,71 @@
/** SPA function codes. Control which outputs are computed. */
export const SPA_ZA = 0 as const;
export const SPA_ZA_INC = 1 as const;
export const SPA_ZA_RTS = 2 as const;
export const SPA_ALL = 3 as const;
export type SpaFunctionCode = typeof SPA_ZA | typeof SPA_ZA_INC | typeof SPA_ZA_RTS | typeof SPA_ALL;
export interface SpaOptions {
/** Observer elevation in meters above sea level. Default: 0. */
elevation?: number;
/** Atmospheric pressure in millibars. Default: 1013. */
pressure?: number;
/** Temperature in degrees Celsius. Default: 15. */
temperature?: number;
/** UT1-UTC correction in seconds. Default: 0. */
delta_ut1?: number;
/** TT-UTC difference in seconds. Default: 67. */
delta_t?: number;
/** Surface slope in degrees from horizontal. Default: 0. */
slope?: number;
/** Surface azimuth rotation in degrees from south. Default: 0. */
azm_rotation?: number;
/** Atmospheric refraction at sunrise/sunset in degrees. Default: 0.5667. */
atmos_refract?: number;
/** SPA function code. Default: SPA_ZA_RTS (2). */
function?: SpaFunctionCode;
}
export interface SpaResult {
/** Topocentric zenith angle in degrees. */
zenith: number;
/** Topocentric azimuth angle, eastward from north (navigational convention), in degrees. */
azimuth: number;
/** Local sunrise time as fractional hours. */
sunrise: number;
/** Local sun transit time (solar noon) as fractional hours. */
solarNoon: number;
/** Local sunset time as fractional hours. */
sunset: number;
}
export interface SpaFormattedResult {
/** Topocentric zenith angle in degrees. */
zenith: number;
/** Topocentric azimuth angle, eastward from north (navigational convention), in degrees. */
azimuth: number;
/** Local sunrise time as HH:MM:SS string. "N/A" during polar day/night. */
sunrise: string;
/** Local sun transit time as HH:MM:SS string. "N/A" during polar day/night. */
solarNoon: string;
/** Local sunset time as HH:MM:SS string. "N/A" during polar day/night. */
sunset: string;
}
export interface SpaAnglesResult {
/** Sunrise time for this custom zenith angle. */
sunrise: number;
/** Sunset time for this custom zenith angle. */
sunset: number;
}
export interface SpaResultWithAngles extends SpaResult {
/** Custom angle results, one per angle in the input array. */
angles: SpaAnglesResult[];
}
export interface SpaFormattedResultWithAngles extends SpaFormattedResult {
/** Custom angle results with formatted times. */
angles: Array<{ sunrise: string; sunset: string }>;
}

76
test-cjs.cjs Normal file
View file

@ -0,0 +1,76 @@
'use strict';
const assert = require('node:assert/strict');
const {
getSpa,
calcSpa,
formatTime,
SPA_ZA,
SPA_ZA_RTS,
SPA_ALL,
} = require('./dist/index.cjs');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}... PASS`);
passed++;
} catch (err) {
console.error(`${name}... FAIL: ${err.message}`);
failed++;
}
}
// ─── Exports ─────────────────────────────────────────────────────────────────
test('CJS: getSpa is a function', () => assert.equal(typeof getSpa, 'function'));
test('CJS: calcSpa is a function', () => assert.equal(typeof calcSpa, 'function'));
test('CJS: formatTime is a function', () => assert.equal(typeof formatTime, 'function'));
test('CJS: SPA_ZA === 0', () => assert.equal(SPA_ZA, 0));
test('CJS: SPA_ZA_RTS === 2', () => assert.equal(SPA_ZA_RTS, 2));
test('CJS: SPA_ALL === 3', () => assert.equal(SPA_ALL, 3));
// ─── Correctness ──────────────────────────────────────────────────────────────
const nyc = calcSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10, pressure: 1013, temperature: 20 },
);
test('CJS: NYC sunrise = 05:25:03', () => assert.equal(nyc.sunrise, '05:25:03'));
test('CJS: NYC solarNoon = 12:57:56', () => assert.equal(nyc.solarNoon, '12:57:56'));
test('CJS: NYC sunset = 20:30:35', () => assert.equal(nyc.sunset, '20:30:35'));
test('CJS: zenith is number', () => assert.equal(typeof nyc.zenith, 'number'));
test('CJS: azimuth 0-360', () => assert.ok(nyc.azimuth >= 0 && nyc.azimuth <= 360));
// ─── formatTime ───────────────────────────────────────────────────────────────
test('CJS formatTime: noon', () => assert.equal(formatTime(12), '12:00:00'));
test('CJS formatTime: negative = N/A', () => assert.equal(formatTime(-1), 'N/A'));
test('CJS formatTime: NaN = N/A', () => assert.equal(formatTime(NaN), 'N/A'));
// ─── Custom angles ────────────────────────────────────────────────────────────
const twilight = getSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10 },
[96, 102, 108],
);
test('CJS angles: has angles array', () => assert.ok(Array.isArray(twilight.angles)));
test('CJS angles: three entries', () => assert.equal(twilight.angles.length, 3));
test('CJS angles: civil < standard sunrise', () => {
const standard = getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, { elevation: 10 });
assert.ok(twilight.angles[0].sunrise < standard.sunrise);
});
// ─── Summary ─────────────────────────────────────────────────────────────────
console.log('---');
console.log(`${passed + failed} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

41
test.js
View file

@ -1,41 +0,0 @@
// tested.js
const { getSpa, calcSpa } = require('./index');
// Use current date/time
const date = new Date();
console.log(`Current Date: ${date.toString()}\n`);
/*
// Example: New York with minimum params
const city = "New York";
const lat = 40.7128;
const lng = -74.0060;
const tz = -5; // Eastern Standard Time
const params = null;
const angles = [];
*/
// Jakarta with all params
const city = "Jakarta";
const lat = -6.2088;
const lng = 106.8456;
const tz = 7; // UTC+7
const params = {
elevation: 18, // meters
temperature: 26.56, // °C
pressure: 1017 // mbar
};
const angles = [63.435]; // example custom zenith angle
console.log(`Test: ${city} (lat: ${lat}, lng: ${lng}, UTC${tz >= 0 ? '+' : ''}${tz})\n`);
// Raw fractional outputs
const raw = getSpa(date, lat, lng, tz, params, angles);
// Formatted HH:MM:SS outputs
const formatted = calcSpa(date, lat, lng, tz, params, angles);
console.log('getSpa (raw fractional values):');
console.log(JSON.stringify(raw, null, 2), '\n');
console.log('calcSpa (formatted HH:MM:SS):');
console.log(JSON.stringify(formatted, null, 2), '\n');

257
test.mjs Normal file
View file

@ -0,0 +1,257 @@
import assert from 'node:assert/strict';
import {
getSpa,
calcSpa,
formatTime,
SPA_ZA,
SPA_ZA_INC,
SPA_ZA_RTS,
SPA_ALL,
} from './dist/index.mjs';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}... PASS`);
passed++;
} catch (err) {
console.error(`${name}... FAIL: ${err.message}`);
failed++;
}
}
function close(actual, expected, tolerance = 0.001, label = '') {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
// ─── Exports ─────────────────────────────────────────────────────────────────
test('exports: getSpa is a function', () => assert.equal(typeof getSpa, 'function'));
test('exports: calcSpa is a function', () => assert.equal(typeof calcSpa, 'function'));
test('exports: formatTime is a function', () => assert.equal(typeof formatTime, 'function'));
test('exports: SPA_ZA === 0', () => assert.equal(SPA_ZA, 0));
test('exports: SPA_ZA_INC === 1', () => assert.equal(SPA_ZA_INC, 1));
test('exports: SPA_ZA_RTS === 2', () => assert.equal(SPA_ZA_RTS, 2));
test('exports: SPA_ALL === 3', () => assert.equal(SPA_ALL, 3));
// ─── formatTime ──────────────────────────────────────────────────────────────
test('formatTime: zero', () => assert.equal(formatTime(0), '00:00:00'));
test('formatTime: noon', () => assert.equal(formatTime(12), '12:00:00'));
test('formatTime: 23:59:59', () => assert.equal(formatTime(23 + 59/60 + 59/3600), '23:59:59'));
test('formatTime: fractional rounding', () => assert.equal(formatTime(5.4174893), '05:25:03'));
test('formatTime: negative returns N/A', () => assert.equal(formatTime(-1), 'N/A'));
test('formatTime: NaN returns N/A', () => assert.equal(formatTime(NaN), 'N/A'));
test('formatTime: Infinity returns N/A', () => assert.equal(formatTime(Infinity), 'N/A'));
test('formatTime: midnight wrap (24h)', () => {
// A time of exactly 24.0 rounds to 00:00:00
assert.equal(formatTime(24), '00:00:00');
});
// ─── getSpa: New York summer solstice (validated against NREL C reference) ───
const NYC_SUMMER = getSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10, pressure: 1013, temperature: 20 },
);
test('NYC summer: sunrise ~05:25:03', () => close(NYC_SUMMER.sunrise, 5.417, 0.001, 'sunrise'));
test('NYC summer: solarNoon ~12:57:56', () => close(NYC_SUMMER.solarNoon, 12.965, 0.001, 'solarNoon'));
test('NYC summer: sunset ~20:30:35', () => close(NYC_SUMMER.sunset, 20.509, 0.001, 'sunset'));
test('NYC summer: zenith is a number', () => assert.equal(typeof NYC_SUMMER.zenith, 'number'));
test('NYC summer: azimuth 0-360', () => {
assert.ok(NYC_SUMMER.azimuth >= 0 && NYC_SUMMER.azimuth <= 360, `azimuth ${NYC_SUMMER.azimuth}`);
});
// ─── getSpa: New York winter solstice ────────────────────────────────────────
const NYC_WINTER = getSpa(
new Date('2025-12-21T00:00:00Z'),
40.7128, -74.006, -5,
{ elevation: 10, pressure: 1013, temperature: 5 },
);
test('NYC winter: sunrise ~07:16:41', () => close(NYC_WINTER.sunrise, 7.278, 0.001, 'sunrise'));
test('NYC winter: solarNoon ~11:54:19', () => close(NYC_WINTER.solarNoon, 11.905, 0.001, 'solarNoon'));
test('NYC winter: sunset ~16:31:56', () => close(NYC_WINTER.sunset, 16.532, 0.001, 'sunset'));
// ─── getSpa: London summer ───────────────────────────────────────────────────
const LONDON_SUMMER = getSpa(
new Date('2025-06-21T00:00:00Z'),
51.5074, -0.1278, 1,
{ elevation: 11, pressure: 1013, temperature: 18 },
);
test('London summer: sunrise ~04:43:07', () => close(LONDON_SUMMER.sunrise, 4.718, 0.001, 'sunrise'));
test('London summer: sunset ~21:21:37', () => close(LONDON_SUMMER.sunset, 21.360, 0.001, 'sunset'));
// ─── getSpa: Tokyo ───────────────────────────────────────────────────────────
const TOKYO_SUMMER = getSpa(
new Date('2025-06-21T00:00:00Z'),
35.6895, 139.6917, 9,
{ elevation: 40, pressure: 1013, temperature: 22 },
);
test('Tokyo summer: sunrise ~04:25:52', () => close(TOKYO_SUMMER.sunrise, 4.431, 0.001, 'sunrise'));
test('Tokyo summer: sunset ~19:00:22', () => close(TOKYO_SUMMER.sunset, 19.006, 0.001, 'sunset'));
// ─── getSpa: Sydney winter (southern hemisphere) ─────────────────────────────
const SYDNEY_WINTER = getSpa(
new Date('2025-06-21T00:00:00Z'),
-33.8688, 151.2093, 10,
{ elevation: 58, pressure: 1013, temperature: 15 },
);
test('Sydney winter: sunrise ~07:00:12', () => close(SYDNEY_WINTER.sunrise, 7.003, 0.001, 'sunrise'));
test('Sydney winter: sunset ~16:53:52', () => close(SYDNEY_WINTER.sunset, 16.898, 0.001, 'sunset'));
// ─── getSpa: Quito (equator, equinox) ────────────────────────────────────────
const QUITO_EQUINOX = getSpa(
new Date('2025-03-20T00:00:00Z'),
-0.1807, -78.4678, -5,
{ elevation: 2850, pressure: 789, temperature: 14 },
);
test('Quito equinox: sunrise ~06:17:54', () => close(QUITO_EQUINOX.sunrise, 6.298, 0.001, 'sunrise'));
test('Quito equinox: sunset ~18:24:25', () => close(QUITO_EQUINOX.sunset, 18.407, 0.001, 'sunset'));
// ─── getSpa: polar night (Tromso, arctic winter) ─────────────────────────────
const TROMSO_POLAR = getSpa(
new Date('2025-12-21T00:00:00Z'),
69.6492, 18.9553, 1,
{ elevation: 0, pressure: 1013, temperature: -2 },
);
test('Tromso polar: sunrise is NaN (polar night)', () => assert.ok(isNaN(TROMSO_POLAR.sunrise) || TROMSO_POLAR.sunrise < 0 || TROMSO_POLAR.sunrise > 24));
test('Tromso polar: zenith > 90 (sun below horizon)', () => assert.ok(TROMSO_POLAR.zenith > 90));
// ─── calcSpa: formatted output ───────────────────────────────────────────────
const NYC_FMT = calcSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10, pressure: 1013, temperature: 20 },
);
test('calcSpa: sunrise is string', () => assert.equal(typeof NYC_FMT.sunrise, 'string'));
test('calcSpa: solarNoon is string', () => assert.equal(typeof NYC_FMT.solarNoon, 'string'));
test('calcSpa: sunset is string', () => assert.equal(typeof NYC_FMT.sunset, 'string'));
test('calcSpa: sunrise format HH:MM:SS', () => assert.match(NYC_FMT.sunrise, /^\d{2}:\d{2}:\d{2}$/));
test('calcSpa: NYC summer sunrise = 05:25:03', () => assert.equal(NYC_FMT.sunrise, '05:25:03'));
test('calcSpa: NYC summer noon = 12:57:56', () => assert.equal(NYC_FMT.solarNoon, '12:57:56'));
test('calcSpa: NYC summer sunset = 20:30:35', () => assert.equal(NYC_FMT.sunset, '20:30:35'));
test('calcSpa: zenith is number', () => assert.equal(typeof NYC_FMT.zenith, 'number'));
// ─── Custom angles (twilight) ────────────────────────────────────────────────
const NYC_TWILIGHT = getSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10 },
[96, 102, 108],
);
test('custom angles: result has angles array', () => assert.ok(Array.isArray(NYC_TWILIGHT.angles)));
test('custom angles: three entries', () => assert.equal(NYC_TWILIGHT.angles.length, 3));
test('custom angles: civil twilight rise < standard rise', () => {
assert.ok(NYC_TWILIGHT.angles[0].sunrise < NYC_SUMMER.sunrise, 'civil rises before standard');
});
test('custom angles: nautical rise < civil rise', () => {
assert.ok(NYC_TWILIGHT.angles[1].sunrise < NYC_TWILIGHT.angles[0].sunrise);
});
test('custom angles: astronomical rise < nautical rise', () => {
assert.ok(NYC_TWILIGHT.angles[2].sunrise < NYC_TWILIGHT.angles[1].sunrise);
});
const NYC_TWILIGHT_FMT = calcSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ elevation: 10 },
[96, 102, 108],
);
test('calcSpa angles: formatted sunrise is string', () => {
assert.equal(typeof NYC_TWILIGHT_FMT.angles[0].sunrise, 'string');
});
test('calcSpa angles: HH:MM:SS format', () => {
assert.match(NYC_TWILIGHT_FMT.angles[0].sunrise, /^\d{2}:\d{2}:\d{2}$/);
});
// ─── Input validation ─────────────────────────────────────────────────────────
test('validation: invalid Date throws TypeError', () => {
assert.throws(() => getSpa(new Date('invalid'), 40, -74, 0), TypeError);
});
test('validation: non-number latitude throws TypeError', () => {
assert.throws(() => getSpa(new Date(), 'bad', -74, 0), TypeError);
});
test('validation: latitude > 90 throws RangeError', () => {
assert.throws(() => getSpa(new Date(), 91, -74, 0), RangeError);
});
test('validation: latitude < -90 throws RangeError', () => {
assert.throws(() => getSpa(new Date(), -91, -74, 0), RangeError);
});
test('validation: longitude > 180 throws RangeError', () => {
assert.throws(() => getSpa(new Date(), 40, 181, 0), RangeError);
});
test('validation: longitude < -180 throws RangeError', () => {
assert.throws(() => getSpa(new Date(), 40, -181, 0), RangeError);
});
// ─── Defaults ────────────────────────────────────────────────────────────────
test('defaults: tz=null uses 0', () => {
const r = getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, null);
assert.equal(typeof r.zenith, 'number');
});
test('defaults: no options arg', () => {
const r = getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006);
assert.equal(typeof r.sunrise, 'number');
});
test('defaults: empty angles array returns no angles key', () => {
const r = getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, {}, []);
assert.ok(!('angles' in r));
});
// ─── Cape Town (southern hemisphere, summer) ──────────────────────────────────
const CAPE_TOWN = getSpa(
new Date('2025-12-21T00:00:00Z'),
-33.9249, 18.4241, 2,
{ elevation: 25, pressure: 1013, temperature: 18 },
);
test('Cape Town summer: sunrise ~05:31:55', () => close(CAPE_TOWN.sunrise, 5.532, 0.001, 'sunrise'));
test('Cape Town summer: sunset ~19:57:01', () => close(CAPE_TOWN.sunset, 19.950, 0.001, 'sunset'));
// ─── Reykjavik (midnight sun) ─────────────────────────────────────────────────
const REYKJAVIK = getSpa(
new Date('2025-06-21T00:00:00Z'),
64.1466, -21.9426, 0,
{ elevation: 0, pressure: 1013, temperature: 10 },
);
test('Reykjavik midsummer: sunrise ~02:55', () => close(REYKJAVIK.sunrise, 2.919, 0.001, 'sunrise'));
// Sunset wraps past midnight, so the raw value > 24 or suntransit is reliable
test('Reykjavik midsummer: solarNoon in range', () => {
assert.ok(REYKJAVIK.solarNoon > 12 && REYKJAVIK.solarNoon < 15);
});
// ─── Summary ─────────────────────────────────────────────────────────────────
console.log('---');
console.log(`${passed + failed} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

20
tsconfig.json Normal file
View file

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

30
tsup.config.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
outDir: 'dist',
splitting: false,
sourcemap: true,
target: 'es2020',
platform: 'node',
outExtension({ format }) {
return {
js: format === 'cjs' ? '.cjs' : '.mjs',
};
},
banner({ format }) {
if (format === 'esm') {
return {
js: `import { createRequire as __cr } from 'node:module';\nconst __require = __cr(import.meta.url);`,
};
}
return {};
},
// The core SPA algorithm lives in lib/spa.js (the JS port of the NREL C source).
// It is checked into git and ships with the package. We load it at runtime so it
// is kept external (not bundled) and resolves via the createRequire shim in ESM.
external: ['../lib/spa.js'],
});