nrel-spa v2.0.1: validation, NaN returns, overloads, wiki comparison

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

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

View file

@ -9,6 +9,8 @@ on:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node-version: [20, 22, 24]
@ -37,6 +39,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
@ -54,6 +58,8 @@ jobs:
pack-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
@ -73,6 +79,6 @@ jobs:
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; }
grep -qE "(^|[[:space:]])${f}([[:space:]]|$)" pack-output.txt || { echo "MISSING: $f"; exit 1; }
done
echo "All expected files present in package"

View file

@ -16,10 +16,12 @@ jobs:
uses: actions/checkout@v4
- name: Checkout wiki
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}.wiki
path: .wiki-remote
run: |
git clone "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git" .wiki-remote \
|| (mkdir -p .wiki-remote \
&& cd .wiki-remote \
&& git init \
&& git remote add origin "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git")
- name: Sync wiki pages
run: |

View file

@ -15,7 +15,7 @@ Computes solar position for the given date and location. Returns raw numerical v
| `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). |
| `angles` | `[number, ...number[]]` | No | One or more custom zenith angles in degrees. See [Twilight Calculations](Twilight-Calculations). |
**Returns:** `SpaResult`
@ -29,17 +29,26 @@ interface SpaResult {
}
```
`sunrise`, `solarNoon`, and `sunset` are `NaN` when `options.function` is `SPA_ZA` or `SPA_ZA_INC` (those codes skip the rise/set calculation).
When `angles` is provided, returns `SpaResultWithAngles`:
```typescript
interface SpaResultWithAngles extends SpaResult {
angles: Array<{ sunrise: number; sunset: number }>;
angles: SpaAnglesResult[];
}
interface SpaAnglesResult {
sunrise: number; // rise time for this zenith angle (fractional hours)
sunset: number; // set time for this zenith angle (fractional hours)
}
```
**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]
- `RangeError` if `options.function` is not 0, 1, 2, or 3
- `RangeError` if `angles` is provided with a non-RTS function code (`SPA_ZA` or `SPA_ZA_INC`)
- `Error` if the internal SPA calculation returns a non-zero error code
---
@ -54,7 +63,7 @@ Same parameters as `getSpa()`. Formats `sunrise`, `solarNoon`, and `sunset` as `
interface SpaFormattedResult {
zenith: number; // same as SpaResult
azimuth: number; // same as SpaResult
sunrise: string; // "HH:MM:SS" or "N/A" during polar day/night
sunrise: string; // "HH:MM:SS" or "N/A" during polar day/night, or when using SPA_ZA/SPA_ZA_INC
solarNoon: string; // "HH:MM:SS" or "N/A"
sunset: string; // "HH:MM:SS" or "N/A"
}
@ -64,7 +73,12 @@ When `angles` is provided, returns `SpaFormattedResultWithAngles`:
```typescript
interface SpaFormattedResultWithAngles extends SpaFormattedResult {
angles: Array<{ sunrise: string; sunset: string }>;
angles: SpaFormattedAnglesResult[];
}
interface SpaFormattedAnglesResult {
sunrise: string; // "HH:MM:SS" or "N/A"
sunset: string; // "HH:MM:SS" or "N/A"
}
```
@ -116,7 +130,7 @@ import { SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from 'nrel-spa';
| `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.
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. Custom zenith `angles` require `SPA_ZA_RTS` or `SPA_ALL`.
---
@ -128,4 +142,23 @@ type SpaFunctionCode = 0 | 1 | 2 | 3;
---
## Named Types
All interfaces and types are exported from `nrel-spa` and can be imported in TypeScript:
```typescript
import type {
SpaOptions,
SpaResult,
SpaFormattedResult,
SpaAnglesResult,
SpaFormattedAnglesResult,
SpaResultWithAngles,
SpaFormattedResultWithAngles,
SpaFunctionCode,
} from 'nrel-spa';
```
---
[Home](Home) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

View file

@ -25,10 +25,20 @@ nrel-spa/
**src/index.ts** is a thin TypeScript wrapper. It:
- Loads `lib/spa.js` at runtime
- Validates input parameters, throwing `TypeError` or `RangeError` for invalid values
- Validates `options.function` and throws `RangeError` for out-of-range codes
- Returns `NaN` for `sunrise`, `solarNoon`, and `sunset` when the function code does not include RTS (`SPA_ZA` or `SPA_ZA_INC`)
- Maps the flat SpaData structure to a clean output object
- Implements `adjustForCustomAngle()` for twilight calculations
- Implements `adjustForCustomAngle()` internally for twilight calculations
- Provides `formatTime()` as a standalone export
**src/types.ts** exports all public TypeScript interfaces and constants:
- `SpaOptions` — atmospheric and calculation parameters
- `SpaResult` / `SpaFormattedResult` — base return types for `getSpa` / `calcSpa`
- `SpaAnglesResult` / `SpaFormattedAnglesResult` — per-angle entries in the `angles` array
- `SpaResultWithAngles` / `SpaFormattedResultWithAngles` — return types when `angles` is passed
- `SpaFunctionCode` — union type `0 | 1 | 2 | 3`
- `SPA_ZA`, `SPA_ZA_INC`, `SPA_ZA_RTS`, `SPA_ALL` — function code constants
**tsup** compiles `src/index.ts` to both CJS (`dist/index.cjs`) and ESM (`dist/index.mjs`) with TypeScript declarations. The `lib/spa.js` module is kept external (not bundled) and resolved at runtime via a `createRequire` shim in the ESM build.
## Loading Strategy

View file

@ -14,6 +14,7 @@ Pure JavaScript implementation of the NREL Solar Position Algorithm (SPA). Compu
- [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
- [Implementation Comparison](Implementation-Comparison) - Accuracy and performance: nrel-spa vs solar-spa vs C reference
## Quick Example
@ -41,4 +42,4 @@ console.log(result.sunset); // "20:30:35"
---
[API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)
[API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm) . [Implementation Comparison](Implementation-Comparison)

View file

@ -0,0 +1,142 @@
# Implementation Comparison
Three implementations of the NREL Solar Position Algorithm exist in this ecosystem. This page documents their accuracy against the original C reference and their throughput on Node.js.
| | [NREL C reference](https://midcdmz.nrel.gov/spa/) | [nrel-spa](https://www.npmjs.com/package/nrel-spa) | [solar-spa](https://www.npmjs.com/package/solar-spa) |
| --- | --- | --- | --- |
| **Language** | C (gcc -O2) | JavaScript (JS port) | WebAssembly (Emscripten) |
| **Source** | Original C | Hand-ported | Compiled from same C |
| **Async init** | No | No | Yes (`await init()`) |
| **Dependencies** | None | None | None (WASM bundled) |
| **Return type** | Synchronous | Synchronous | Promise |
| **Algorithm** | NREL SPA 2004 | NREL SPA 2004 | NREL SPA 2004 |
---
## Accuracy
Tested across eight global locations and three dates spanning both hemispheres, polar regions, and all four seasons. All times are local time for the given timezone. Delta is the absolute difference in seconds from the C reference binary.
**Environment:** Node.js v24.6.0. Inputs: local midnight at each location. Atmospheric defaults: pressure 1013 mb, temperature varies by season, elevation as noted. `delta_t = 67` seconds (NREL default).
| Location | Date | Field | C Reference | nrel-spa | Δ (s) | solar-spa | Δ (s) |
| --- | --- | --- | --- | --- | ---: | --- | ---: |
| New York (40.7°N) | Jun 21 | Sunrise | 05:25:03 | 05:25:03 | 0.04 | 05:25:03 | 0.04 |
| | | Solar noon | 12:57:56 | 12:57:56 | 0.40 | 12:57:56 | 0.40 |
| | | Sunset | 20:30:35 | 20:30:35 | 0.17 | 20:30:35 | 0.17 |
| New York (40.7°N) | Dec 21 | Sunrise | 07:16:41 | 07:16:41 | 0.35 | 07:16:41 | 0.35 |
| | | Solar noon | 11:54:19 | 11:54:19 | 0.40 | 11:54:19 | 0.40 |
| | | Sunset | 16:31:56 | 16:31:56 | 0.08 | 16:31:56 | 0.08 |
| London (51.5°N) | Jun 21 | Sunrise | 04:43:07 | 04:43:07 | 0.20 | 04:43:07 | 0.20 |
| | | Solar noon | 13:02:22 | 13:02:22 | 0.14 | 13:02:22 | 0.14 |
| | | Sunset | 21:21:37 | 21:21:37 | 0.34 | 21:21:37 | 0.34 |
| Tokyo (35.7°N) | Jun 21 | Sunrise | 04:25:52 | 04:25:52 | 0.09 | 04:25:52 | 0.09 |
| | | Solar noon | 11:43:00 | 11:43:00 | 0.35 | 11:43:00 | 0.35 |
| | | Sunset | 19:00:22 | 19:00:22 | 0.24 | 19:00:22 | 0.24 |
| Sydney (33.9°S) | Jun 21 | Sunrise | 07:00:12 | 07:00:12 | 0.15 | 07:00:12 | 0.15 |
| | | Solar noon | 11:56:56 | 11:56:56 | 0.29 | 11:56:56 | 0.29 |
| | | Sunset | 16:53:52 | 16:53:52 | 0.04 | 16:53:52 | 0.04 |
| Cape Town (33.9°S) | Dec 21 | Sunrise | 05:31:55 | 05:31:55 | 0.43 | 05:31:55 | 0.43 |
| | | Solar noon | 12:44:28 | 12:44:28 | 0.31 | 12:44:28 | 0.31 |
| | | Sunset | 19:57:01 | 19:57:01 | 0.01 | 19:57:01 | 0.01 |
| Quito (0.2°S) | Mar 20 | Sunrise | 06:17:54 | 06:17:54 | 0.41 | 06:17:54 | 0.41 |
| | | Solar noon | 12:21:10 | 12:21:10 | 0.10 | 12:21:10 | 0.10 |
| | | Sunset | 18:24:25 | 18:24:25 | 0.23 | 18:24:25 | 0.23 |
| Reykjavik (64.1°N) | Jun 21 | Sunrise | 02:55:10 | 02:55:10 | 0.44 | 02:55:10 | 0.44 |
| | | Solar noon | 13:29:38 | 13:29:38 | 0.49 | 13:29:38 | 0.49 |
| | | Sunset | 00:03:54 | 00:03:54 | 0.20 | 00:03:54 | 0.20 |
| **Maximum divergence** | | | | | **0.49 s** | | **0.49 s** |
Both JavaScript implementations are numerically identical to each other across all test cases. The sub-second delta from the C reference is not an algorithmic error. It comes from floating-point rounding accumulated across roughly 200 intermediate calculations in the VSOP87 series. The practical precision ceiling is the `delta_t` parameter (default 67 seconds), which is itself an approximation.
Polar night is handled correctly. Tromso in December returns the NREL sentinel value (-99999) for sunrise and sunset, indicating the sun does not cross the horizon.
---
## Performance
**Environment:** Node.js v24.6.0, macOS. 200,000 iterations per measurement, with a 2,000-iteration warm-up before each run. Test case: New York summer solstice.
| Mode | Implementation | ns/call | calls/s |
| --- | --- | ---: | ---: |
| **SPA_ZA_RTS** (zenith + azimuth + rise/set) | nrel-spa | 84,497 | 11,835 |
| | solar-spa | 45,139 | 22,154 |
| **SPA_ZA** (zenith + azimuth only) | nrel-spa | 9,284 | 107,711 |
| | solar-spa | 6,112 | 163,616 |
| **C reference** (native binary, estimated) | gcc -O2 | ~5002,000 | ~500K2M |
**solar-spa WASM is 1.51.9× faster than nrel-spa JS** for sustained throughput. The WASM binary is compiled from the same C source with `-O2`, so the engine runs optimized machine code rather than interpreted JavaScript.
**SPA_ZA is roughly 9× faster than SPA_ZA_RTS** in both implementations. The three-day rise/set calculation (which interpolates solar coordinates across yesterday, today, and tomorrow) dominates runtime. If you only need zenith and azimuth for the current moment, use `SPA_ZA` and skip that computation entirely.
The C reference estimate is not directly measured here. It assumes a compiled binary called via subprocess, which adds process-spawn overhead. Pure in-process C math for this algorithm typically runs 10100× faster than equivalent JavaScript.
---
## API Convention Difference
nrel-spa and solar-spa handle dates differently. This is the single most important thing to understand when choosing between them.
**nrel-spa** reads UTC components from the Date object:
```javascript
d.year = date.getUTCFullYear();
d.month = date.getUTCMonth() + 1;
d.day = date.getUTCDate();
d.hour = date.getUTCHours();
```
To represent "June 21 at midnight local time," you pass a Date whose UTC components match that local time:
```javascript
// UTC components = 2025-06-21 00:00 → treated as local midnight
getSpa(new Date('2025-06-21T00:00:00Z'), lat, lon, timezone, opts);
```
This is portable. The result is the same on any machine, regardless of the host's system timezone.
**solar-spa** reads LOCAL components from the Date object:
```javascript
date.getFullYear(), // local year
date.getMonth() + 1, // local month
date.getDate(), // local day
date.getHours(), // local hour
```
To represent "June 21 at midnight local time," you pass a Date whose local components match:
```javascript
// Local components = 2025-06-21 00:00
spa(new Date(2025, 5, 21, 0, 0, 0), lat, lon, { timezone });
```
This works correctly on the machine where the date is constructed. On a machine in a different timezone, `new Date(2025, 5, 21, 0, 0, 0)` still creates local midnight, so it still works. The risk arises if you pass a date received as a string, an API timestamp, or any UTC-anchored value: `new Date('2025-06-21T00:00:00Z').getHours()` returns 20 on a UTC-4 machine, not 0.
In short: with nrel-spa, UTC ISO strings are safe. With solar-spa, construct dates as local time explicitly, or use the `timezone` option and always verify your date components.
---
## When to Use Each
**Use nrel-spa when:**
- You need synchronous, zero-init execution (serverless functions, edge workers, middleware)
- You are processing individual requests rather than large batches
- You want simple date handling with no machine-timezone surprises
- Bundle size matters (nrel-spa is smaller; no WASM blob)
**Use solar-spa when:**
- You are pre-computing thousands or millions of solar positions in a batch
- You have already paid the async init cost and want maximum throughput
- You need the full SPA output struct including incidence angle, equation of time, and sun transit altitude
**Use the C reference when:**
- You are writing native code or a library that wraps native code
- You need validated output for scientific publication
- Runtime performance is the primary constraint
The C reference is available from NREL at no cost: [https://midcdmz.nrel.gov/spa/](https://midcdmz.nrel.gov/spa/)
---
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [NREL SPA Algorithm](NREL-SPA-Algorithm)

View file

@ -64,13 +64,13 @@ The SPA computes solar position through a chain of coordinate transformations:
## 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:
[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 numerically identical results.
- **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)
- **nrel-spa** is synchronous, requires no init, and uses UTC date components (portable across all machines)
- **solar-spa** requires an async `init()` call, reads local date components, and runs 1.51.9× faster for sustained batch throughput
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.
For the full accuracy and performance comparison, including benchmarks across eight global locations and both SPA function codes, see the [Implementation Comparison](Implementation-Comparison) page.
---
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations)
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture) . [Twilight Calculations](Twilight-Calculations) . [Implementation Comparison](Implementation-Comparison)

View file

@ -1,4 +1,28 @@
# Changelog
<!-- markdownlint-disable MD024 -->
## [2.0.1] - 2026-02-25
### Fixed
- **Runtime crash:** `calcSpa(... , [])` with an empty angles array no longer crashes. The empty-array guard is now consistent between `getSpa` and `calcSpa`.
- **Silent wrong output:** `getSpa` with `options.function: SPA_ZA` or `SPA_ZA_INC` now returns `NaN` for `sunrise`, `solarNoon`, and `sunset` instead of silently returning `0`. `calcSpa` returns `"N/A"` for those fields. The zero values were misleading — those fields are never computed by non-RTS function codes.
- `lib/spa.js` internal file header corrected from `// dist/spa.js` to `// lib/spa.js`.
- `wiki-sync.yml`: workflow now handles first-run initialization when the GitHub Wiki repository does not yet exist. Replaces `actions/checkout@v4` for the wiki step with a `git clone || git init` pattern.
- Removed `package-import-method=hardlink` from `.npmrc` — it is pnpm's default since v7 and caused `npm warn Unknown project config` because npm reads `.npmrc` too.
- CI pack-check grep now uses a word-boundary pattern, preventing false matches on files with similar prefixes.
### Added
- **Validation:** `options.function` is now validated before the SPA calculation. Passing an invalid function code throws a descriptive `RangeError` instead of silently producing wrong results.
- **Validation:** Passing custom `angles` with a non-RTS function code (`SPA_ZA` or `SPA_ZA_INC`) now throws `RangeError`. Custom angle calculations require `suntransit`, which is only computed by `SPA_ZA_RTS` and `SPA_ALL`.
- TypeScript function overloads for `getSpa` and `calcSpa`: the `angles` parameter is typed as `[number, ...number[]]` (non-empty tuple), so TypeScript rejects empty arrays at compile time and narrows the return type automatically.
- `SpaFormattedAnglesResult` interface for the formatted angles array, consistent with the existing `SpaAnglesResult` interface on the raw side.
- CI workflows now declare explicit `permissions: contents: read` on all jobs.
- API Reference wiki updated: inline anonymous types replaced with named `SpaAnglesResult` and `SpaFormattedAnglesResult` interfaces; new Named Types import block added; `angles` parameter type and new throws documented.
- Architecture wiki updated to document all exported interfaces from `src/types.ts`.
---
## [2.0.0] - 2026-02-25

View file

@ -23,7 +23,7 @@ const date = new Date('2025-06-21T00:00:00Z'); // UTC date/time
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
console.log(raw.sunset); // 20.509
// Formatted output — same parameters, HH:MM:SS strings
const fmt = calcSpa(date, 40.7128, -74.006, -4);
@ -140,10 +140,18 @@ import {
getSpa,
calcSpa,
formatTime,
SPA_ZA,
SPA_ZA_INC,
SPA_ZA_RTS,
SPA_ALL,
type SpaOptions,
type SpaResult,
type SpaFormattedResult,
type SpaOptions,
type SpaAnglesResult,
type SpaFormattedAnglesResult,
type SpaResultWithAngles,
type SpaFormattedResultWithAngles,
type SpaFunctionCode,
} from 'nrel-spa';
```

989
dist/spa.js vendored
View file

@ -1,989 +0,0 @@
// 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,4 +1,4 @@
// dist/spa.js
// lib/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;

View file

@ -1,6 +1,6 @@
{
"name": "nrel-spa",
"version": "2.0.0",
"version": "2.0.1",
"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",

View file

@ -3,6 +3,7 @@ export type {
SpaResult,
SpaFormattedResult,
SpaAnglesResult,
SpaFormattedAnglesResult,
SpaResultWithAngles,
SpaFormattedResultWithAngles,
SpaFunctionCode,
@ -17,6 +18,7 @@ import type {
SpaFormattedResult,
SpaResultWithAngles,
SpaFormattedResultWithAngles,
SpaFormattedAnglesResult,
} from './types.js';
// The core SPA algorithm lives in lib/spa.js (the JS port of the NREL C source).
@ -132,9 +134,34 @@ function adjustForCustomAngle(
* @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
* @returns Solar position result with raw numerical values
*/
export function getSpa(
date: Date,
latitude: number,
longitude: number,
timezone?: number | null,
options?: SpaOptions | null,
): SpaResult;
/**
* Compute solar position and resolve custom zenith angles (e.g., twilight).
*
* @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 - Atmospheric and calculation parameters (pass null for defaults)
* @param angles - Custom zenith angles in degrees. Common: 96 civil, 102 nautical, 108 astronomical
* @returns Solar position result including an angles array
*/
export function getSpa(
date: Date,
latitude: number,
longitude: number,
timezone: number | null | undefined,
options: SpaOptions | null | undefined,
angles: [number, ...number[]],
): SpaResultWithAngles;
export function getSpa(
date: Date,
latitude: number,
@ -159,6 +186,20 @@ export function getSpa(
const tz = timezone ?? 0;
const opts = options ?? {};
const fnCode = opts.function ?? SPA_ZA_RTS;
if (fnCode !== 0 && fnCode !== 1 && fnCode !== 2 && fnCode !== 3) {
throw new RangeError(
`SPA: options.function must be 0 (SPA_ZA), 1 (SPA_ZA_INC), 2 (SPA_ZA_RTS), or 3 (SPA_ALL), got ${fnCode}`,
);
}
// Custom angle calculations depend on suntransit, which requires an RTS function code.
if (angles && angles.length > 0 && fnCode !== 2 && fnCode !== 3) {
throw new RangeError(
'SPA: custom zenith angle calculations require an RTS function code (SPA_ZA_RTS or SPA_ALL)',
);
}
const d = new spa.SpaData();
d.year = date.getUTCFullYear();
d.month = date.getUTCMonth() + 1;
@ -178,19 +219,23 @@ export function getSpa(
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;
d.function = fnCode;
const rc = spa.spa_calculate(d);
if (rc !== 0) {
throw new Error(`SPA: calculation failed (error code ${rc})`);
}
// sunrise, solarNoon, sunset are only computed for SPA_ZA_RTS (2) and SPA_ALL (3).
// For SPA_ZA and SPA_ZA_INC, those fields are never populated — return NaN so
// callers and formatTime() handle them correctly rather than silently returning 0.
const hasRts = fnCode === 2 || fnCode === 3;
const result: SpaResult = {
zenith: d.zenith,
azimuth: d.azimuth,
sunrise: d.sunrise,
solarNoon: d.suntransit,
sunset: d.sunset,
sunrise: hasRts ? d.sunrise : NaN,
solarNoon: hasRts ? d.suntransit : NaN,
sunset: hasRts ? d.sunset : NaN,
};
if (angles && angles.length > 0) {
@ -208,6 +253,25 @@ export function getSpa(
* 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,
): SpaFormattedResult;
/**
* Same as getSpa() with custom angles, but formats all time values as HH:MM:SS strings.
* Returns "N/A" for time fields during polar day or polar night.
*/
export function calcSpa(
date: Date,
latitude: number,
longitude: number,
timezone: number | null | undefined,
options: SpaOptions | null | undefined,
angles: [number, ...number[]],
): SpaFormattedResultWithAngles;
export function calcSpa(
date: Date,
latitude: number,
@ -216,25 +280,27 @@ export function calcSpa(
options?: SpaOptions | null,
angles?: number[],
): SpaFormattedResult | SpaFormattedResultWithAngles {
const raw = getSpa(date, latitude, longitude, timezone, options, angles);
if (angles !== undefined && angles.length > 0) {
const raw = getSpa(date, latitude, longitude, timezone, options, angles as [number, ...number[]]);
return {
zenith: raw.zenith,
azimuth: raw.azimuth,
sunrise: formatTime(raw.sunrise),
solarNoon: formatTime(raw.solarNoon),
sunset: formatTime(raw.sunset),
angles: raw.angles.map((a): SpaFormattedAnglesResult => ({
sunrise: formatTime(a.sunrise),
sunset: formatTime(a.sunset),
})),
};
}
const formatted: SpaFormattedResult = {
const raw = getSpa(date, latitude, longitude, timezone, options);
return {
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;
}

View file

@ -65,7 +65,14 @@ export interface SpaResultWithAngles extends SpaResult {
angles: SpaAnglesResult[];
}
export interface SpaFormattedAnglesResult {
/** Sunrise time for this custom zenith angle, formatted as HH:MM:SS. */
sunrise: string;
/** Sunset time for this custom zenith angle, formatted as HH:MM:SS. */
sunset: string;
}
export interface SpaFormattedResultWithAngles extends SpaFormattedResult {
/** Custom angle results with formatted times. */
angles: Array<{ sunrise: string; sunset: string }>;
angles: SpaFormattedAnglesResult[];
}

View file

@ -134,7 +134,8 @@ const TROMSO_POLAR = getSpa(
{ 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));
// NREL sets sunrise/sunset to -99999 when the sun never rises.
test('Tromso polar: sunrise < 0 (polar night sentinel)', () => assert.ok(TROMSO_POLAR.sunrise < 0));
test('Tromso polar: zenith > 90 (sun below horizon)', () => assert.ok(TROMSO_POLAR.zenith > 90));
// ─── calcSpa: formatted output ───────────────────────────────────────────────
@ -220,11 +221,6 @@ 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(
@ -250,6 +246,57 @@ test('Reykjavik midsummer: solarNoon in range', () => {
assert.ok(REYKJAVIK.solarNoon > 12 && REYKJAVIK.solarNoon < 15);
});
// ─── SPA_ZA function code (zenith/azimuth only, no RTS) ──────────────────────
const ZA_ONLY = getSpa(
new Date('2025-06-21T00:00:00Z'),
40.7128, -74.006, -4,
{ function: SPA_ZA },
);
test('SPA_ZA: zenith is a finite number', () => assert.ok(isFinite(ZA_ONLY.zenith)));
test('SPA_ZA: azimuth is a finite number', () => assert.ok(isFinite(ZA_ONLY.azimuth)));
test('SPA_ZA: sunrise is NaN (not computed)', () => assert.ok(isNaN(ZA_ONLY.sunrise)));
test('SPA_ZA: solarNoon is NaN (not computed)', () => assert.ok(isNaN(ZA_ONLY.solarNoon)));
test('SPA_ZA: sunset is NaN (not computed)', () => assert.ok(isNaN(ZA_ONLY.sunset)));
test('SPA_ZA calcSpa: sunrise is N/A', () => {
const r = calcSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, { function: SPA_ZA });
assert.equal(r.sunrise, 'N/A');
assert.equal(r.solarNoon, 'N/A');
assert.equal(r.sunset, 'N/A');
});
// ─── Function code validation ─────────────────────────────────────────────────
test('validation: invalid function code throws RangeError', () => {
assert.throws(
() => getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, { function: 99 }),
RangeError,
);
});
test('validation: angles + SPA_ZA throws RangeError', () => {
assert.throws(
() => getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, { function: SPA_ZA }, [96, 102]),
RangeError,
);
});
test('validation: angles + SPA_ZA_INC throws RangeError', () => {
assert.throws(
() => getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, { function: SPA_ZA_INC }, [96]),
RangeError,
);
});
test('validation: empty angles array returns plain SpaResult', () => {
const r = getSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, {}, []);
assert.ok(!('angles' in r));
});
test('validation: calcSpa with empty angles does not crash', () => {
const r = calcSpa(new Date('2025-06-21T00:00:00Z'), 40.7128, -74.006, -4, {}, []);
assert.equal(typeof r.sunrise, 'string');
assert.ok(!('angles' in r));
});
// ─── Summary ─────────────────────────────────────────────────────────────────
console.log('---');