@verifyhash/wbgt-heat-stress 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 +162 -0
- package/index.js +285 -0
- package/package.json +40 -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,162 @@
|
|
|
1
|
+
# wbgt-heat-stress
|
|
2
|
+
|
|
3
|
+
<!-- publish-prep -->
|
|
4
|
+
> **TODO (owner): pick the final npm name/scope before publishing.**
|
|
5
|
+
> The npm package name is **not** finalized. `package.json` keeps the working
|
|
6
|
+
> slug `wbgt-heat-stress` as a placeholder — replace it (and the install line
|
|
7
|
+
> below) with the real published name/scope before any `npm publish`.
|
|
8
|
+
> Graduation to npm is a **needs-human** decision, not something this project does.
|
|
9
|
+
|
|
10
|
+
A tiny, **zero-dependency** Node library for the three most-used heat-stress
|
|
11
|
+
indices, each returned with a plain-language **risk category**:
|
|
12
|
+
|
|
13
|
+
- **`heatIndex(tempC, relHumidityPct)`** — the US National Weather Service
|
|
14
|
+
"feels-like" apparent temperature (Rothfusz regression).
|
|
15
|
+
- **`humidex(tempC, dewpointC)`** — Environment Canada's humidex comfort index.
|
|
16
|
+
- **`wbgt(tempC, relHumidityPct, opts)`** — a **Wet-Bulb Globe Temperature
|
|
17
|
+
approximation** with the white/green/yellow/red/black flag band.
|
|
18
|
+
|
|
19
|
+
Every export is a **pure function**: no I/O, no network, no daemon, no
|
|
20
|
+
dependencies, and no mutation of its arguments. Same inputs → same output.
|
|
21
|
+
Units are Celsius, percent RH, and (for outdoor WBGT) m/s wind and W/m² solar.
|
|
22
|
+
|
|
23
|
+
## Who it's for
|
|
24
|
+
|
|
25
|
+
- **Occupational safety** — flag heat-stress risk for outdoor/industrial crews
|
|
26
|
+
against the WBGT flag conditions used by OSHA guidance and the US military.
|
|
27
|
+
- **Sports & athletics** — apply the American College of Sports Medicine WBGT
|
|
28
|
+
activity bands for training and event go/no-go decisions.
|
|
29
|
+
- **Weather & wellness apps** — show an honest "feels like" number (heat index
|
|
30
|
+
or humidex) without pulling in a heavy scientific package or calling an API.
|
|
31
|
+
|
|
32
|
+
If you already have a psychrometrics library, this one is complementary: it
|
|
33
|
+
focuses on the *stress indices and their risk bands*, not on general moist-air
|
|
34
|
+
math.
|
|
35
|
+
|
|
36
|
+
## Install / use
|
|
37
|
+
|
|
38
|
+
Zero dependencies — vendor the folder or (after the owner finalizes the name)
|
|
39
|
+
`npm install <final-name>`.
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const { heatIndex, humidex, wbgt, wbgtFlag } = require('wbgt-heat-stress');
|
|
43
|
+
|
|
44
|
+
heatIndex(32.22, 70);
|
|
45
|
+
// { heatIndexF: 105.9, heatIndexC: 41.1, risk: 'danger' }
|
|
46
|
+
|
|
47
|
+
humidex(30, 15);
|
|
48
|
+
// { humidex: 33.97, risk: 'some discomfort' }
|
|
49
|
+
|
|
50
|
+
wbgt(30, 50);
|
|
51
|
+
// { wbgtC: 29.26, flag: 'green', mode: 'indoor' }
|
|
52
|
+
|
|
53
|
+
wbgt(30, 50, { solarWm2: 900, windMs: 1.5 });
|
|
54
|
+
// { wbgtC: ~31.0, flag: 'yellow', mode: 'outdoor' }
|
|
55
|
+
|
|
56
|
+
wbgtFlag(31.5); // 'red' — band a WBGT you measured elsewhere
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Running the tests
|
|
60
|
+
|
|
61
|
+
One command, no framework, no dependencies:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
node test/index.test.js
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It exits `0` on success and non-zero on any failure. The suite checks fixtures
|
|
68
|
+
at published reference points — a NWS heat-index table cell, an Environment
|
|
69
|
+
Canada humidex example, and every WBGT flag-band boundary — each within a stated
|
|
70
|
+
tolerance, plus the input guards.
|
|
71
|
+
|
|
72
|
+
## The formulas (and their sources)
|
|
73
|
+
|
|
74
|
+
### Heat index — NWS/NOAA Rothfusz regression
|
|
75
|
+
|
|
76
|
+
Apparent temperature from air temperature and relative humidity. The NWS first
|
|
77
|
+
evaluates Steadman's simple formula; if that value is **below 80 °F (26.7 °C)**
|
|
78
|
+
the Rothfusz regression does not apply and the simple value is used (the
|
|
79
|
+
documented **low-temperature fallback** — below ~80 °F the heat index is
|
|
80
|
+
essentially the air temperature). Otherwise the full Rothfusz polynomial runs,
|
|
81
|
+
with the low-humidity (RH < 13 %) and high-humidity (RH > 85 %) corrections.
|
|
82
|
+
This is the exact method behind the published NWS heat-index chart and is
|
|
83
|
+
accurate to about **±1.3 °F**. Source: Rothfusz, L.P. (1990), *NWS Technical
|
|
84
|
+
Attachment SR 90-23*; Steadman, R.G. (1979).
|
|
85
|
+
|
|
86
|
+
Risk bands (NWS, °F): `none` < 80, `caution` 80–90, `extreme caution` 90–103,
|
|
87
|
+
`danger` 103–124, `extreme danger` ≥ 124.
|
|
88
|
+
|
|
89
|
+
### Humidex — Environment Canada
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
humidex = Tair + 0.5555 * (e - 10)
|
|
93
|
+
e = 6.11 * exp(5417.7530 * (1/273.16 - 1/(Tdew + 273.15))) [hPa]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Humidex is a unitless comfort index reported on the Celsius scale. When the dew
|
|
97
|
+
point implies dry air (`e ≤ 10 hPa`) humidex would fall below the air
|
|
98
|
+
temperature; Environment Canada does not report that, so the value is clamped to
|
|
99
|
+
the air temperature. Source: Masterton, J.M. & Richardson, F.A. (1979),
|
|
100
|
+
Environment Canada.
|
|
101
|
+
|
|
102
|
+
Comfort bands (°C-scale): `none` < 30, `some discomfort` 30–39,
|
|
103
|
+
`great discomfort` 40–45, `dangerous` ≥ 46.
|
|
104
|
+
|
|
105
|
+
### WBGT — an APPROXIMATION (read this carefully)
|
|
106
|
+
|
|
107
|
+
**True WBGT cannot be computed from air temperature and humidity alone.** The
|
|
108
|
+
real index is `WBGT = 0.7·Tnwb + 0.2·Tg + 0.1·Ta`, where **Tg is the temperature
|
|
109
|
+
of a 150 mm matte-black globe** in the actual sun/wind and **Tnwb is a naturally
|
|
110
|
+
ventilated wet-bulb** — both require physical sensors. This library does **not**
|
|
111
|
+
pretend to that accuracy. It provides two documented approximations:
|
|
112
|
+
|
|
113
|
+
**Indoor / shaded (default).** The Australian Bureau of Meteorology simplified
|
|
114
|
+
regression, valid where there is no direct solar load (indoors, or fully
|
|
115
|
+
shaded):
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
WBGT = 0.567 * Ta + 0.393 * e + 3.94
|
|
119
|
+
e = (RH/100) * 6.105 * exp(17.27*Ta/(237.7 + Ta)) [hPa]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This is the standard substitute when no globe sensor is available and is what
|
|
123
|
+
many heat-safety apps display for indoor/shaded conditions.
|
|
124
|
+
|
|
125
|
+
**Outdoor (when you pass `opts.solarWm2`).** The shade estimate **plus a coarse
|
|
126
|
+
solar-load term** — direct sun raises the globe temperature and hence WBGT,
|
|
127
|
+
while wind ventilates the globe and lowers it:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
dSolar = 3.2 * (solarWm2 / 1000) / sqrt(1 + windMs) [°C], capped at 4 °C
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
so ~1000 W/m² midday sun in light wind adds a few °C, tapering with wind.
|
|
134
|
+
|
|
135
|
+
**Honest limits.**
|
|
136
|
+
- The outdoor `dSolar` term is a **rough heuristic, not a validated model**. It
|
|
137
|
+
is tuned to give plausible magnitudes, not sensor-grade accuracy. Do not use
|
|
138
|
+
outdoor mode as the sole basis for a real occupational go/no-go decision in
|
|
139
|
+
the sun — **measure with a black-globe WBGT meter** for that.
|
|
140
|
+
- Even the indoor ABM regression carries its own scatter (order ~±1 °C) versus
|
|
141
|
+
instrumented readings and was fit for typical outdoor screen conditions.
|
|
142
|
+
- Humidity must be a real measurement; a bad RH propagates directly into WBGT.
|
|
143
|
+
- This library computes numbers and risk bands only. It is **not** a substitute
|
|
144
|
+
for an employer's heat-illness prevention program or medical judgement.
|
|
145
|
+
|
|
146
|
+
Flag bands (US military / American College of Sports Medicine), °C converted
|
|
147
|
+
from the canonical °F cut-points:
|
|
148
|
+
|
|
149
|
+
| Flag | WBGT (°C) | WBGT (°F) | Meaning |
|
|
150
|
+
|--------|-------------|-------------|----------------------------------|
|
|
151
|
+
| white | < 27.8 | < 82.0 | low risk |
|
|
152
|
+
| green | 27.8 – 29.3 | 82.0 – 84.9 | use discretion, hydrate |
|
|
153
|
+
| yellow | 29.4 – 31.0 | 85.0 – 87.9 | active caution; limit exertion |
|
|
154
|
+
| red | 31.1 – 32.1 | 88.0 – 89.9 | high risk; curtail hard activity |
|
|
155
|
+
| black | ≥ 32.2 | ≥ 90.0 | extreme; cancel/relocate |
|
|
156
|
+
|
|
157
|
+
`wbgtFlag(wbgtC)` is exported separately so you can band a WBGT you obtained
|
|
158
|
+
from a real sensor.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT — see `LICENSE`. (Copyright holder is a placeholder pending owner sign-off.)
|
package/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wbgt-heat-stress — zero-dependency heat-stress indices.
|
|
5
|
+
*
|
|
6
|
+
* Three PURE functions (no I/O, no network, no globals mutated, no arguments
|
|
7
|
+
* mutated, no dependencies — same inputs always give the same output):
|
|
8
|
+
*
|
|
9
|
+
* heatIndex(tempC, relHumidityPct) NWS/NOAA "apparent temperature" (°C)
|
|
10
|
+
* humidex(tempC, dewpointC) Environment Canada humidex (°C-scale)
|
|
11
|
+
* wbgt(tempC, relHumidityPct, opts) Wet-Bulb Globe Temperature APPROXIMATION
|
|
12
|
+
*
|
|
13
|
+
* Each returns the index value AND, where the metric defines one, a risk
|
|
14
|
+
* category. `wbgt` returns { wbgtC, flag, mode }. A standalone `wbgtFlag`
|
|
15
|
+
* classifier is also exported so callers can band a WBGT they computed
|
|
16
|
+
* elsewhere (e.g. from a real black-globe sensor).
|
|
17
|
+
*
|
|
18
|
+
* UNITS
|
|
19
|
+
* tempC, dewpointC degrees Celsius
|
|
20
|
+
* relHumidityPct relative humidity, percent, 0..100
|
|
21
|
+
* opts.windMs 10 m wind speed, metres/second (outdoor mode)
|
|
22
|
+
* opts.solarWm2 global solar irradiance, W/m^2 (outdoor mode)
|
|
23
|
+
*
|
|
24
|
+
* SOURCES (also cited per-function and in README.md)
|
|
25
|
+
* Heat index — Rothfusz, L.P. (1990), NWS Technical Attachment SR 90-23; the
|
|
26
|
+
* regression the US National Weather Service publishes its heat-index table
|
|
27
|
+
* from, with the low-humidity / high-humidity corrections and the Steadman
|
|
28
|
+
* low-temperature fallback.
|
|
29
|
+
* Humidex — Masterton & Richardson (1979), Environment Canada; e computed
|
|
30
|
+
* from dew point with the WMO/Buck-style saturation relation Canada uses.
|
|
31
|
+
* WBGT (shade) — the Australian Bureau of Meteorology simplified WBGT:
|
|
32
|
+
* WBGT = 0.567*Ta + 0.393*e + 3.94, valid for shaded/indoor conditions.
|
|
33
|
+
* WBGT flags — the WBGT flag-colour bands (white/green/yellow/red/black) used
|
|
34
|
+
* by the US military and the American College of Sports Medicine.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Input guards. Throw rather than silently coerce so a caller's unit bug
|
|
39
|
+
// (e.g. passing RH as a 0..1 fraction) surfaces immediately.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
function num(name, v) {
|
|
42
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
43
|
+
throw new TypeError(`${name} must be a finite number, got ${String(v)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateRh(rhPct) {
|
|
48
|
+
num('relHumidityPct', rhPct);
|
|
49
|
+
if (rhPct < 0 || rhPct > 100) {
|
|
50
|
+
throw new RangeError(
|
|
51
|
+
`relHumidityPct must be within [0, 100], got ${rhPct}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cToF = (c) => (c * 9) / 5 + 32;
|
|
57
|
+
const fToC = (f) => ((f - 32) * 5) / 9;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Saturation / actual vapour pressure helpers (hPa == millibars).
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// Tetens-form saturation vapour pressure over water, as used by the Australian
|
|
64
|
+
// Bureau of Meteorology alongside its simplified WBGT regression.
|
|
65
|
+
// es(T) = 6.105 * exp(17.27 * T / (237.7 + T)), T in °C, es in hPa.
|
|
66
|
+
function satVapPressureBoM(tempC) {
|
|
67
|
+
return 6.105 * Math.exp((17.27 * tempC) / (237.7 + tempC));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Saturation vapour pressure over water using the coefficient set Environment
|
|
71
|
+
// Canada uses for humidex: e_sat(Td) = 6.11 * exp(5417.7530 * (1/273.16 - 1/T)),
|
|
72
|
+
// T in kelvin. Evaluated at the dew point it gives the ACTUAL vapour pressure.
|
|
73
|
+
function vapPressureHumidex(dewpointC) {
|
|
74
|
+
const Tk = dewpointC + 273.15;
|
|
75
|
+
return 6.11 * Math.exp(5417.753 * (1 / 273.16 - 1 / Tk));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 1) HEAT INDEX (NWS/NOAA Rothfusz regression)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
/**
|
|
82
|
+
* Apparent ("feels-like") temperature from air temperature and relative
|
|
83
|
+
* humidity, following the US National Weather Service.
|
|
84
|
+
*
|
|
85
|
+
* Method: the NWS first evaluates Steadman's simple formula. If that averaged
|
|
86
|
+
* value is < 80 °F the Rothfusz regression does not apply and the simple value
|
|
87
|
+
* is used (this is the documented LOW-TEMPERATURE FALLBACK — below roughly
|
|
88
|
+
* 26.7 °C / 80 °F the heat index is essentially the air temperature). Otherwise
|
|
89
|
+
* the full Rothfusz regression is used, with the low-humidity (RH < 13 %) and
|
|
90
|
+
* high-humidity (RH > 85 %) corrections. The regression is the basis of the
|
|
91
|
+
* published NWS heat-index chart and is accurate to about ±1.3 °F.
|
|
92
|
+
*
|
|
93
|
+
* @param {number} tempC air temperature, °C
|
|
94
|
+
* @param {number} relHumidityPct relative humidity, percent 0..100
|
|
95
|
+
* @returns {{ heatIndexC:number, heatIndexF:number, risk:string }}
|
|
96
|
+
*/
|
|
97
|
+
function heatIndex(tempC, relHumidityPct) {
|
|
98
|
+
num('tempC', tempC);
|
|
99
|
+
validateRh(relHumidityPct);
|
|
100
|
+
|
|
101
|
+
const T = cToF(tempC); // °F
|
|
102
|
+
const R = relHumidityPct;
|
|
103
|
+
|
|
104
|
+
// Steadman "simple" apparent temperature, averaged with dry-bulb.
|
|
105
|
+
const simple = 0.5 * (T + 61.0 + (T - 68.0) * 1.2 + R * 0.094);
|
|
106
|
+
const avg = (simple + T) / 2;
|
|
107
|
+
|
|
108
|
+
let hiF;
|
|
109
|
+
if (avg < 80) {
|
|
110
|
+
// Low-temperature fallback: Rothfusz not valid here.
|
|
111
|
+
hiF = avg;
|
|
112
|
+
} else {
|
|
113
|
+
hiF =
|
|
114
|
+
-42.379 +
|
|
115
|
+
2.04901523 * T +
|
|
116
|
+
10.14333127 * R -
|
|
117
|
+
0.22475541 * T * R -
|
|
118
|
+
0.00683783 * T * T -
|
|
119
|
+
0.05481717 * R * R +
|
|
120
|
+
0.00122874 * T * T * R +
|
|
121
|
+
0.00085282 * T * R * R -
|
|
122
|
+
0.00000199 * T * T * R * R;
|
|
123
|
+
|
|
124
|
+
if (R < 13 && T >= 80 && T <= 112) {
|
|
125
|
+
hiF -= ((13 - R) / 4) * Math.sqrt((17 - Math.abs(T - 95)) / 17);
|
|
126
|
+
} else if (R > 85 && T >= 80 && T <= 87) {
|
|
127
|
+
hiF += ((R - 85) / 10) * ((87 - T) / 5);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
heatIndexF: hiF,
|
|
133
|
+
heatIndexC: fToC(hiF),
|
|
134
|
+
risk: heatIndexRisk(hiF),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// NWS heat-index caution bands (°F thresholds):
|
|
139
|
+
// < 80 none (fallback region)
|
|
140
|
+
// 80 – 90 caution
|
|
141
|
+
// 90 – 103 extreme caution
|
|
142
|
+
// 103 – 124 danger
|
|
143
|
+
// >= 124 extreme danger
|
|
144
|
+
function heatIndexRisk(hiF) {
|
|
145
|
+
if (hiF < 80) return 'none';
|
|
146
|
+
if (hiF < 90) return 'caution';
|
|
147
|
+
if (hiF < 103) return 'extreme caution';
|
|
148
|
+
if (hiF < 124) return 'danger';
|
|
149
|
+
return 'extreme danger';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 2) HUMIDEX (Environment Canada)
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
/**
|
|
156
|
+
* Canadian humidex from air temperature and dew point.
|
|
157
|
+
*
|
|
158
|
+
* humidex = Tair + 0.5555 * (e - 10)
|
|
159
|
+
* e = 6.11 * exp(5417.7530 * (1/273.16 - 1/(Td + 273.15))) [hPa]
|
|
160
|
+
*
|
|
161
|
+
* Humidex is a unitless comfort index reported on the Celsius scale. If the
|
|
162
|
+
* dew point implies e <= 10 hPa (dry air) humidex is not defined above the air
|
|
163
|
+
* temperature, so we clamp to the air temperature (Environment Canada does not
|
|
164
|
+
* report a humidex below the air temperature).
|
|
165
|
+
*
|
|
166
|
+
* @param {number} tempC air temperature, °C
|
|
167
|
+
* @param {number} dewpointC dew-point temperature, °C
|
|
168
|
+
* @returns {{ humidex:number, risk:string }}
|
|
169
|
+
*/
|
|
170
|
+
function humidex(tempC, dewpointC) {
|
|
171
|
+
num('tempC', tempC);
|
|
172
|
+
num('dewpointC', dewpointC);
|
|
173
|
+
if (dewpointC > tempC + 1e-9) {
|
|
174
|
+
throw new RangeError(
|
|
175
|
+
`dewpointC (${dewpointC}) cannot exceed tempC (${tempC})`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const e = vapPressureHumidex(dewpointC);
|
|
180
|
+
const h = tempC + 0.5555 * (e - 10);
|
|
181
|
+
const value = h < tempC ? tempC : h;
|
|
182
|
+
|
|
183
|
+
return { humidex: value, risk: humidexRisk(value) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Environment Canada humidex comfort bands (°C-scale):
|
|
187
|
+
// < 30 none / comfortable
|
|
188
|
+
// 30 – 39 some discomfort
|
|
189
|
+
// 40 – 45 great discomfort; avoid exertion
|
|
190
|
+
// >= 46 dangerous; heat stroke possible
|
|
191
|
+
function humidexRisk(h) {
|
|
192
|
+
if (h < 30) return 'none';
|
|
193
|
+
if (h < 40) return 'some discomfort';
|
|
194
|
+
if (h < 46) return 'great discomfort';
|
|
195
|
+
return 'dangerous';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// 3) WBGT (Wet-Bulb Globe Temperature APPROXIMATION)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Approximate Wet-Bulb Globe Temperature.
|
|
203
|
+
*
|
|
204
|
+
* INDOOR / SHADED (default) — Australian Bureau of Meteorology regression:
|
|
205
|
+
* WBGT = 0.567 * Ta + 0.393 * e + 3.94
|
|
206
|
+
* e = (RH/100) * 6.105 * exp(17.27*Ta/(237.7+Ta)) [hPa]
|
|
207
|
+
* This needs only air temperature and humidity; it is the standard simple
|
|
208
|
+
* substitute when no black-globe sensor is available and there is no direct
|
|
209
|
+
* sun load (indoors, or fully shaded outdoors).
|
|
210
|
+
*
|
|
211
|
+
* OUTDOOR (opts.solarWm2 given) — the shade estimate PLUS a coarse solar-load
|
|
212
|
+
* term. Direct sun raises the globe temperature and hence WBGT; wind ventilates
|
|
213
|
+
* the globe and reduces it. This term is a rough heuristic, NOT a validated
|
|
214
|
+
* model — see the honest-limits note in README.md. The increment is:
|
|
215
|
+
* dSolar = 3.2 * (solarWm2 / 1000) / sqrt(1 + windMs) [°C], capped at 4 °C
|
|
216
|
+
* so full midday sun (~1000 W/m^2) in light wind adds a few °C, tapering with
|
|
217
|
+
* wind. For real occupational decisions in the sun, measure with a globe sensor.
|
|
218
|
+
*
|
|
219
|
+
* @param {number} tempC air temperature, °C
|
|
220
|
+
* @param {number} relHumidityPct relative humidity, percent 0..100
|
|
221
|
+
* @param {object} [opts]
|
|
222
|
+
* @param {number} [opts.solarWm2] global solar irradiance, W/m^2 (enables outdoor)
|
|
223
|
+
* @param {number} [opts.windMs=1] wind speed, m/s (outdoor ventilation)
|
|
224
|
+
* @returns {{ wbgtC:number, flag:string, mode:'indoor'|'outdoor' }}
|
|
225
|
+
*/
|
|
226
|
+
function wbgt(tempC, relHumidityPct, opts) {
|
|
227
|
+
num('tempC', tempC);
|
|
228
|
+
validateRh(relHumidityPct);
|
|
229
|
+
const o = opts || {};
|
|
230
|
+
|
|
231
|
+
const e = (relHumidityPct / 100) * satVapPressureBoM(tempC);
|
|
232
|
+
let wbgtC = 0.567 * tempC + 0.393 * e + 3.94;
|
|
233
|
+
let mode = 'indoor';
|
|
234
|
+
|
|
235
|
+
if (o.solarWm2 !== undefined && o.solarWm2 !== null) {
|
|
236
|
+
num('opts.solarWm2', o.solarWm2);
|
|
237
|
+
if (o.solarWm2 < 0) {
|
|
238
|
+
throw new RangeError(`opts.solarWm2 must be >= 0, got ${o.solarWm2}`);
|
|
239
|
+
}
|
|
240
|
+
let wind = o.windMs === undefined || o.windMs === null ? 1 : o.windMs;
|
|
241
|
+
num('opts.windMs', wind);
|
|
242
|
+
if (wind < 0) {
|
|
243
|
+
throw new RangeError(`opts.windMs must be >= 0, got ${wind}`);
|
|
244
|
+
}
|
|
245
|
+
let dSolar = (3.2 * (o.solarWm2 / 1000)) / Math.sqrt(1 + wind);
|
|
246
|
+
if (dSolar > 4) dSolar = 4; // cap the heuristic
|
|
247
|
+
wbgtC += dSolar;
|
|
248
|
+
mode = 'outdoor';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { wbgtC, flag: wbgtFlag(wbgtC), mode };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Classify a WBGT (°C) into the flag-colour band used by the US military and
|
|
256
|
+
* the American College of Sports Medicine. Boundaries (°C, converted from the
|
|
257
|
+
* canonical °F cut-points):
|
|
258
|
+
*
|
|
259
|
+
* white WBGT < 27.8 °C (< 82.0 °F) low
|
|
260
|
+
* green 27.8 – 29.3 °C (82.0–84.9 °F)
|
|
261
|
+
* yellow 29.4 – 31.0 °C (85.0–87.9 °F)
|
|
262
|
+
* red 31.1 – 32.1 °C (88.0–89.9 °F)
|
|
263
|
+
* black >= 32.2 °C (>= 90.0 °F) highest risk
|
|
264
|
+
*
|
|
265
|
+
* @param {number} wbgtC
|
|
266
|
+
* @returns {string} 'white' | 'green' | 'yellow' | 'red' | 'black'
|
|
267
|
+
*/
|
|
268
|
+
function wbgtFlag(wbgtC) {
|
|
269
|
+
num('wbgtC', wbgtC);
|
|
270
|
+
if (wbgtC >= 32.2) return 'black';
|
|
271
|
+
if (wbgtC >= 31.1) return 'red';
|
|
272
|
+
if (wbgtC >= 29.4) return 'yellow';
|
|
273
|
+
if (wbgtC >= 27.8) return 'green';
|
|
274
|
+
return 'white';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
heatIndex,
|
|
279
|
+
humidex,
|
|
280
|
+
wbgt,
|
|
281
|
+
wbgtFlag,
|
|
282
|
+
// low-level helpers, exported for testing / advanced use
|
|
283
|
+
heatIndexRisk,
|
|
284
|
+
humidexRisk,
|
|
285
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verifyhash/wbgt-heat-stress",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency heat-stress indices: NWS heat index, Environment Canada humidex, and a Wet-Bulb Globe Temperature (WBGT) approximation with risk flags.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/index.test.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"wbgt",
|
|
12
|
+
"wet-bulb-globe-temperature",
|
|
13
|
+
"heat-index",
|
|
14
|
+
"humidex",
|
|
15
|
+
"heat-stress",
|
|
16
|
+
"apparent-temperature",
|
|
17
|
+
"feels-like",
|
|
18
|
+
"occupational-safety",
|
|
19
|
+
"sports-safety",
|
|
20
|
+
"meteorology",
|
|
21
|
+
"zero-dependency"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/verifyhash/libs.git",
|
|
34
|
+
"directory": "wbgt-heat-stress"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/verifyhash/libs/tree/main/wbgt-heat-stress#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/verifyhash/libs/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|