@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.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/index.js +177 -0
- package/lib/solar.js +125 -0
- 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
|
+
}
|