diff --git a/.DS_Store b/.DS_Store index 5008ddf..64a1d28 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 713d500..a733e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ node_modules/ .env + +# Ignore NREL SPA C sources and binaries +/bin/spa +/bin/spa_cli +/bin/*.c +/bin/*.h \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b3456..3f39bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + \ No newline at end of file diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..eefac74 --- /dev/null +++ b/bin/README.md @@ -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 +``` \ No newline at end of file diff --git a/bin/test.js b/bin/test.js new file mode 100644 index 0000000..ab29613 --- /dev/null +++ b/bin/test.js @@ -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}` + ); +}); diff --git a/index.js b/index.js index 4cb200c..499fb35 100644 --- a/index.js +++ b/index.js @@ -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 }; \ No newline at end of file diff --git a/package.json b/package.json index a5cf25d..b5fbac8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/test.js b/test.js index 0892d57..3685716 100644 --- a/test.js +++ b/test.js @@ -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'); \ No newline at end of file