Major fix to core time calculations

This commit is contained in:
acamarata 2025-05-04 07:50:00 -04:00
parent 322342d978
commit b1c7f638ae
8 changed files with 323 additions and 98 deletions

BIN
.DS_Store vendored

Binary file not shown.

6
.gitignore vendored
View file

@ -1,2 +1,8 @@
node_modules/
.env
# Ignore NREL SPA C sources and binaries
/bin/spa
/bin/spa_cli
/bin/*.c
/bin/*.h

View file

@ -14,3 +14,10 @@ All notable changes to this project will be documented in this file.
- 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

88
bin/README.md Normal file
View file

@ -0,0 +1,88 @@
# bin/README.md
This folder contains the C reference executable (`spa_cli`) and the JavaScript test harness (`test.js`) to compare your JS port of the NREL Solar Position Algorithm (SPA) against the original C implementation.
## Prerequisites
* **Node.js** (v14+) installed on your machine.
* **C compiler** (e.g. `gcc`) supporting C99.
## Files
* `spa_cli.c` C CLI wrapper to parse command-line arguments into the SPA structure and output Sunrise, Solar Noon, Sunset in `HH:MM:SS` format.
* `spa.c`, `spa.h` The NREL SPA reference source (download separately).
* `test.js` Node.js script that runs 10 diverse test cases through both `spa_cli` and your JS port (`getSpa`) and prints a side-by-side comparison.
## Setup
1. **Download the NREL SPA source**
```bash
cd bin
curl -O https://midcdmz.nrel.gov/spa/spa.c
curl -O https://midcdmz.nrel.gov/spa/spa.h
```
2. **Copy or create `spa_cli.c`**
Place the `spa_cli.c` file (provided alongside this README) into this folder.
3. **Compile the C executable**
```bash
gcc -std=c99 -O2 -o spa_cli spa.c spa_cli.c -lm
```
* **Do NOT** include `spa_tester.c` for this purpose. The custom `spa_cli.c` handles all required argument parsing and output.
4. **Install Node.js dependencies**
From the project root (one level up):
```bash
npm install
```
This ensures your JS port (`index.js` and `dist/spa.js`) is available.
## Running the Tests
Inside the `bin/` folder, execute:
```bash
node test.js
```
You should see a table with each city/date, and matching Sunrise, Solar Noon, and Sunset times from both the C reference and your JS implementation.
Example output:
```
Location | Date | C Rise | JS Rise | C Noon | JS Noon | C Set | JS Set
-----------------------------------------------------------------------------------
New York Summer | 2025-06-21 | 05:25:03 | 05:25:03 | 12:57:56 | 12:57:56 | 20:30:35 | 20:30:35
...
```
## Notes
* If you update your JS port (`index.js`), rerun `node test.js` to verify that drift remains within a second.
* Ensure `spa_cli` is executable (`chmod +x spa_cli`) and located in the same directory as `test.js`.
## Results
Results from my personal tests when comparing original C version to this JS version is below:
```
% node test.js
Location | Date | C Rise | JS Rise | C Noon | JS Noon | C Set | JS Set
-----------------------------------------------------------------------------------
New York Summer | 2025-06-21 | 05:25:03 | 05:25:03 | 12:57:56 | 12:57:56 | 20:30:35 | 20:30:35
New York Winter | 2025-12-21 | 07:16:41 | 07:16:41 | 11:54:19 | 11:54:19 | 16:31:56 | 16:31:56
London Summer | 2025-06-21 | 04:43:07 | 04:43:07 | 13:02:22 | 13:02:22 | 21:21:37 | 21:21:37
London Winter | 2025-12-21 | 08:03:52 | 08:03:52 | 11:58:42 | 11:58:42 | 15:53:32 | 15:53:32
Tokyo Summer | 2025-06-21 | 04:25:52 | 04:25:52 | 11:43:00 | 11:43:00 | 19:00:22 | 19:00:22
Sydney Winter | 2025-06-21 | 07:00:12 | 07:00:12 | 11:56:56 | 11:56:56 | 16:53:52 | 16:53:52
Reykjavik Mids | 2025-06-21 | 02:55:10 | 02:55:10 | 13:29:38 | 13:29:38 | 00:03:54 | 00:03:54
Cape Town Summer | 2025-12-21 | 05:31:55 | 05:31:55 | 12:44:28 | 12:44:28 | 19:57:01 | 19:57:01
Quito Equinox | 2025-03-20 | 06:17:54 | 06:17:54 | 12:21:10 | 12:21:10 | 18:24:25 | 18:24:25
Tromso Polar | 2025-12-21 | N/A | N/A | N/A | N/A | N/A | N/A
```

88
bin/test.js Normal file
View file

@ -0,0 +1,88 @@
// bin/test.js
// Run 10 diverse test cases through both your JS port and the C reference (spa_cli) and compare.
'use strict';
const { spawnSync } = require('child_process');
const path = require('path');
const { getSpa } = require('../index');
// Constants for C invocation
const DELTA_UT1 = 0.0;
const DELTA_T = 67.0;
const SLOPE = 0.0;
const AZM_ROT = 0.0;
const ATMOS_REF = 0.5667;
// Helper to format fractional hours into "HH:MM:SS" or "N/A"
function formatJS(hour) {
if (typeof hour !== 'number' || isNaN(hour) || hour < 0 || hour >= 24) {
return 'N/A';
}
const total = Math.round(hour * 3600);
const H = Math.floor(total / 3600);
const M = Math.floor((total % 3600) / 60);
const S = total % 60;
return [H, M, S]
.map(v => v.toString().padStart(2, '0'))
.join(':');
}
// Ten diverse test cases
const cases = [
{ label: 'New York Summer', dateUTC: new Date(Date.UTC(2025, 5, 21, 0,0,0)), lat: 40.7128, lon: -74.0060, tz: -4, elevation: 10, pressure: 1013, temperature: 20 },
{ label: 'New York Winter', dateUTC: new Date(Date.UTC(2025,11,21, 0,0,0)), lat: 40.7128, lon: -74.0060, tz: -5, elevation: 10, pressure: 1013, temperature: 5 },
{ label: 'London Summer', dateUTC: new Date(Date.UTC(2025, 5, 21, 0,0,0)), lat: 51.5074, lon: -0.1278, tz: 1, elevation: 11, pressure: 1013, temperature: 18 },
{ label: 'London Winter', dateUTC: new Date(Date.UTC(2025,11,21, 0,0,0)), lat: 51.5074, lon: -0.1278, tz: 0, elevation: 11, pressure: 1013, temperature: 7 },
{ label: 'Tokyo Summer', dateUTC: new Date(Date.UTC(2025, 5, 21, 0,0,0)), lat: 35.6895, lon: 139.6917, tz: 9, elevation: 40, pressure: 1013, temperature: 22 },
{ label: 'Sydney Winter', dateUTC: new Date(Date.UTC(2025, 5, 21, 0,0,0)), lat: -33.8688,lon: 151.2093, tz: 10, elevation: 58, pressure: 1013, temperature: 15 },
{ label: 'Reykjavik Mids', dateUTC: new Date(Date.UTC(2025, 5, 21, 0,0,0)), lat: 64.1466, lon: -21.9426,tz: 0, elevation: 0, pressure: 1013, temperature: 10 },
{ label: 'Cape Town Summer', dateUTC: new Date(Date.UTC(2025,11,21, 0,0,0)), lat: -33.9249,lon: 18.4241, tz: 2, elevation: 25, pressure: 1013, temperature: 18 },
{ label: 'Quito Equinox', dateUTC: new Date(Date.UTC(2025, 2, 20, 0,0,0)), lat: -0.1807, lon: -78.4678,tz: -5, elevation:2850, pressure: 789, temperature: 14 },
{ label: 'Tromso Polar', dateUTC: new Date(Date.UTC(2025,11,21, 0,0,0)), lat: 69.6492, lon: 18.9553,tz: 1, elevation: 0, pressure: 1013, temperature: -2 }
];
// Print header
console.log(
'Location | Date | C Rise | JS Rise | C Noon | JS Noon | C Set | JS Set'
);
console.log('-'.repeat(83));
cases.forEach(({ label, dateUTC, lat, lon, tz, elevation, pressure, temperature }) => {
const dateStr = dateUTC.toISOString().slice(0,10);
// JS calculation
const jsResult = getSpa(dateUTC, lat, lon, tz, { elevation, pressure, temperature });
const jsRise = formatJS(jsResult.sunrise);
const jsNoon = formatJS(jsResult.solarNoon);
const jsSet = formatJS(jsResult.sunset);
// C reference via spa_cli
const cli = path.join(__dirname, 'spa_cli');
const args = [
dateUTC.getUTCFullYear(), dateUTC.getUTCMonth()+1, dateUTC.getUTCDate(),
dateUTC.getUTCHours(), dateUTC.getUTCMinutes(), dateUTC.getUTCSeconds(),
DELTA_UT1, DELTA_T, tz,
lon, lat,
elevation, pressure, temperature,
SLOPE, AZM_ROT, ATMOS_REF
].map(String);
const stdout = spawnSync(cli, args, { encoding: 'utf8' }).stdout || '';
let cRise = 'N/A', cNoon = 'N/A', cSet = 'N/A';
stdout.split(/\r?\n/).forEach(line => {
const match = line.match(/(\d{2}:\d{2}:\d{2})/);
if (match) {
const key = line.toLowerCase();
if (key.includes('sunrise')) cRise = match[1];
else if (key.includes('solar noon')) cNoon = match[1];
else if (key.includes('sunset')) cSet = match[1];
}
});
// Print row
console.log(
`${label.padEnd(16)} | ${dateStr} | ${cRise.padEnd(8)} | ${jsRise.padEnd(8)} | ` +
`${cNoon.padEnd(8)} | ${jsNoon.padEnd(8)} | ${cSet.padEnd(8)} | ${jsSet}`
);
});

175
index.js
View file

@ -1,89 +1,118 @@
// index.js
const spa = require('./dist/spa');
/**
* Convert fractional hours to HH:MM:SS.mmm (rounding total seconds)
*/
function fractalTime(fractionalHour) {
const hours = Math.floor(fractionalHour);
const minutes = Math.floor((fractionalHour - hours) * 60);
const seconds = Math.floor((fractionalHour * 3600) - (hours * 3600) - (minutes * 60));
const ms = Math.floor((fractionalHour * 3600000) - (hours * 3600000) - (minutes * 60000) - (seconds * 1000));
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
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')}`;
}
function adjustForCustomAngle(baseSpaData, zenithAngle) {
let adjustedData = { ...baseSpaData };
const standardZenith = 90.83;
const angleDifference = zenithAngle - standardZenith;
const timeAdjustment = angleDifference / 360 * 24;
adjustedData.sunrise -= timeAdjustment;
adjustedData.sunset += timeAdjustment;
return adjustedData;
/**
* 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
};
}
function getSpa(date, lat, lng, tz = 0, params = null, angles = []) {
let data = new spa.SpaData();
data.year = date.getFullYear();
data.month = date.getMonth() + 1; // JavaScript months are 0-indexed
data.day = date.getDate();
data.hour = date.getHours();
data.minute = date.getMinutes();
data.second = date.getSeconds();
data.longitude = lng;
data.latitude = lat;
data.timezone = tz;
/**
* 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;
// Set default values if optional parameters are not provided
data.elevation = params?.elevation ?? 50;
data.pressure = params?.pressure ?? 1013.25;
data.temperature = params?.temperature ?? 15;
data.function = spa.SPA_ALL;
// 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;
let result = spa.spa_calculate(data);
let output = {};
// Only compute ZA and rise/transit/set
d.function = spa.SPA_ZA_RTS;
if (result === 0) {
output = {
zenith: data.zenith,
azimuth: data.azimuth,
sunrise: data.sunrise,
solarNoon: data.suntransit,
sunset: data.sunset
};
const rc = spa.spa_calculate(d);
if (rc !== 0) {
throw new Error(`SPA calculation failed with code ${rc}`);
}
if (angles.length > 0) {
output.angles = angles.map(angle => {
let customSpaData = adjustForCustomAngle({ ...data }, angle);
return {
sunrise: customSpaData.sunrise,
sunset: customSpaData.sunset
};
});
}
} else {
console.error('SPA Calculation failed');
}
// Base outputs
const output = {
zenith: d.zenith,
azimuth: d.azimuth,
sunrise: d.sunrise,
solarNoon: d.suntransit,
sunset: d.sunset
};
return output;
// 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;
}
function calcSpa(date, lat, lng, tz = 0, params = null, angles = []) {
let rawData = getSpa(date, lat, lng, tz, params, angles);
rawData.sunrise = fractalTime(rawData.sunrise);
rawData.solarNoon = fractalTime(rawData.solarNoon);
rawData.sunset = fractalTime(rawData.sunset);
if (rawData.angles) {
rawData.angles = rawData.angles.map(angleData => {
return {
sunrise: fractalTime(angleData.sunrise),
sunset: fractalTime(angleData.sunset)
};
});
}
return rawData;
/**
* 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
};
module.exports = { getSpa, calcSpa, fractalTime, adjustForCustomAngle };

View file

@ -1,6 +1,6 @@
{
"name": "nrel-spa",
"version": "1.2.4",
"version": "1.3.0",
"description": "NREL SPA native implementation in JS",
"main": "index.js",
"scripts": {

55
test.js
View file

@ -1,34 +1,41 @@
// tested.js
const { getSpa, calcSpa } = require('./index');
// Use current date/time
const date = new Date();
console.log(date)
console.log(`Current Date: ${date.toString()}\n`);
/* NYC - minimum params
const city = "New York"
/*
// Example: New York with minimum params
const city = "New York";
const lat = 40.7128;
const lng = -74.006;
const tz = -5;
const params = null
const angles = []
const lng = -74.0060;
const tz = -5; // Eastern Standard Time
const params = null;
const angles = [];
*/
// 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]
// 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`);
// Get results
const get = getSpa(date, lat, lng, tz, params, angles);
const calc = calcSpa(date, lat, lng, tz, params, angles);
// 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);
// Print results
console.log(`\nTest: ${city} with current Date():\n`)
console.log("getSpa =", get, "\n");
console.log("calcSpa =", calc, "\n");
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');