@verifyhash/solar-calc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/index.js +414 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 <OWNER-NAME PLACEHOLDER>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# solar-calc
|
|
2
|
+
|
|
3
|
+
<!-- publish-prep -->
|
|
4
|
+
> **TODO (owner): pick the final npm name/scope before publishing.**
|
|
5
|
+
> The npm package name is **not** finalized by this project. Convention used here:
|
|
6
|
+
> `package.json` keeps the working slug `solar-calc` as a placeholder — replace it
|
|
7
|
+
> (and the `npm install` line below) with the real published name/scope before
|
|
8
|
+
> running `npm publish`.
|
|
9
|
+
|
|
10
|
+
A tiny, **zero-dependency** Node library for solar geometry: sunrise, sunset,
|
|
11
|
+
solar noon, day length, the Sun's elevation and azimuth at any instant, plus
|
|
12
|
+
civil-twilight and golden-hour boundaries — for any latitude, longitude, and
|
|
13
|
+
date.
|
|
14
|
+
|
|
15
|
+
Every export is a **pure function**: no I/O, no network, no daemon, no
|
|
16
|
+
dependencies, and no mutation of its arguments. The math follows the algorithm
|
|
17
|
+
published by [NOAA's Solar Calculator](https://gml.noaa.gov/grad/solcalc/), a
|
|
18
|
+
simplified form of Jean Meeus, *Astronomical Algorithms* (2nd ed.). Against
|
|
19
|
+
published almanac times it lands within about a minute at mid latitudes (see
|
|
20
|
+
the test fixtures).
|
|
21
|
+
|
|
22
|
+
## Who it's for
|
|
23
|
+
|
|
24
|
+
Developers building **weather**, **photography**, **astronomy**, or
|
|
25
|
+
**agriculture** tools who need reliable solar numbers without pulling in a big
|
|
26
|
+
ephemeris package or hitting an API:
|
|
27
|
+
|
|
28
|
+
- a weather dashboard showing today's sunrise/sunset and length of day;
|
|
29
|
+
- a photography planner that wants the golden-hour window for a shoot;
|
|
30
|
+
- an astronomy or stargazing app that needs when civil twilight ends;
|
|
31
|
+
- a solar/agriculture model that needs the Sun's elevation through the day.
|
|
32
|
+
|
|
33
|
+
### Honest limits
|
|
34
|
+
|
|
35
|
+
- Accuracy is **~1 minute at mid latitudes** and degrades toward the poles,
|
|
36
|
+
where a tiny error in the Sun's altitude maps to a large time swing. Near the
|
|
37
|
+
Arctic/Antarctic circles, treat sunrise/sunset times as approximate; the
|
|
38
|
+
polar-night / midnight-sun *detection* (returning `null`) is reliable.
|
|
39
|
+
- Elevation and azimuth are **geometric** — the Sun's true position. They do
|
|
40
|
+
**not** add atmospheric refraction, so within ~0.5° of the horizon the
|
|
41
|
+
apparent (refracted) Sun sits a little higher than the returned value.
|
|
42
|
+
- Sunrise/sunset use the standard **−0.833°** horizon (refraction + solar
|
|
43
|
+
radius). One visible consequence: at the equator on an equinox the day comes
|
|
44
|
+
out as ~12 h 07 m, not exactly 12 h — that offset is real, not a bug.
|
|
45
|
+
- This is a **position** library, not an irradiance/energy model: it tells you
|
|
46
|
+
*where* the Sun is, not how much power hits a panel.
|
|
47
|
+
|
|
48
|
+
## Install / use
|
|
49
|
+
|
|
50
|
+
No install step, no dependencies. Copy the folder in and `require` it:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const solar = require('./solar-calc');
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Conventions
|
|
57
|
+
|
|
58
|
+
| input | meaning |
|
|
59
|
+
|-------------|---------------------------------------------------------------------|
|
|
60
|
+
| `lat` | latitude in degrees, **+ North** (New York ≈ `40.71`) |
|
|
61
|
+
| `lon` | longitude in degrees, **+ East** (New York ≈ `-74.01`) |
|
|
62
|
+
| `dateInput` | a JS `Date` (only its Y/M/D matter, read in **UTC**) **or** a plain `{ year, month, day }` object with a **1-based** month |
|
|
63
|
+
| `tz` | offset of the **local clock** from UTC in **hours** (US Eastern Standard Time = `-5`); defaults to `0` (UTC) |
|
|
64
|
+
|
|
65
|
+
Daily event functions (`sunrise`, `sunset`, `solarNoon`, twilight, golden hour)
|
|
66
|
+
return a JS `Date` for the true **UTC instant** of the event, or `null` when it
|
|
67
|
+
doesn't happen that day (polar night / midnight sun). To print one in local
|
|
68
|
+
time, use `localClockMinutes(instant, tz)` or format the `Date` with your own
|
|
69
|
+
`tz`.
|
|
70
|
+
|
|
71
|
+
The instantaneous functions `solarElevation(lat, lon, date)` and
|
|
72
|
+
`solarAzimuth(lat, lon, date)` take a **full `Date` instant** (date **and**
|
|
73
|
+
time, UTC) and need no `tz` — the instant already fixes the moment.
|
|
74
|
+
|
|
75
|
+
## Worked example
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const solar = require('./solar-calc');
|
|
79
|
+
|
|
80
|
+
// New York City, June solstice 2021. EDT is UTC−4.
|
|
81
|
+
const lat = 40.7128;
|
|
82
|
+
const lon = -74.006;
|
|
83
|
+
const tz = -4;
|
|
84
|
+
const date = { year: 2021, month: 6, day: 21 };
|
|
85
|
+
|
|
86
|
+
// Pretty-print a UTC event instant as local "HH:MM".
|
|
87
|
+
function localHM(instant) {
|
|
88
|
+
if (!instant) return 'none';
|
|
89
|
+
const m = solar.localClockMinutes(instant, tz);
|
|
90
|
+
const h = Math.floor(m / 60);
|
|
91
|
+
const min = Math.round(m % 60);
|
|
92
|
+
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('sunrise ', localHM(solar.sunrise(lat, lon, date, tz))); // 05:25
|
|
96
|
+
console.log('solar noon ', localHM(solar.solarNoon(lat, lon, date, tz))); // 12:58
|
|
97
|
+
console.log('sunset ', localHM(solar.sunset(lat, lon, date, tz))); // 20:31
|
|
98
|
+
|
|
99
|
+
const mins = solar.dayLengthMinutes(lat, lon, date, tz);
|
|
100
|
+
console.log('day length ', (mins / 60).toFixed(2), 'hours'); // 15.09 hours
|
|
101
|
+
|
|
102
|
+
// Golden hour for an evening shoot:
|
|
103
|
+
console.log('golden hour', localHM(solar.goldenHourEveningStart(lat, lon, date, tz)),
|
|
104
|
+
'→', localHM(solar.sunset(lat, lon, date, tz)));
|
|
105
|
+
|
|
106
|
+
// Where is the Sun at 15:00 local (= 19:00 UTC)?
|
|
107
|
+
const t = new Date(Date.UTC(2021, 5, 21, 19, 0, 0));
|
|
108
|
+
console.log('elevation ', solar.solarElevation(lat, lon, t).toFixed(1), '°');
|
|
109
|
+
console.log('azimuth ', solar.solarAzimuth(lat, lon, t).toFixed(1), '° from north');
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## API
|
|
113
|
+
|
|
114
|
+
All angles are in **degrees**; all times/durations in **minutes** unless noted.
|
|
115
|
+
|
|
116
|
+
### Daily events → `Date` (UTC instant) or `null`
|
|
117
|
+
|
|
118
|
+
| function | returns |
|
|
119
|
+
|----------|---------|
|
|
120
|
+
| `solarNoon(lat, lon, dateInput, tz?)` | instant the Sun is highest (always defined) |
|
|
121
|
+
| `sunrise(lat, lon, dateInput, tz?)` | upper limb clearing the horizon, or `null` |
|
|
122
|
+
| `sunset(lat, lon, dateInput, tz?)` | upper limb touching the horizon, or `null` |
|
|
123
|
+
| `civilDawn(lat, lon, dateInput, tz?)` | Sun 6° below horizon, rising, or `null` |
|
|
124
|
+
| `civilDusk(lat, lon, dateInput, tz?)` | Sun 6° below horizon, setting, or `null` |
|
|
125
|
+
| `goldenHourMorningEnd(lat, lon, dateInput, tz?)` | Sun reaches 6° elevation after sunrise, or `null` |
|
|
126
|
+
| `goldenHourEveningStart(lat, lon, dateInput, tz?)` | Sun descends to 6° elevation before sunset, or `null` |
|
|
127
|
+
|
|
128
|
+
The morning golden hour runs **sunrise → `goldenHourMorningEnd`**; the evening
|
|
129
|
+
golden hour runs **`goldenHourEveningStart` → sunset**.
|
|
130
|
+
|
|
131
|
+
### Durations & raw quantities → `number`
|
|
132
|
+
|
|
133
|
+
| function | returns |
|
|
134
|
+
|----------|---------|
|
|
135
|
+
| `dayLengthMinutes(lat, lon, dateInput, tz?)` | minutes between sunrise and sunset; `0` on polar night, `1440` under the midnight sun |
|
|
136
|
+
| `declination(dateInput, tz?)` | Sun's declination in degrees (+ North) for that day |
|
|
137
|
+
| `equationOfTime(dateInput, tz?)` | apparent − mean solar time, in minutes (≈ −14 … +16 over a year) |
|
|
138
|
+
|
|
139
|
+
### Instantaneous position → `number` (degrees)
|
|
140
|
+
|
|
141
|
+
| function | returns |
|
|
142
|
+
|----------|---------|
|
|
143
|
+
| `solarElevation(lat, lon, date)` | geometric elevation above the horizon; negative below. `date` is a full UTC `Date` instant |
|
|
144
|
+
| `solarAzimuth(lat, lon, date)` | compass bearing clockwise from **true North** (0 = N, 90 = E, 180 = S, 270 = W) |
|
|
145
|
+
|
|
146
|
+
### Helper
|
|
147
|
+
|
|
148
|
+
| function | returns |
|
|
149
|
+
|----------|---------|
|
|
150
|
+
| `localClockMinutes(instant, tz?)` | minutes-after-midnight `[0, 1440)` on the local clock for a returned event instant, or `null` if passed `null` |
|
|
151
|
+
|
|
152
|
+
## Running the tests
|
|
153
|
+
|
|
154
|
+
One command, no dependencies:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
node test/solar.test.js
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The suite checks four known-city fixtures (New York, London, Sydney, Tokyo)
|
|
161
|
+
against **published** sunrise/sunset times within **±2 minutes**, verifies the
|
|
162
|
+
equator-at-equinox ≈12-hour day, the Tromsø midnight-sun (no sunset) and
|
|
163
|
+
polar-night (no sunrise) cases, cross-checks the daily and instantaneous code
|
|
164
|
+
paths against each other, and asserts the twilight/golden-hour ordering. It
|
|
165
|
+
exits non-zero if anything fails.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT.
|
|
170
|
+
|
|
171
|
+
## Install
|
|
172
|
+
|
|
173
|
+
> Placeholder name: the `solar-calc` below is the working slug, not a finalized
|
|
174
|
+
> npm name — see the owner TODO near the top of this README.
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
npm install solar-calc
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const solar = require('solar-calc');
|
|
182
|
+
|
|
183
|
+
const lat = 40.7128, lon = -74.006, tz = -4; // New York City, EDT
|
|
184
|
+
const date = { year: 2021, month: 6, day: 21 };
|
|
185
|
+
solar.sunrise(lat, lon, date, tz); // Date — UTC instant of sunrise
|
|
186
|
+
solar.dayLengthMinutes(lat, lon, date, tz); // minutes of daylight
|
|
187
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* solar-calc — zero-dependency solar position & daylight math.
|
|
5
|
+
*
|
|
6
|
+
* Every export is a PURE function: no I/O, no network, no globals mutated, no
|
|
7
|
+
* dependencies. The geometry follows the algorithm published by NOAA's Global
|
|
8
|
+
* Monitoring Laboratory Solar Calculator
|
|
9
|
+
* (https://gml.noaa.gov/grad/solcalc/), which is itself a simplified form of
|
|
10
|
+
* Jean Meeus, "Astronomical Algorithms" (2nd ed.). The same equations back the
|
|
11
|
+
* NOAA spreadsheet used by thousands of solar/weather tools.
|
|
12
|
+
*
|
|
13
|
+
* CONVENTIONS
|
|
14
|
+
* lat latitude in degrees, + to the North (e.g. New York = 40.7128)
|
|
15
|
+
* lon longitude in degrees, + to the East (e.g. New York = -74.006)
|
|
16
|
+
* dateInput either a JS Date (only its Y/M/D matter — see below) or a plain
|
|
17
|
+
* object { year, month, day } with a 1-based month (1 = January).
|
|
18
|
+
* tz time-zone offset of the LOCAL clock from UTC, in hours
|
|
19
|
+
* (e.g. US Eastern Standard Time = -5). Defaults to 0 (UTC).
|
|
20
|
+
*
|
|
21
|
+
* When you pass a JS Date to a DAILY function (sunrise, sunset, solarNoon,
|
|
22
|
+
* dayLengthMinutes, twilight, golden hour) only its calendar date is used, and
|
|
23
|
+
* that date is read in UTC — so `new Date('2020-06-21')` is June 21 everywhere.
|
|
24
|
+
* If you care about a specific local day near a date boundary, pass an explicit
|
|
25
|
+
* { year, month, day } object.
|
|
26
|
+
*
|
|
27
|
+
* Daily event functions return a JS Date representing the true UTC INSTANT of
|
|
28
|
+
* the event (or null when the event does not occur that day, e.g. polar night /
|
|
29
|
+
* midnight sun). To show one in local time, format it with the same `tz` you
|
|
30
|
+
* passed in, or use localClockMinutes(instant, tz).
|
|
31
|
+
*
|
|
32
|
+
* The instantaneous functions solarElevation(lat, lon, date) and
|
|
33
|
+
* solarAzimuth(lat, lon, date) take a FULL JS Date instant (date + time, in
|
|
34
|
+
* UTC) and need no tz — the instant already pins the moment.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Trig helpers (JS Math uses radians; the NOAA formulas are written in degrees)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const rad = (d) => (d * Math.PI) / 180;
|
|
41
|
+
const deg = (r) => (r * 180) / Math.PI;
|
|
42
|
+
|
|
43
|
+
// Zenith angle (degrees from vertical) used for the standard sunrise/sunset.
|
|
44
|
+
// 90.833° = 90° + 0.833°, where 0.833° accounts for mean atmospheric
|
|
45
|
+
// refraction (~34') plus the Sun's apparent radius (~16') at the horizon.
|
|
46
|
+
const SUNRISE_ZENITH = 90.833;
|
|
47
|
+
// Civil twilight: geometric Sun centre 6° below the horizon.
|
|
48
|
+
const CIVIL_ZENITH = 96;
|
|
49
|
+
// "Golden hour" upper bound: Sun 6° ABOVE the horizon (soft, warm light below
|
|
50
|
+
// this). Zenith = 90 - 6 = 84.
|
|
51
|
+
const GOLDEN_ZENITH = 84;
|
|
52
|
+
|
|
53
|
+
const MS_PER_DAY = 86400000;
|
|
54
|
+
const JULIAN_UNIX_EPOCH = 2440587.5; // Julian Day of 1970-01-01T00:00:00Z
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Date-input normalisation
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// Return { year, month (1-based), day } from a JS Date (read in UTC) or a
|
|
61
|
+
// plain { year, month, day } object.
|
|
62
|
+
function normalizeYMD(dateInput) {
|
|
63
|
+
if (dateInput instanceof Date) {
|
|
64
|
+
if (Number.isNaN(dateInput.getTime())) {
|
|
65
|
+
throw new TypeError('solar-calc: received an invalid Date');
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
year: dateInput.getUTCFullYear(),
|
|
69
|
+
month: dateInput.getUTCMonth() + 1,
|
|
70
|
+
day: dateInput.getUTCDate(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
dateInput &&
|
|
75
|
+
typeof dateInput === 'object' &&
|
|
76
|
+
Number.isFinite(dateInput.year) &&
|
|
77
|
+
Number.isFinite(dateInput.month) &&
|
|
78
|
+
Number.isFinite(dateInput.day)
|
|
79
|
+
) {
|
|
80
|
+
return { year: dateInput.year, month: dateInput.month, day: dateInput.day };
|
|
81
|
+
}
|
|
82
|
+
throw new TypeError(
|
|
83
|
+
'solar-calc: dateInput must be a JS Date or { year, month, day }'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// UTC milliseconds at 00:00:00 UTC on the given calendar date.
|
|
88
|
+
function utcMidnightMs(ymd) {
|
|
89
|
+
return Date.UTC(ymd.year, ymd.month - 1, ymd.day);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Julian Day for a given UTC-millisecond instant.
|
|
93
|
+
function julianDay(ms) {
|
|
94
|
+
return ms / MS_PER_DAY + JULIAN_UNIX_EPOCH;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Core NOAA solar quantities, all as a function of Julian centuries T since
|
|
99
|
+
// the J2000.0 epoch (JD 2451545.0).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function julianCentury(jd) {
|
|
103
|
+
return (jd - 2451545) / 36525;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sun declination (degrees) and the equation of time (minutes) for century T.
|
|
107
|
+
// The equation of time is (apparent solar time − mean solar time), i.e. how far
|
|
108
|
+
// a real sundial runs ahead of a mean clock.
|
|
109
|
+
function sunPosition(t) {
|
|
110
|
+
// Geometric mean longitude of the Sun (deg), wrapped to [0, 360).
|
|
111
|
+
let l0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360;
|
|
112
|
+
if (l0 < 0) l0 += 360;
|
|
113
|
+
|
|
114
|
+
// Geometric mean anomaly of the Sun (deg).
|
|
115
|
+
const m = 357.52911 + t * (35999.05029 - 0.0001537 * t);
|
|
116
|
+
|
|
117
|
+
// Eccentricity of Earth's orbit (unitless).
|
|
118
|
+
const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
|
|
119
|
+
|
|
120
|
+
// Sun's equation of the centre (deg).
|
|
121
|
+
const c =
|
|
122
|
+
Math.sin(rad(m)) * (1.914602 - t * (0.004817 + 0.000014 * t)) +
|
|
123
|
+
Math.sin(rad(2 * m)) * (0.019993 - 0.000101 * t) +
|
|
124
|
+
Math.sin(rad(3 * m)) * 0.000289;
|
|
125
|
+
|
|
126
|
+
const trueLong = l0 + c; // Sun's true longitude (deg)
|
|
127
|
+
|
|
128
|
+
// Apparent longitude (deg), corrected for nutation/aberration.
|
|
129
|
+
const omega = 125.04 - 1934.136 * t;
|
|
130
|
+
const lambda = trueLong - 0.00569 - 0.00478 * Math.sin(rad(omega));
|
|
131
|
+
|
|
132
|
+
// Mean obliquity of the ecliptic (deg) and its correction.
|
|
133
|
+
const seconds = 21.448 - t * (46.815 + t * (0.00059 - t * 0.001813));
|
|
134
|
+
const e0 = 23 + (26 + seconds / 60) / 60;
|
|
135
|
+
const obliq = e0 + 0.00256 * Math.cos(rad(omega));
|
|
136
|
+
|
|
137
|
+
// Sun declination (deg).
|
|
138
|
+
const declination = deg(
|
|
139
|
+
Math.asin(Math.sin(rad(obliq)) * Math.sin(rad(lambda)))
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Equation of time (minutes).
|
|
143
|
+
const y = Math.tan(rad(obliq / 2)) ** 2;
|
|
144
|
+
const eqTime =
|
|
145
|
+
4 *
|
|
146
|
+
deg(
|
|
147
|
+
y * Math.sin(2 * rad(l0)) -
|
|
148
|
+
2 * e * Math.sin(rad(m)) +
|
|
149
|
+
4 * e * y * Math.sin(rad(m)) * Math.cos(2 * rad(l0)) -
|
|
150
|
+
0.5 * y * y * Math.sin(4 * rad(l0)) -
|
|
151
|
+
1.25 * e * e * Math.sin(2 * rad(m))
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return { declination, eqTime };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Public: raw astronomical quantities (handy on their own)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Sun declination in degrees for the given date (evaluated at local solar noon,
|
|
163
|
+
* which is more than accurate enough for a daily value). + to the North.
|
|
164
|
+
*/
|
|
165
|
+
function declination(dateInput, tz = 0) {
|
|
166
|
+
const ymd = normalizeYMD(dateInput);
|
|
167
|
+
const noonMs = utcMidnightMs(ymd) + (12 - tz) * 3600000;
|
|
168
|
+
return sunPosition(julianCentury(julianDay(noonMs))).declination;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Equation of time in minutes for the given date (apparent minus mean solar
|
|
173
|
+
* time). Ranges over roughly −14 … +16 minutes across a year.
|
|
174
|
+
*/
|
|
175
|
+
function equationOfTime(dateInput, tz = 0) {
|
|
176
|
+
const ymd = normalizeYMD(dateInput);
|
|
177
|
+
const noonMs = utcMidnightMs(ymd) + (12 - tz) * 3600000;
|
|
178
|
+
return sunPosition(julianCentury(julianDay(noonMs))).eqTime;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Daily events: solar noon plus symmetric morning/evening events at a chosen
|
|
183
|
+
// zenith angle. All returned as true UTC instants (Date) or null.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
// Shared daily solve. Returns UTC minutes-after-midnight for solar noon and the
|
|
187
|
+
// hour-angle (deg) needed to reach `zenith`, or the reason an event is absent.
|
|
188
|
+
function dailySolve(lat, lon, dateInput, tz, zenith) {
|
|
189
|
+
const ymd = normalizeYMD(dateInput);
|
|
190
|
+
const midnightMs = utcMidnightMs(ymd);
|
|
191
|
+
// Evaluate the Sun's declination / equation of time at local solar noon.
|
|
192
|
+
const noonMs = midnightMs + (12 - tz) * 3600000;
|
|
193
|
+
const { declination: decl, eqTime } = sunPosition(
|
|
194
|
+
julianCentury(julianDay(noonMs))
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// UTC minutes-after-midnight of solar noon at this longitude.
|
|
198
|
+
const solarNoonMin = 720 - eqTime - 4 * lon;
|
|
199
|
+
|
|
200
|
+
// Hour angle (deg) at which the Sun's centre sits at `zenith`.
|
|
201
|
+
const cosH =
|
|
202
|
+
(Math.cos(rad(zenith)) - Math.sin(rad(lat)) * Math.sin(rad(decl))) /
|
|
203
|
+
(Math.cos(rad(lat)) * Math.cos(rad(decl)));
|
|
204
|
+
|
|
205
|
+
let hourAngle = null;
|
|
206
|
+
let absence = null; // 'below' = never reaches zenith (stays lower);
|
|
207
|
+
// 'above' = always above zenith (never descends to it)
|
|
208
|
+
if (cosH > 1) {
|
|
209
|
+
// Sun never gets as high as `zenith` from below: for sunrise this is a
|
|
210
|
+
// polar-night day (Sun always below the horizon).
|
|
211
|
+
absence = 'below';
|
|
212
|
+
} else if (cosH < -1) {
|
|
213
|
+
// Sun never sinks to `zenith`: for sunrise this is a midnight-sun day.
|
|
214
|
+
absence = 'above';
|
|
215
|
+
} else {
|
|
216
|
+
hourAngle = deg(Math.acos(cosH));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { midnightMs, midnightUTC: midnightMs, decl, eqTime, solarNoonMin, hourAngle, absence };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Build a UTC Date from a date's UTC-midnight and a minutes-after-midnight.
|
|
223
|
+
function instantFrom(midnightMs, minutes) {
|
|
224
|
+
return new Date(midnightMs + minutes * 60000);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Solar noon (Sun due south/north, highest point of the day) as a UTC instant.
|
|
229
|
+
* Always defined.
|
|
230
|
+
*/
|
|
231
|
+
function solarNoon(lat, lon, dateInput, tz = 0) {
|
|
232
|
+
const s = dailySolve(lat, lon, dateInput, tz, SUNRISE_ZENITH);
|
|
233
|
+
return instantFrom(s.midnightMs, s.solarNoonMin);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Generic morning event (Sun rising through `zenith`).
|
|
237
|
+
function morningEvent(lat, lon, dateInput, tz, zenith) {
|
|
238
|
+
const s = dailySolve(lat, lon, dateInput, tz, zenith);
|
|
239
|
+
if (s.hourAngle === null) return null;
|
|
240
|
+
return instantFrom(s.midnightMs, s.solarNoonMin - s.hourAngle * 4);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Generic evening event (Sun descending through `zenith`).
|
|
244
|
+
function eveningEvent(lat, lon, dateInput, tz, zenith) {
|
|
245
|
+
const s = dailySolve(lat, lon, dateInput, tz, zenith);
|
|
246
|
+
if (s.hourAngle === null) return null;
|
|
247
|
+
return instantFrom(s.midnightMs, s.solarNoonMin + s.hourAngle * 4);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Sunrise (upper limb clearing the horizon) as a UTC instant, or null. */
|
|
251
|
+
function sunrise(lat, lon, dateInput, tz = 0) {
|
|
252
|
+
return morningEvent(lat, lon, dateInput, tz, SUNRISE_ZENITH);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Sunset (upper limb touching the horizon) as a UTC instant, or null. */
|
|
256
|
+
function sunset(lat, lon, dateInput, tz = 0) {
|
|
257
|
+
return eveningEvent(lat, lon, dateInput, tz, SUNRISE_ZENITH);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Start of civil dawn (Sun 6° below horizon, rising) as a UTC instant, or null. */
|
|
261
|
+
function civilDawn(lat, lon, dateInput, tz = 0) {
|
|
262
|
+
return morningEvent(lat, lon, dateInput, tz, CIVIL_ZENITH);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** End of civil dusk (Sun 6° below horizon, setting) as a UTC instant, or null. */
|
|
266
|
+
function civilDusk(lat, lon, dateInput, tz = 0) {
|
|
267
|
+
return eveningEvent(lat, lon, dateInput, tz, CIVIL_ZENITH);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* End of the morning golden hour: the Sun reaching 6° elevation after sunrise.
|
|
272
|
+
* Below this the light is soft and warm. Returns a UTC instant, or null.
|
|
273
|
+
*/
|
|
274
|
+
function goldenHourMorningEnd(lat, lon, dateInput, tz = 0) {
|
|
275
|
+
return morningEvent(lat, lon, dateInput, tz, GOLDEN_ZENITH);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Start of the evening golden hour: the Sun descending back to 6° elevation
|
|
280
|
+
* before sunset. Returns a UTC instant, or null.
|
|
281
|
+
*/
|
|
282
|
+
function goldenHourEveningStart(lat, lon, dateInput, tz = 0) {
|
|
283
|
+
return eveningEvent(lat, lon, dateInput, tz, GOLDEN_ZENITH);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Length of the day (sunrise → sunset) in minutes.
|
|
288
|
+
* - Normal day: a value in (0, 1440).
|
|
289
|
+
* - Polar night (Sun never rises): 0.
|
|
290
|
+
* - Midnight sun (Sun never sets): 1440.
|
|
291
|
+
*/
|
|
292
|
+
function dayLengthMinutes(lat, lon, dateInput, tz = 0) {
|
|
293
|
+
const s = dailySolve(lat, lon, dateInput, tz, SUNRISE_ZENITH);
|
|
294
|
+
if (s.hourAngle === null) return s.absence === 'above' ? 1440 : 0;
|
|
295
|
+
// Day length is twice the sunrise hour angle, at 4 minutes per degree.
|
|
296
|
+
return 2 * s.hourAngle * 4;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Instantaneous position for a specific moment (UTC Date instant)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
// Shared instantaneous solve for a full Date instant. Returns declination,
|
|
304
|
+
// hour angle (deg), and the derived elevation/azimuth.
|
|
305
|
+
function instantaneous(lat, lon, date) {
|
|
306
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
307
|
+
throw new TypeError(
|
|
308
|
+
'solar-calc: solarElevation/solarAzimuth need a valid JS Date instant'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const ms = date.getTime();
|
|
312
|
+
const { declination: decl, eqTime } = sunPosition(
|
|
313
|
+
julianCentury(julianDay(ms))
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// UTC minutes elapsed in the current UTC day.
|
|
317
|
+
const dayStart = Date.UTC(
|
|
318
|
+
date.getUTCFullYear(),
|
|
319
|
+
date.getUTCMonth(),
|
|
320
|
+
date.getUTCDate()
|
|
321
|
+
);
|
|
322
|
+
const utcMinutes = (ms - dayStart) / 60000;
|
|
323
|
+
|
|
324
|
+
// True solar time (minutes): UTC time shifted to this longitude, plus the
|
|
325
|
+
// equation of time. 4 min per degree of longitude.
|
|
326
|
+
let trueSolarTime = (utcMinutes + eqTime + 4 * lon) % 1440;
|
|
327
|
+
if (trueSolarTime < 0) trueSolarTime += 1440;
|
|
328
|
+
|
|
329
|
+
// Hour angle (deg): 0 at solar noon, negative in the morning.
|
|
330
|
+
let hourAngle = trueSolarTime / 4 - 180;
|
|
331
|
+
if (hourAngle < -180) hourAngle += 360;
|
|
332
|
+
|
|
333
|
+
// Solar zenith → elevation.
|
|
334
|
+
const cosZenith =
|
|
335
|
+
Math.sin(rad(lat)) * Math.sin(rad(decl)) +
|
|
336
|
+
Math.cos(rad(lat)) * Math.cos(rad(decl)) * Math.cos(rad(hourAngle));
|
|
337
|
+
const zenith = deg(Math.acos(Math.max(-1, Math.min(1, cosZenith))));
|
|
338
|
+
const elevation = 90 - zenith;
|
|
339
|
+
|
|
340
|
+
// Azimuth, measured clockwise from true North (0 = N, 90 = E, 180 = S).
|
|
341
|
+
let azimuth;
|
|
342
|
+
const denom = Math.cos(rad(90 - zenith)) * Math.sin(rad(lat)); // cos(elev)*sin(lat)
|
|
343
|
+
// Guard against the poles / Sun exactly overhead where azimuth is undefined.
|
|
344
|
+
const sinZen = Math.sin(rad(zenith));
|
|
345
|
+
if (Math.abs(sinZen) < 1e-9 || Math.abs(Math.cos(rad(lat))) < 1e-9) {
|
|
346
|
+
azimuth = 180; // degenerate; report due south as a stable default
|
|
347
|
+
} else {
|
|
348
|
+
let cosAz =
|
|
349
|
+
(Math.sin(rad(lat)) * Math.cos(rad(zenith)) - Math.sin(rad(decl))) /
|
|
350
|
+
(Math.cos(rad(lat)) * sinZen);
|
|
351
|
+
cosAz = Math.max(-1, Math.min(1, cosAz));
|
|
352
|
+
const az = deg(Math.acos(cosAz));
|
|
353
|
+
azimuth = hourAngle > 0 ? (az + 180) % 360 : (540 - az) % 360;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { elevation, azimuth, declination: decl };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Solar elevation (altitude) in degrees above the horizon for a given instant.
|
|
361
|
+
* Positive = above the horizon. This is the GEOMETRIC elevation of the Sun's
|
|
362
|
+
* centre; it does not add atmospheric refraction, so values very near 0° differ
|
|
363
|
+
* from an apparent, refracted horizon by up to ~0.5°.
|
|
364
|
+
*/
|
|
365
|
+
function solarElevation(lat, lon, date) {
|
|
366
|
+
return instantaneous(lat, lon, date).elevation;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Solar azimuth in degrees, measured clockwise from true North
|
|
371
|
+
* (0 = N, 90 = E, 180 = S, 270 = W).
|
|
372
|
+
*/
|
|
373
|
+
function solarAzimuth(lat, lon, date) {
|
|
374
|
+
return instantaneous(lat, lon, date).azimuth;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Small convenience for display
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Convert a UTC instant (as returned by the daily event functions) to
|
|
383
|
+
* minutes-after-midnight on the LOCAL clock for the given tz offset (hours).
|
|
384
|
+
* Returns a number in [0, 1440). Useful for printing "HH:MM" local times.
|
|
385
|
+
*/
|
|
386
|
+
function localClockMinutes(instant, tz = 0) {
|
|
387
|
+
if (instant == null) return null;
|
|
388
|
+
const ms = instant.getTime() + tz * 3600000;
|
|
389
|
+
let mins = (ms / 60000) % 1440;
|
|
390
|
+
if (mins < 0) mins += 1440;
|
|
391
|
+
return mins;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
// raw quantities
|
|
396
|
+
declination,
|
|
397
|
+
equationOfTime,
|
|
398
|
+
// daily events
|
|
399
|
+
solarNoon,
|
|
400
|
+
sunrise,
|
|
401
|
+
sunset,
|
|
402
|
+
dayLengthMinutes,
|
|
403
|
+
civilDawn,
|
|
404
|
+
civilDusk,
|
|
405
|
+
goldenHourMorningEnd,
|
|
406
|
+
goldenHourEveningStart,
|
|
407
|
+
// instantaneous
|
|
408
|
+
solarElevation,
|
|
409
|
+
solarAzimuth,
|
|
410
|
+
// helpers
|
|
411
|
+
localClockMinutes,
|
|
412
|
+
// exposed for advanced use / testing
|
|
413
|
+
_sunPosition: sunPosition,
|
|
414
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verifyhash/solar-calc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency solar position & daylight math (NOAA algorithm): sunrise, sunset, solar noon, day length, elevation, azimuth, civil twilight, and golden hour for any latitude/longitude/date.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/solar.test.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"solar",
|
|
12
|
+
"sunrise",
|
|
13
|
+
"sunset",
|
|
14
|
+
"solar-noon",
|
|
15
|
+
"day-length",
|
|
16
|
+
"solar-elevation",
|
|
17
|
+
"solar-azimuth",
|
|
18
|
+
"twilight",
|
|
19
|
+
"golden-hour",
|
|
20
|
+
"noaa",
|
|
21
|
+
"astronomy",
|
|
22
|
+
"zero-dependency"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/verifyhash/libs.git",
|
|
35
|
+
"directory": "solar-calc"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/verifyhash/libs/tree/main/solar-calc#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/verifyhash/libs/issues"
|
|
40
|
+
}
|
|
41
|
+
}
|