@verifyhash/uv-index 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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/index.js +177 -0
  4. package/lib/solar.js +125 -0
  5. package/package.json +42 -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,158 @@
1
+ # uv-index
2
+
3
+ A **zero-dependency, network-free clear-sky UV-index estimator** for Node.js.
4
+
5
+ Give it a place (latitude / longitude), a date and time, and — optionally — the
6
+ total-column ozone and site elevation. It computes the sun's elevation angle and
7
+ derives an approximate **clear-sky erythemal UV index (UVI)**, the matching
8
+ **WHO/WMO exposure category** (Low / Moderate / High / Very High / Extreme), and
9
+ a rough **burn-time** for fair skin.
10
+
11
+ Everything runs locally with the Node standard library. No network calls, no npm
12
+ dependencies, no API keys.
13
+
14
+ ## Who it's for
15
+
16
+ Developers building **sun-safety**, **weather**, or **outdoor-planning** features
17
+ who want a fast, offline estimate of "how strong will the sun be here, right now,
18
+ if the sky is clear?" — for example:
19
+
20
+ - a hiking / beach / gardening app that shades a timeline by UV strength,
21
+ - a "reapply sunscreen" nudge that scales with the clear-sky UV ceiling,
22
+ - a backfill for historical or future dates where no measured UVI feed exists,
23
+ - a sanity bound to cross-check a measured/forecast UVI feed against.
24
+
25
+ It is a **planning-grade ceiling**, not a sensor. If you need the *actual* UVI
26
+ right now, use a measured feed (they capture the clouds and haze this model
27
+ deliberately ignores).
28
+
29
+ ## Install / use
30
+
31
+ No install step — it is a single self-contained module. Copy the `uv-index/`
32
+ folder into your project and `require` it:
33
+
34
+ ```js
35
+ const { estimateUVIndex } = require('./uv-index');
36
+
37
+ const r = estimateUVIndex({
38
+ lat: 23.44, // + North
39
+ lon: 0, // + East
40
+ date: new Date('2023-06-21T12:00:00Z'), // a full UTC instant (date + time)
41
+ ozoneDU: 300, // optional, total-column ozone in Dobson Units (default 300)
42
+ elevationM: 0, // optional, site elevation in metres (default 0)
43
+ skinType: 'II', // optional, Fitzpatrick I–VI for burn time (default 'II')
44
+ });
45
+
46
+ console.log(r);
47
+ // {
48
+ // uvi: 12.499, // continuous clear-sky UV index
49
+ // uviRounded: 12, // rounded, as UVI is officially reported
50
+ // category: 'Extreme', // WHO/WMO band
51
+ // solarElevationDeg: 89.6, // sun altitude above the horizon
52
+ // sunAboveHorizon: true,
53
+ // burnTimeMinutes: 13, // ~minutes of clear-sky sun to one MED (skin II)
54
+ // inputs: { lat: 23.44, lon: 0, ozoneDU: 300, elevationM: 0, skinType: 'II' }
55
+ // }
56
+ ```
57
+
58
+ `date` is interpreted as a **UTC instant** (both date and time-of-day matter —
59
+ this is an instantaneous estimate, not a daily one). To model a specific local
60
+ clock time, convert it to UTC first.
61
+
62
+ ### Other exports
63
+
64
+ ```js
65
+ const {
66
+ estimateUVIndex, // the main estimator (above)
67
+ categoryFor, // (uvi) -> { category, min, max } — WHO band for a UVI value
68
+ burnTimeMinutes, // (uvi, skinType='II') -> minutes to one MED, or null at UVI 0
69
+ solarElevation, // (lat, lon, Date) -> sun elevation in degrees (vendored math)
70
+ } = require('./uv-index');
71
+ ```
72
+
73
+ ## How the number is built
74
+
75
+ 1. **Solar elevation** is computed with the NOAA solar-position algorithm
76
+ (Meeus-derived), **vendored into `lib/solar.js`** — see "Vendored solar math"
77
+ below. If the sun is at or below the horizon, UVI is exactly `0`.
78
+ 2. **Clear-sky UVI** from the elevation `h`:
79
+
80
+ ```
81
+ μ0 = sin(h) # = cos(solar zenith angle)
82
+ UVI = 12.5 · μ0^2.42 · (Ω/300)^(-1.23) · (1 + 0.06 · z_km)
83
+ ```
84
+
85
+ - `12.5` is the base coefficient tuned so an overhead sun at 300 DU ozone and
86
+ sea level peaks near the low-Extreme band, matching observed clear-sky
87
+ maxima of ~12 at sea level.
88
+ - `2.42` is a common empirical exponent for the erythemal (sunburn-weighted)
89
+ UV response versus solar elevation.
90
+ - `Ω` is total-column ozone in Dobson Units; the `-1.23` exponent is the
91
+ erythemal Radiation Amplification Factor (~1.2 fractional UV change per 1%
92
+ ozone; literature values span ~1.1–1.4).
93
+ - `z_km` is site elevation in km; `+6% per km` sits in the field-observed
94
+ ~5–10%/km range.
95
+ 3. **Category** maps the UVI (rounded to the nearest whole number, as UVI is
96
+ officially reported) to the WHO/WMO bands: `0–2 Low`, `3–5 Moderate`,
97
+ `6–7 High`, `8–10 Very High`, `11+ Extreme`.
98
+ 4. **Burn time** is the physical time to accumulate one Minimal Erythemal Dose
99
+ (MED) for the chosen Fitzpatrick skin type:
100
+ `t = MED / (UVI · 0.025 W/m² · 60 s)` minutes, using MED values of
101
+ 200 / 250 / 350 / 450 / 600 / 1000 J/m² for skin types I–VI.
102
+
103
+ ## Honest limits — read this
104
+
105
+ This is a **CLEAR-SKY model**. It answers "what is the sun's UV *ceiling* under a
106
+ cloudless, clean sky?" and nothing more:
107
+
108
+ - **No clouds.** Real clouds can cut UVI by 50%+ (or, with bright broken cloud,
109
+ briefly *raise* it slightly). This model never sees them, so on an overcast day
110
+ its number will read **too high**.
111
+ - **No aerosols / haze / pollution.** Urban or smoky air absorbs UV; the model
112
+ ignores it and will overestimate in hazy conditions.
113
+ - **No surface albedo boost.** Fresh snow (up to ~+90%), sand, and water reflect
114
+ UV upward; the model does not add that, so it can read **too low** on snow.
115
+ - **The ozone and elevation terms are approximations.** The 300 DU reference, the
116
+ `-1.23` ozone exponent, and the `+6%/km` altitude factor are single-value
117
+ fits, not a radiative-transfer computation. Real ozone varies ~230–500 DU by
118
+ latitude and season; if you don't pass a value the model assumes 300 DU.
119
+ - **Geometric sun angle, no refraction.** Near sunrise/sunset (elevation within
120
+ ~0.5° of the horizon) the true, refracted sun may be marginally higher than
121
+ the geometric elevation used here; the UV contribution there is negligible
122
+ anyway.
123
+ - **Mid-latitude clear-sky peaks read on the high side.** Because there are no
124
+ clouds or aerosols, a clear-sky summer noon at ~50° latitude lands near the top
125
+ of the observed 7–9 window (this library returns ~9). That is the intended
126
+ clear-sky ceiling, not a typical measured value.
127
+ - **Not medical advice.** Burn time is an order-of-magnitude physical estimate;
128
+ actual sunburn onset depends on sunscreen, reflection, medication, and
129
+ individual skin. **This is not a substitute for a measured UVI feed.**
130
+
131
+ Use it for planning, shading UIs, and offline estimates — not for anything where
132
+ being wrong by a few index points on a cloudy or hazy day matters.
133
+
134
+ ## Vendored solar math
135
+
136
+ The solar-elevation geometry in `lib/solar.js` is a trimmed **copy** of the
137
+ sibling `solar-calc` project in this incubator (`tools/solar-calc/index.js`,
138
+ v0.1.0 — the `solarElevation` / NOAA `sunPosition` path). It is copied in on
139
+ purpose so `uv-index` has **zero runtime dependencies** and never networks to or
140
+ `require`s another package. Both are MIT-licensed and share the NOAA Global
141
+ Monitoring Laboratory Solar Calculator algorithm.
142
+
143
+ ## Running the tests
144
+
145
+ One command, no framework, no dependencies:
146
+
147
+ ```sh
148
+ node test/index.test.js
149
+ ```
150
+
151
+ The suite covers: sun below the horizon → UVI 0, a high-UVI low-latitude
152
+ solar-noon case, a documented mid-latitude clear-sky band plus a winter contrast,
153
+ WHO category boundaries, ozone/elevation correction direction, burn-time physics,
154
+ and invalid-input rejection.
155
+
156
+ ## License
157
+
158
+ MIT — see `LICENSE`.
package/index.js ADDED
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * uv-index — zero-dependency, network-free CLEAR-SKY UV-index estimator.
5
+ *
6
+ * Given a place (lat/lon), a date and time, and optionally the total-column
7
+ * ozone and site elevation, this estimates the clear-sky erythemal UV index,
8
+ * maps it to the WHO exposure category (Low / Moderate / High / Very High /
9
+ * Extreme), and gives a rough burn-time for fair skin.
10
+ *
11
+ * IT IS A CLEAR-SKY MODEL. There are no clouds, no aerosols/haze, no surface
12
+ * albedo (snow/water) boost, and the ozone & elevation terms are simple
13
+ * approximations. Treat the number as a clear-sky *ceiling*, not a measurement.
14
+ * See README.md → "Honest limits".
15
+ *
16
+ * Every export is a PURE function: no I/O, no network, no globals mutated, and
17
+ * ZERO runtime dependencies. The solar-elevation geometry is vendored in
18
+ * ./lib/solar.js (copied from the sibling solar-calc project, NOAA algorithm).
19
+ */
20
+
21
+ const { solarElevation } = require('./lib/solar');
22
+
23
+ const rad = (d) => (d * Math.PI) / 180;
24
+
25
+ // Reference total-column ozone (Dobson Units) the base coefficient is tuned to.
26
+ const REF_OZONE_DU = 300;
27
+ // Radiation Amplification Factor for erythemal UV: a ~1.2 fractional drop in
28
+ // UV per +1% ozone. Literature values cluster around 1.1–1.4; we use 1.23.
29
+ const OZONE_RAF = 1.23;
30
+ // Base coefficient: overhead sun (elevation 90°), 300 DU ozone, sea level.
31
+ // Chosen so a tropical clear-sky solar noon peaks around the low-Extreme band,
32
+ // consistent with observed clear-sky maxima of ~12 at sea level.
33
+ const BASE_COEFF = 12.5;
34
+ // Exponent on cos(zenith) = sin(elevation). ~2.4 is a common empirical fit for
35
+ // the erythemal (sunburn-weighted) UV response versus solar elevation.
36
+ const ELEV_EXPONENT = 2.42;
37
+ // UV rises with altitude because there is less atmosphere overhead. Field
38
+ // studies report ~5–10% more erythemal UV per 1000 m; we use 6%/km.
39
+ const ALTITUDE_PER_KM = 0.06;
40
+
41
+ // Minimal Erythemal Dose (J/m² of erythemally-weighted UV) by Fitzpatrick skin
42
+ // type. Type II ("fair, usually burns") is the default guidance audience.
43
+ const MED_BY_SKIN_TYPE = {
44
+ I: 200, // very fair, always burns
45
+ II: 250, // fair, usually burns
46
+ III: 350, // medium, sometimes burns
47
+ IV: 450, // olive, rarely burns
48
+ V: 600, // brown, very rarely burns
49
+ VI: 1000, // deeply pigmented, extremely rarely burns
50
+ };
51
+
52
+ // 1 UV-index unit == 0.025 W/m² of erythemally-weighted irradiance (WHO/WMO
53
+ // definition). Energy accumulated per minute at index U = U*0.025*60 J/m².
54
+ const WPM2_PER_UVI = 0.025;
55
+
56
+ /**
57
+ * Map a UV index to its WHO/WMO exposure category. Categorisation uses the
58
+ * value ROUNDED to the nearest whole number, exactly as UVI is reported:
59
+ * 0–2 Low · 3–5 Moderate · 6–7 High · 8–10 Very High · 11+ Extreme.
60
+ * @param {number} uvi
61
+ * @returns {{category:string, min:number, max:number|null}}
62
+ */
63
+ function categoryFor(uvi) {
64
+ if (!Number.isFinite(uvi) || uvi < 0) {
65
+ throw new TypeError('categoryFor: uvi must be a finite number >= 0');
66
+ }
67
+ const r = Math.round(uvi);
68
+ if (r <= 2) return { category: 'Low', min: 0, max: 2 };
69
+ if (r <= 5) return { category: 'Moderate', min: 3, max: 5 };
70
+ if (r <= 7) return { category: 'High', min: 6, max: 7 };
71
+ if (r <= 10) return { category: 'Very High', min: 8, max: 10 };
72
+ return { category: 'Extreme', min: 11, max: null };
73
+ }
74
+
75
+ /**
76
+ * Approximate minutes of clear-sky exposure to reach one Minimal Erythemal Dose
77
+ * (the onset of visible sunburn) for a given Fitzpatrick skin type. Returns
78
+ * null when UVI is 0 (no meaningful burn risk).
79
+ *
80
+ * t = MED / (UVI * 0.025 W/m² * 60 s) [minutes]
81
+ *
82
+ * This is a physical order-of-magnitude figure, NOT medical advice; real burn
83
+ * onset depends on sunscreen, reflection, medication and individual skin.
84
+ *
85
+ * @param {number} uvi
86
+ * @param {'I'|'II'|'III'|'IV'|'V'|'VI'} [skinType='II']
87
+ * @returns {number|null} minutes, rounded to the nearest minute, or null
88
+ */
89
+ function burnTimeMinutes(uvi, skinType = 'II') {
90
+ if (!Number.isFinite(uvi) || uvi < 0) {
91
+ throw new TypeError('burnTimeMinutes: uvi must be a finite number >= 0');
92
+ }
93
+ const med = MED_BY_SKIN_TYPE[skinType];
94
+ if (med === undefined) {
95
+ throw new TypeError(
96
+ `burnTimeMinutes: unknown skinType "${skinType}" (use I–VI)`
97
+ );
98
+ }
99
+ if (uvi === 0) return null;
100
+ const minutes = med / (uvi * WPM2_PER_UVI * 60);
101
+ return Math.round(minutes);
102
+ }
103
+
104
+ /**
105
+ * Estimate the CLEAR-SKY UV index and derived guidance for a place and moment.
106
+ *
107
+ * @param {Object} opts
108
+ * @param {number} opts.lat latitude in degrees, + North [-90, 90]
109
+ * @param {number} opts.lon longitude in degrees, + East [-180, 180]
110
+ * @param {Date} opts.date a full JS Date instant (UTC-based)
111
+ * @param {number} [opts.ozoneDU=300] total-column ozone in Dobson Units
112
+ * @param {number} [opts.elevationM=0] site elevation above sea level, metres
113
+ * @param {'I'|'II'|'III'|'IV'|'V'|'VI'} [opts.skinType='II'] for burn time
114
+ * @returns {{
115
+ * uvi:number, uviRounded:number, category:string,
116
+ * solarElevationDeg:number, sunAboveHorizon:boolean,
117
+ * burnTimeMinutes:number|null,
118
+ * inputs:{lat:number,lon:number,ozoneDU:number,elevationM:number,skinType:string}
119
+ * }}
120
+ */
121
+ function estimateUVIndex(opts) {
122
+ if (!opts || typeof opts !== 'object') {
123
+ throw new TypeError('estimateUVIndex: expected an options object');
124
+ }
125
+ const {
126
+ lat,
127
+ lon,
128
+ date,
129
+ ozoneDU = REF_OZONE_DU,
130
+ elevationM = 0,
131
+ skinType = 'II',
132
+ } = opts;
133
+
134
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
135
+ throw new TypeError('estimateUVIndex: date must be a valid JS Date');
136
+ }
137
+ if (!Number.isFinite(ozoneDU) || ozoneDU <= 0) {
138
+ throw new TypeError('estimateUVIndex: ozoneDU must be a positive number');
139
+ }
140
+ if (!Number.isFinite(elevationM) || elevationM < 0) {
141
+ throw new TypeError('estimateUVIndex: elevationM must be >= 0');
142
+ }
143
+ // solarElevation validates lat/lon and throws on out-of-range values.
144
+ const elevationDeg = solarElevation(lat, lon, date);
145
+
146
+ let uvi = 0;
147
+ const sunAboveHorizon = elevationDeg > 0;
148
+ if (sunAboveHorizon) {
149
+ const mu0 = Math.sin(rad(elevationDeg)); // cos(zenith) = sin(elevation)
150
+ const base = BASE_COEFF * Math.pow(mu0, ELEV_EXPONENT);
151
+ const ozoneFactor = Math.pow(ozoneDU / REF_OZONE_DU, -OZONE_RAF);
152
+ const altitudeFactor = 1 + ALTITUDE_PER_KM * (elevationM / 1000);
153
+ uvi = base * ozoneFactor * altitudeFactor;
154
+ if (uvi < 0) uvi = 0; // numerical guard; the model is non-negative already
155
+ }
156
+
157
+ const uviRounded = Math.round(uvi);
158
+ return {
159
+ uvi,
160
+ uviRounded,
161
+ category: categoryFor(uvi).category,
162
+ solarElevationDeg: elevationDeg,
163
+ sunAboveHorizon,
164
+ burnTimeMinutes: burnTimeMinutes(uvi, skinType),
165
+ inputs: { lat, lon, ozoneDU, elevationM, skinType },
166
+ };
167
+ }
168
+
169
+ module.exports = {
170
+ estimateUVIndex,
171
+ categoryFor,
172
+ burnTimeMinutes,
173
+ solarElevation,
174
+ // constants exposed for advanced use / testing
175
+ MED_BY_SKIN_TYPE,
176
+ REF_OZONE_DU,
177
+ };
package/lib/solar.js ADDED
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * solar.js — VENDORED solar-elevation math.
5
+ *
6
+ * This is a trimmed, self-contained copy of the solar-position geometry from
7
+ * the sibling `solar-calc` project in this incubator (tools/solar-calc,
8
+ * index.js). It is COPIED IN ON PURPOSE so uv-index has ZERO runtime
9
+ * dependencies and never reaches over the network or requires another package.
10
+ *
11
+ * PINNED SOURCE: tools/solar-calc/index.js @ v0.1.0 (the `instantaneous` /
12
+ * `solarElevation` path, plus the NOAA `sunPosition` helper it depends on).
13
+ *
14
+ * The geometry follows the algorithm published by NOAA's Global Monitoring
15
+ * Laboratory Solar Calculator (https://gml.noaa.gov/grad/solcalc/), a
16
+ * simplified form of Jean Meeus, "Astronomical Algorithms" (2nd ed.).
17
+ *
18
+ * Only the pieces uv-index needs are kept: solarElevation(lat, lon, Date).
19
+ * Everything is a pure function — no I/O, no globals mutated, no deps.
20
+ */
21
+
22
+ // Trig helpers (JS Math is in radians; the NOAA formulas are in degrees).
23
+ const rad = (d) => (d * Math.PI) / 180;
24
+ const deg = (r) => (r * 180) / Math.PI;
25
+
26
+ const MS_PER_DAY = 86400000;
27
+ const JULIAN_UNIX_EPOCH = 2440587.5; // Julian Day of 1970-01-01T00:00:00Z
28
+
29
+ // Julian Day for a given UTC-millisecond instant.
30
+ function julianDay(ms) {
31
+ return ms / MS_PER_DAY + JULIAN_UNIX_EPOCH;
32
+ }
33
+
34
+ // Julian centuries since the J2000.0 epoch (JD 2451545.0).
35
+ function julianCentury(jd) {
36
+ return (jd - 2451545) / 36525;
37
+ }
38
+
39
+ // Sun declination (degrees) and equation of time (minutes) for century T.
40
+ function sunPosition(t) {
41
+ let l0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360;
42
+ if (l0 < 0) l0 += 360;
43
+
44
+ const m = 357.52911 + t * (35999.05029 - 0.0001537 * t);
45
+ const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
46
+
47
+ const c =
48
+ Math.sin(rad(m)) * (1.914602 - t * (0.004817 + 0.000014 * t)) +
49
+ Math.sin(rad(2 * m)) * (0.019993 - 0.000101 * t) +
50
+ Math.sin(rad(3 * m)) * 0.000289;
51
+
52
+ const trueLong = l0 + c;
53
+ const omega = 125.04 - 1934.136 * t;
54
+ const lambda = trueLong - 0.00569 - 0.00478 * Math.sin(rad(omega));
55
+
56
+ const seconds = 21.448 - t * (46.815 + t * (0.00059 - t * 0.001813));
57
+ const e0 = 23 + (26 + seconds / 60) / 60;
58
+ const obliq = e0 + 0.00256 * Math.cos(rad(omega));
59
+
60
+ const declination = deg(
61
+ Math.asin(Math.sin(rad(obliq)) * Math.sin(rad(lambda)))
62
+ );
63
+
64
+ const y = Math.tan(rad(obliq / 2)) ** 2;
65
+ const eqTime =
66
+ 4 *
67
+ deg(
68
+ y * Math.sin(2 * rad(l0)) -
69
+ 2 * e * Math.sin(rad(m)) +
70
+ 4 * e * y * Math.sin(rad(m)) * Math.cos(2 * rad(l0)) -
71
+ 0.5 * y * y * Math.sin(4 * rad(l0)) -
72
+ 1.25 * e * e * Math.sin(2 * rad(m))
73
+ );
74
+
75
+ return { declination, eqTime };
76
+ }
77
+
78
+ /**
79
+ * Solar elevation (altitude) in degrees above the horizon for a given instant.
80
+ * Positive = above the horizon. GEOMETRIC elevation of the Sun's centre; it
81
+ * does not add atmospheric refraction, so values very near 0° differ from an
82
+ * apparent, refracted horizon by up to ~0.5°.
83
+ *
84
+ * @param {number} lat latitude in degrees, + North
85
+ * @param {number} lon longitude in degrees, + East
86
+ * @param {Date} date a full JS Date instant (date + time, interpreted in UTC)
87
+ * @returns {number} elevation in degrees (can be negative when below horizon)
88
+ */
89
+ function solarElevation(lat, lon, date) {
90
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
91
+ throw new TypeError('solar.js: solarElevation needs a valid JS Date instant');
92
+ }
93
+ if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
94
+ throw new TypeError('solar.js: lat must be a number in [-90, 90]');
95
+ }
96
+ if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
97
+ throw new TypeError('solar.js: lon must be a number in [-180, 180]');
98
+ }
99
+
100
+ const ms = date.getTime();
101
+ const { declination: decl, eqTime } = sunPosition(
102
+ julianCentury(julianDay(ms))
103
+ );
104
+
105
+ const dayStart = Date.UTC(
106
+ date.getUTCFullYear(),
107
+ date.getUTCMonth(),
108
+ date.getUTCDate()
109
+ );
110
+ const utcMinutes = (ms - dayStart) / 60000;
111
+
112
+ let trueSolarTime = (utcMinutes + eqTime + 4 * lon) % 1440;
113
+ if (trueSolarTime < 0) trueSolarTime += 1440;
114
+
115
+ let hourAngle = trueSolarTime / 4 - 180;
116
+ if (hourAngle < -180) hourAngle += 360;
117
+
118
+ const cosZenith =
119
+ Math.sin(rad(lat)) * Math.sin(rad(decl)) +
120
+ Math.cos(rad(lat)) * Math.cos(rad(decl)) * Math.cos(rad(hourAngle));
121
+ const zenith = deg(Math.acos(Math.max(-1, Math.min(1, cosZenith))));
122
+ return 90 - zenith;
123
+ }
124
+
125
+ module.exports = { solarElevation, _sunPosition: sunPosition };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@verifyhash/uv-index",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency, network-free clear-sky UV-index estimator: solar-elevation-driven UVI, WHO exposure category, and burn-time guidance from lat/lon/date/time (+ optional ozone and elevation).",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "test": "node test/index.test.js"
9
+ },
10
+ "keywords": [
11
+ "uv-index",
12
+ "uvi",
13
+ "ultraviolet",
14
+ "clear-sky",
15
+ "sun-safety",
16
+ "sunburn",
17
+ "erythemal",
18
+ "solar-elevation",
19
+ "who-uv",
20
+ "weather",
21
+ "zero-dependency"
22
+ ],
23
+ "license": "MIT",
24
+ "files": [
25
+ "index.js",
26
+ "lib/solar.js",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/verifyhash/libs.git",
36
+ "directory": "uv-index"
37
+ },
38
+ "homepage": "https://github.com/verifyhash/libs/tree/main/uv-index#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/verifyhash/libs/issues"
41
+ }
42
+ }