@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +187 -0
  3. package/index.js +414 -0
  4. 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
+ }