@verifyhash/climate-normals 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 +218 -0
- package/index.js +338 -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,218 @@
|
|
|
1
|
+
# climate-normals
|
|
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 `climate-normals` 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 of climate math over **monthly
|
|
11
|
+
normals** — heating/cooling degree-days, apparent ("feels like") temperature,
|
|
12
|
+
diurnal range, a continentality index, and a monthly pleasantness score.
|
|
13
|
+
|
|
14
|
+
Every export is a **pure function**: no I/O, no network, no dependencies, no
|
|
15
|
+
mutation of its arguments. The formulas are lifted verbatim from the proven
|
|
16
|
+
[weatherhack](../../weatherhack) page generator (`generate.cjs`) — this package
|
|
17
|
+
is the shared engine those city pages already use, extracted so other tools can
|
|
18
|
+
reuse the exact same, already-validated math rather than reinventing it.
|
|
19
|
+
|
|
20
|
+
## Who it's for
|
|
21
|
+
|
|
22
|
+
- Building weather/climate pages, dashboards, or "best time to visit" tools and
|
|
23
|
+
wanting energy-demand, comfort, and climate-character numbers from a simple
|
|
24
|
+
12-month normals table.
|
|
25
|
+
- Anyone who needs the standard NWS Heat Index / Wind Chill or the standard
|
|
26
|
+
base-18 °C degree-day accounting as small, testable functions.
|
|
27
|
+
|
|
28
|
+
It is **not** a forecasting library and not a substitute for a metered energy
|
|
29
|
+
audit or published frost dates: it works on long-term monthly *averages*, so it
|
|
30
|
+
cannot capture an individual hot-and-humid afternoon, a cold-and-windy night, or
|
|
31
|
+
a late-spring frost. Use it for climatology, not day-level prediction.
|
|
32
|
+
|
|
33
|
+
## Install / use
|
|
34
|
+
|
|
35
|
+
No install step, no dependencies. Copy the folder in and require it:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const climate = require('./climate-normals');
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### The month object
|
|
42
|
+
|
|
43
|
+
A "normal" is one month's long-term average. The canonical shape is:
|
|
44
|
+
|
|
45
|
+
| field | meaning | unit | used by |
|
|
46
|
+
|-------|---------|------|---------|
|
|
47
|
+
| `hi` | average daily **high** temperature | °C | all temperature functions |
|
|
48
|
+
| `lo` | average daily **low** temperature | °C | all temperature functions |
|
|
49
|
+
| `pr` | average monthly **precipitation** | mm | `pleasantnessScore` / `rankMonths` |
|
|
50
|
+
| `rh` | average **relative humidity** | % | `apparentTemperature` (heat index) |
|
|
51
|
+
| `ws` | average **wind speed** | m/s | `apparentTemperature` (wind chill) |
|
|
52
|
+
|
|
53
|
+
A month may omit fields a given function doesn't read. Functions that summarise
|
|
54
|
+
a whole year take a `months` array of **exactly 12** entries in calendar order
|
|
55
|
+
(index 0 = January).
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
### `degreeDays(months, base = 18)`
|
|
60
|
+
|
|
61
|
+
Heating (HDD) and cooling (CDD) degree-days for a year.
|
|
62
|
+
|
|
63
|
+
For each month, with `mean = (hi + lo) / 2` and `days = DAYS_IN_MONTH[i]`
|
|
64
|
+
(non-leap: `[31,28,31,30,31,30,31,31,30,31,30,31]`):
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
HDD = max(0, base - mean) × days
|
|
68
|
+
CDD = max(0, mean - base) × days
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
summed over the 12 months. `base` defaults to **18 °C** — the point below which
|
|
72
|
+
buildings typically need heating and above which they need cooling. Degree-days
|
|
73
|
+
track roughly with heating and air-conditioning energy use.
|
|
74
|
+
|
|
75
|
+
Returns `{ rows, hdd, cdd, dominated }`: `rows[i] = { i, mean, hdd, cdd }` with
|
|
76
|
+
**raw (unrounded)** per-month degree-days; `hdd`/`cdd` are the **rounded** annual
|
|
77
|
+
totals (unitless °C·days); `dominated` is `'heating'` or `'cooling'` (ties count
|
|
78
|
+
as heating).
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
climate.degreeDays(london).hdd; // 2360 (base 18 °C)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `heatIndex(tC, rh)` → °C
|
|
85
|
+
|
|
86
|
+
NWS **Heat Index** (Rothfusz regression). Apparent temperature for air
|
|
87
|
+
temperature `tC` (°C) and relative humidity `rh` (%). The regression is defined
|
|
88
|
+
in °F, so the function converts in and out; below ~80 °F it uses the NWS simple
|
|
89
|
+
linear estimate, and it applies the NWS low-humidity (`rh < 13`) and
|
|
90
|
+
high-humidity (`rh > 85`) adjustment terms. Humidity can only make warm air feel
|
|
91
|
+
hotter. Example: `heatIndex(30.3, 86.8) ≈ 40.8 °C`.
|
|
92
|
+
|
|
93
|
+
### `windChill(tC, ws)` → °C
|
|
94
|
+
|
|
95
|
+
NWS **Wind Chill** (2001 revision). Apparent temperature for air temperature
|
|
96
|
+
`tC` (°C) and wind speed `ws` (m/s). Internally works in °C and km/h
|
|
97
|
+
(`km/h = ws × 3.6`); it is only defined above ~4.8 km/h, so at lower wind the air
|
|
98
|
+
temperature is returned unchanged, and the result is clamped to never read warmer
|
|
99
|
+
than `tC` (wind only makes cold air feel colder). Example:
|
|
100
|
+
`windChill(0, 10) ≈ -7.1 °C`.
|
|
101
|
+
|
|
102
|
+
### `apparentTemperature(month, opts?)`
|
|
103
|
+
|
|
104
|
+
Combines a month's temperature with its humidity and/or wind, exactly as the
|
|
105
|
+
weatherhack "feels like" section does:
|
|
106
|
+
|
|
107
|
+
- **feelsHi** = `heatIndex(hi, rh)` when `rh` is present **and** `hi ≥ warmThreshold`
|
|
108
|
+
(default **27 °C**) **and** the heat index reads hotter than `hi`; otherwise `hi`.
|
|
109
|
+
- **feelsLo** = `windChill(lo, ws)` when `ws` is present **and** `lo ≤ coldThreshold`
|
|
110
|
+
(default **10 °C**) **and** the wind chill reads colder than `lo`; otherwise `lo`.
|
|
111
|
+
|
|
112
|
+
`opts` may override `{ warmThreshold, coldThreshold }` (°C). Returns
|
|
113
|
+
`{ feelsHi, feelsLo, hiMode, loMode, dHot, dCold }` where `hiMode`/`loMode` are
|
|
114
|
+
`'actual'` or `'heat'`/`'wind'`, `dHot = feelsHi - hi` (≥0 °C, how much hotter
|
|
115
|
+
humidity makes the day) and `dCold = lo - feelsLo` (≥0 °C, how much colder wind
|
|
116
|
+
makes the night). With neither `rh` nor `ws`, it simply restates the thermometer.
|
|
117
|
+
|
|
118
|
+
### `diurnalRange(months)`
|
|
119
|
+
|
|
120
|
+
Day-to-night temperature swing across the year. For each month `swing = hi - lo`
|
|
121
|
+
(°C). Returns `{ swings, annual, big, small }`: `swings` is the 12 monthly
|
|
122
|
+
swings, `annual` is their mean (°C), and `big`/`small` are the month indices of
|
|
123
|
+
the largest and smallest swing (first month wins ties). Big swings are typical of
|
|
124
|
+
dry, continental, or high-altitude places; small swings of humid coastal ones.
|
|
125
|
+
|
|
126
|
+
### `continentality(months)`
|
|
127
|
+
|
|
128
|
+
Annual mean-temperature range and its continentality class. The warmest month is
|
|
129
|
+
the one with the highest average daily **high**, the coldest the one with the
|
|
130
|
+
lowest average daily **low**; the **range** is the difference between those two
|
|
131
|
+
months' **mean** daily temperatures (`(hi + lo) / 2`), rounded to one decimal °C.
|
|
132
|
+
Classification:
|
|
133
|
+
|
|
134
|
+
| range (°C) | `klass` |
|
|
135
|
+
|------------|---------|
|
|
136
|
+
| `< 10` | `maritime / oceanic` |
|
|
137
|
+
| `10 – 20` (inclusive) | `transitional / moderately continental` |
|
|
138
|
+
| `> 20` | `continental` |
|
|
139
|
+
|
|
140
|
+
Returns `{ warm, cold, warmMean, coldMean, range, klass }` (`warm`/`cold` are
|
|
141
|
+
month indices; `warmMean`/`coldMean`/`range` are °C).
|
|
142
|
+
|
|
143
|
+
### `pleasantnessScore(month)`
|
|
144
|
+
|
|
145
|
+
Comfort score for a single month — **lower is more comfortable**. With
|
|
146
|
+
`mean = (hi + lo) / 2`:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
tempPenalty = mean < 18 ? 18 - mean : mean > 24 ? mean - 24 : 0
|
|
150
|
+
rainPenalty = (pr ?? 0) × 0.03 // ≈100 mm ~ being 3 °C off the band
|
|
151
|
+
score = tempPenalty + rainPenalty
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The comfort band is **18–24 °C** mean daily temperature; a month inside it with
|
|
155
|
+
no rain scores 0. Returns `{ mean, tempPenalty, rainPenalty, score }` (mean in
|
|
156
|
+
°C; penalties/score are unitless heuristic points). This is a deliberately simple,
|
|
157
|
+
transparent heuristic, not a physiological comfort model.
|
|
158
|
+
|
|
159
|
+
### `rankMonths(months)`
|
|
160
|
+
|
|
161
|
+
Ranks a 12-month year from most to least pleasant using `pleasantnessScore`.
|
|
162
|
+
Returns `{ scored, ranked }`: `scored[i]` adds `{ i }` to each month's score
|
|
163
|
+
object; `ranked` is an array of month indices, best-first (ties keep calendar
|
|
164
|
+
order).
|
|
165
|
+
|
|
166
|
+
### Constants
|
|
167
|
+
|
|
168
|
+
Exported for reference and reuse: `DAYS_IN_MONTH`, `HDD_BASE` (18),
|
|
169
|
+
`COMFORT_LO` (18), `COMFORT_HI` (24), `RAIN_PENALTY_PER_MM` (0.03),
|
|
170
|
+
`FEELS_WARM_HI` (27), `FEELS_COLD_LO` (10), and the helper `meanTemp(month)`.
|
|
171
|
+
|
|
172
|
+
## Running the tests
|
|
173
|
+
|
|
174
|
+
One command, from this directory:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
node test/index.test.js
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It prints one `ok - …` line per test and exits **0** when all pass, non-zero on
|
|
181
|
+
the first failure (via `node:assert`). The suite has no dependencies and makes no
|
|
182
|
+
network calls. It covers every function with a real-city fixture (London and
|
|
183
|
+
Singapore 2020–2024 ERA5 normals) plus edge cases: all-cold and all-warm cities,
|
|
184
|
+
custom degree-day bases, the continentality threshold boundaries, missing
|
|
185
|
+
humidity/wind, missing precipitation, tie-breaking, and input-mutation checks.
|
|
186
|
+
|
|
187
|
+
## Provenance
|
|
188
|
+
|
|
189
|
+
Formulas ported from `weatherhack/generate.cjs`:
|
|
190
|
+
`degreeDays()`, `heatIndexC()` / `windChillC()` / `feelsLikeStats()`,
|
|
191
|
+
`diurnalStats()`, `annualRangeStats()`, and `bestTimeToVisit()`'s comfort
|
|
192
|
+
heuristic (`comfortScore()`). The normals fixtures come from weatherhack's ERA5
|
|
193
|
+
2020–2024 dataset. No math was invented here.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT.
|
|
198
|
+
|
|
199
|
+
## Install
|
|
200
|
+
|
|
201
|
+
> Placeholder name: the `climate-normals` below is the working slug, not a finalized
|
|
202
|
+
> npm name — see the owner TODO near the top of this README.
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
npm install climate-normals
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
const climate = require('climate-normals');
|
|
210
|
+
|
|
211
|
+
const july = { hi: 23.5, lo: 14.0, pr: 45, rh: 65, ws: 3.2 };
|
|
212
|
+
climate.meanTemp(july); // 18.75 (°C, midpoint of hi/lo)
|
|
213
|
+
climate.apparentTemperature(july); // { feelsHi, feelsLo, hiMode, loMode, ... }
|
|
214
|
+
climate.pleasantnessScore(july); // { mean, tempPenalty, rainPenalty, score }
|
|
215
|
+
|
|
216
|
+
// Whole-year helpers take a 12-element array (index 0 = January):
|
|
217
|
+
// climate.degreeDays(months).hdd → heating degree-days (base 18 °C)
|
|
218
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* climate-normals — zero-dependency climate math over monthly normals.
|
|
5
|
+
*
|
|
6
|
+
* Every export here is a PURE function: no I/O, no network, no globals mutated.
|
|
7
|
+
* The formulas are lifted verbatim from the proven weatherhack generator
|
|
8
|
+
* (weatherhack/generate.cjs), not reinvented — see README.md for the exact
|
|
9
|
+
* source function each one ports.
|
|
10
|
+
*
|
|
11
|
+
* A "normal" is one month's long-term average. The canonical month object is:
|
|
12
|
+
* { hi, lo, pr, rh, ws }
|
|
13
|
+
* hi — average daily HIGH temperature, °C
|
|
14
|
+
* lo — average daily LOW temperature, °C
|
|
15
|
+
* pr — average monthly PRECIPITATION, mm (pleasantness only)
|
|
16
|
+
* rh — average RELATIVE HUMIDITY, % (apparent-temp only)
|
|
17
|
+
* ws — average WIND SPEED, m/s (apparent-temp only)
|
|
18
|
+
* Functions only read the fields they document; a month may omit the rest.
|
|
19
|
+
*
|
|
20
|
+
* "months" arrays are expected in calendar order (index 0 = January). Functions
|
|
21
|
+
* that summarise a whole year require exactly 12 entries.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Documented constants (match generate.cjs)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Non-leap days per month (Feb = 28). Degree-day totals are climatological
|
|
29
|
+
// estimates, so the leap-day rounding is immaterial. (generate.cjs L902)
|
|
30
|
+
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
31
|
+
|
|
32
|
+
// Default degree-day base temperature, °C (generate.cjs HDD_BASE, L911).
|
|
33
|
+
const HDD_BASE = 18;
|
|
34
|
+
|
|
35
|
+
// Comfort band, °C mean daily temperature, for the pleasantness heuristic
|
|
36
|
+
// (generate.cjs COMFORT_LO / COMFORT_HI, L507).
|
|
37
|
+
const COMFORT_LO = 18;
|
|
38
|
+
const COMFORT_HI = 24;
|
|
39
|
+
|
|
40
|
+
// Rain penalty per mm for the pleasantness heuristic (generate.cjs L520:
|
|
41
|
+
// `v.pr * 0.03`) — ~100 mm costs about as much as being 3 °C off the band.
|
|
42
|
+
const RAIN_PENALTY_PER_MM = 0.03;
|
|
43
|
+
|
|
44
|
+
// Thresholds that decide when humidity / wind are worth folding into the
|
|
45
|
+
// apparent temperature (generate.cjs FEELS_WARM_HI / FEELS_COLD_LO, L1691).
|
|
46
|
+
const FEELS_WARM_HI = 27; // avg high (°C) at/above which the heat index applies
|
|
47
|
+
const FEELS_COLD_LO = 10; // avg low (°C) at/below which wind chill applies
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Small internal helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function requireMonths(months, needFull) {
|
|
54
|
+
if (!Array.isArray(months)) {
|
|
55
|
+
throw new TypeError('months must be an array');
|
|
56
|
+
}
|
|
57
|
+
if (needFull && months.length !== 12) {
|
|
58
|
+
throw new RangeError('months must have exactly 12 entries (one per calendar month)');
|
|
59
|
+
}
|
|
60
|
+
return months;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function num(x, field) {
|
|
64
|
+
if (typeof x !== 'number' || !Number.isFinite(x)) {
|
|
65
|
+
throw new TypeError('month.' + field + ' must be a finite number');
|
|
66
|
+
}
|
|
67
|
+
return x;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mean daily temperature of a month = (hi + lo) / 2, in °C.
|
|
72
|
+
* (generate.cjs uses `(v.hi + v.lo) / 2` throughout.)
|
|
73
|
+
*/
|
|
74
|
+
function meanTemp(month) {
|
|
75
|
+
return (num(month.hi, 'hi') + num(month.lo, 'lo')) / 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 1. Heating & cooling degree-days (ports degreeDays(), generate.cjs L912)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Heating & cooling degree-days for a year of monthly normals.
|
|
84
|
+
*
|
|
85
|
+
* For each month, with mean = (hi + lo) / 2 and days = DAYS_IN_MONTH[i]:
|
|
86
|
+
* HDD = max(0, base - mean) × days
|
|
87
|
+
* CDD = max(0, mean - base) × days
|
|
88
|
+
* summed over the year. Base defaults to 18 °C — the point below which
|
|
89
|
+
* buildings typically need heating and above which they need cooling.
|
|
90
|
+
*
|
|
91
|
+
* @param {Array<{hi:number,lo:number}>} months exactly 12 months, Jan..Dec
|
|
92
|
+
* @param {number} [base=18] base temperature, °C
|
|
93
|
+
* @returns {{rows:Array<{i:number,mean:number,hdd:number,cdd:number}>,
|
|
94
|
+
* hdd:number, cdd:number, dominated:'heating'|'cooling'}}
|
|
95
|
+
* rows hold per-month raw (unrounded) degree-days; hdd/cdd are the
|
|
96
|
+
* rounded annual totals; `dominated` is whichever total is larger
|
|
97
|
+
* (ties count as heating, matching generate.cjs `hdd >= cdd`).
|
|
98
|
+
*/
|
|
99
|
+
function degreeDays(months, base = HDD_BASE) {
|
|
100
|
+
requireMonths(months, true);
|
|
101
|
+
if (typeof base !== 'number' || !Number.isFinite(base)) {
|
|
102
|
+
throw new TypeError('base must be a finite number');
|
|
103
|
+
}
|
|
104
|
+
let hddTotal = 0;
|
|
105
|
+
let cddTotal = 0;
|
|
106
|
+
const rows = months.map((v, i) => {
|
|
107
|
+
const mean = meanTemp(v);
|
|
108
|
+
const days = DAYS_IN_MONTH[i];
|
|
109
|
+
const hdd = Math.max(0, base - mean) * days;
|
|
110
|
+
const cdd = Math.max(0, mean - base) * days;
|
|
111
|
+
hddTotal += hdd;
|
|
112
|
+
cddTotal += cdd;
|
|
113
|
+
return { i, mean, hdd, cdd };
|
|
114
|
+
});
|
|
115
|
+
const hdd = Math.round(hddTotal);
|
|
116
|
+
const cdd = Math.round(cddTotal);
|
|
117
|
+
return { rows, hdd, cdd, dominated: hdd >= cdd ? 'heating' : 'cooling' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 2. Apparent temperature (ports heatIndexC/windChillC/feelsLikeStats,
|
|
122
|
+
// generate.cjs L1658 / L1680 / L1708)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* NWS Heat Index (Rothfusz regression). Returns the apparent ("feels like")
|
|
127
|
+
* temperature in °C for air temperature `tC` (°C) and relative humidity
|
|
128
|
+
* `rh` (%). The regression is defined in °F, so we convert in and out. Below
|
|
129
|
+
* ~80 °F the simpler linear estimate is used; the low-/high-humidity
|
|
130
|
+
* adjustments match the NWS reference implementation. (generate.cjs L1658)
|
|
131
|
+
*/
|
|
132
|
+
function heatIndex(tC, rh) {
|
|
133
|
+
num(tC, 'tC');
|
|
134
|
+
num(rh, 'rh');
|
|
135
|
+
const T = tC * 9 / 5 + 32; // °F
|
|
136
|
+
let hiF = 0.5 * (T + 61 + (T - 68) * 1.2 + rh * 0.094);
|
|
137
|
+
if ((hiF + T) / 2 >= 80) {
|
|
138
|
+
hiF = -42.379 + 2.04901523 * T + 10.14333127 * rh
|
|
139
|
+
- 0.22475541 * T * rh - 0.00683783 * T * T - 0.05481717 * rh * rh
|
|
140
|
+
+ 0.00122874 * T * T * rh + 0.00085282 * T * rh * rh
|
|
141
|
+
- 0.00000199 * T * T * rh * rh;
|
|
142
|
+
if (rh < 13 && T >= 80 && T <= 112) {
|
|
143
|
+
hiF -= ((13 - rh) / 4) * Math.sqrt((17 - Math.abs(T - 95)) / 17);
|
|
144
|
+
} else if (rh > 85 && T >= 80 && T <= 87) {
|
|
145
|
+
hiF += ((rh - 85) / 10) * ((87 - T) / 5);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return (hiF - 32) * 5 / 9;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* NWS wind chill (2001 revision). Returns the apparent temperature in °C for
|
|
153
|
+
* air temperature `tC` (°C) and wind speed `ws` (m/s). The formula works in
|
|
154
|
+
* °C and km/h and is only defined above ~4.8 km/h, so below that the air
|
|
155
|
+
* temperature is returned unchanged; wind can only make cold air feel colder,
|
|
156
|
+
* so the result is never warmer than `tC`. (generate.cjs L1680)
|
|
157
|
+
*/
|
|
158
|
+
function windChill(tC, ws) {
|
|
159
|
+
num(tC, 'tC');
|
|
160
|
+
num(ws, 'ws');
|
|
161
|
+
const V = ws * 3.6; // m/s -> km/h
|
|
162
|
+
if (V < 4.8) return tC;
|
|
163
|
+
const p = Math.pow(V, 0.16);
|
|
164
|
+
const wc = 13.12 + 0.6215 * tC - 11.37 * p + 0.3965 * tC * p;
|
|
165
|
+
return Math.min(wc, tC);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Apparent ("feels like") daily high and low for one month of normals,
|
|
170
|
+
* combining temperature with humidity and/or wind exactly as feelsLikeStats()
|
|
171
|
+
* does (generate.cjs L1723-1737):
|
|
172
|
+
* - feelsHi = heatIndex(hi, rh) when rh is present AND hi >= warmThreshold
|
|
173
|
+
* AND the heat index reads hotter than hi; else hi.
|
|
174
|
+
* - feelsLo = windChill(lo, ws) when ws is present AND lo <= coldThreshold
|
|
175
|
+
* AND the wind chill reads colder than lo; else lo.
|
|
176
|
+
*
|
|
177
|
+
* @param {{hi:number,lo:number,rh?:number,ws?:number}} month
|
|
178
|
+
* @param {{warmThreshold?:number, coldThreshold?:number}} [opts]
|
|
179
|
+
* @returns {{feelsHi:number, feelsLo:number,
|
|
180
|
+
* hiMode:'actual'|'heat', loMode:'actual'|'wind',
|
|
181
|
+
* dHot:number, dCold:number}}
|
|
182
|
+
* dHot = feelsHi - hi (≥0, how much hotter humidity makes the day);
|
|
183
|
+
* dCold = lo - feelsLo (≥0, how much colder wind makes the night).
|
|
184
|
+
*/
|
|
185
|
+
function apparentTemperature(month, opts = {}) {
|
|
186
|
+
const warm = opts.warmThreshold == null ? FEELS_WARM_HI : opts.warmThreshold;
|
|
187
|
+
const cold = opts.coldThreshold == null ? FEELS_COLD_LO : opts.coldThreshold;
|
|
188
|
+
const hi = num(month.hi, 'hi');
|
|
189
|
+
const lo = num(month.lo, 'lo');
|
|
190
|
+
let feelsHi = hi;
|
|
191
|
+
let feelsLo = lo;
|
|
192
|
+
let hiMode = 'actual';
|
|
193
|
+
let loMode = 'actual';
|
|
194
|
+
if (typeof month.rh === 'number' && Number.isFinite(month.rh) && hi >= warm) {
|
|
195
|
+
const hi2 = heatIndex(hi, month.rh);
|
|
196
|
+
if (hi2 > hi) { feelsHi = hi2; hiMode = 'heat'; }
|
|
197
|
+
}
|
|
198
|
+
if (typeof month.ws === 'number' && Number.isFinite(month.ws) && lo <= cold) {
|
|
199
|
+
const lo2 = windChill(lo, month.ws);
|
|
200
|
+
if (lo2 < lo) { feelsLo = lo2; loMode = 'wind'; }
|
|
201
|
+
}
|
|
202
|
+
return { feelsHi, feelsLo, hiMode, loMode, dHot: feelsHi - hi, dCold: lo - feelsLo };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// 3. Diurnal range (ports diurnalStats(), generate.cjs L1111)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Day-to-night temperature swing across a year of monthly normals.
|
|
211
|
+
* For each month swing = hi - lo (°C); `annual` is the mean of the 12 swings;
|
|
212
|
+
* `big`/`small` are the indices of the largest and smallest swings.
|
|
213
|
+
* (First month wins ties, matching generate.cjs strict `>` / `<`.)
|
|
214
|
+
*
|
|
215
|
+
* @param {Array<{hi:number,lo:number}>} months exactly 12 months, Jan..Dec
|
|
216
|
+
* @returns {{swings:number[], annual:number, big:number, small:number}}
|
|
217
|
+
*/
|
|
218
|
+
function diurnalRange(months) {
|
|
219
|
+
requireMonths(months, true);
|
|
220
|
+
const swings = [];
|
|
221
|
+
let big = 0;
|
|
222
|
+
let small = 0;
|
|
223
|
+
let sum = 0;
|
|
224
|
+
for (let i = 0; i < 12; i++) {
|
|
225
|
+
const sw = num(months[i].hi, 'hi') - num(months[i].lo, 'lo');
|
|
226
|
+
swings.push(sw);
|
|
227
|
+
sum += sw;
|
|
228
|
+
if (sw > swings[big]) big = i;
|
|
229
|
+
if (sw < swings[small]) small = i;
|
|
230
|
+
}
|
|
231
|
+
return { swings, annual: sum / 12, big, small };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// 4. Continentality (ports annualRangeStats(), generate.cjs L373)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Annual mean-temperature range and its continentality classification.
|
|
240
|
+
* The warmest month is the one with the highest average daily HIGH and the
|
|
241
|
+
* coldest the one with the lowest average daily LOW; the range is the
|
|
242
|
+
* difference between those two months' MEAN daily temperatures, rounded to
|
|
243
|
+
* one decimal °C. Classification thresholds (generate.cjs L388-397):
|
|
244
|
+
* range < 10 → 'maritime / oceanic'
|
|
245
|
+
* 10 <= range <= 20 → 'transitional / moderately continental'
|
|
246
|
+
* range > 20 → 'continental'
|
|
247
|
+
*
|
|
248
|
+
* @param {Array<{hi:number,lo:number}>} months exactly 12 months, Jan..Dec
|
|
249
|
+
* @returns {{warm:number, cold:number, warmMean:number, coldMean:number,
|
|
250
|
+
* range:number, klass:string}}
|
|
251
|
+
*/
|
|
252
|
+
function continentality(months) {
|
|
253
|
+
requireMonths(months, true);
|
|
254
|
+
for (let i = 0; i < 12; i++) {
|
|
255
|
+
num(months[i].hi, 'hi');
|
|
256
|
+
num(months[i].lo, 'lo');
|
|
257
|
+
}
|
|
258
|
+
let warm = 0;
|
|
259
|
+
let cold = 0;
|
|
260
|
+
for (let i = 1; i < 12; i++) {
|
|
261
|
+
if (months[i].hi > months[warm].hi) warm = i;
|
|
262
|
+
if (months[i].lo < months[cold].lo) cold = i;
|
|
263
|
+
}
|
|
264
|
+
const warmMean = (months[warm].hi + months[warm].lo) / 2;
|
|
265
|
+
const coldMean = (months[cold].hi + months[cold].lo) / 2;
|
|
266
|
+
const range = Math.round((warmMean - coldMean) * 10) / 10;
|
|
267
|
+
let klass;
|
|
268
|
+
if (range < 10) klass = 'maritime / oceanic';
|
|
269
|
+
else if (range <= 20) klass = 'transitional / moderately continental';
|
|
270
|
+
else klass = 'continental';
|
|
271
|
+
return { warm, cold, warmMean, coldMean, range, klass };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// 5. Monthly pleasantness score (ports bestTimeToVisit()'s heuristic /
|
|
276
|
+
// comfortScore(), generate.cjs L516-521 / L2181)
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Pleasantness (comfort) score for a single month. LOWER is more comfortable.
|
|
281
|
+
* mean = (hi + lo) / 2
|
|
282
|
+
* tempPenalty = mean < 18 ? 18 - mean : mean > 24 ? mean - 24 : 0
|
|
283
|
+
* rainPenalty = pr * 0.03 (pr defaults to 0 when absent)
|
|
284
|
+
* score = tempPenalty + rainPenalty
|
|
285
|
+
* A month sitting inside the 18–24 °C band with no rain scores 0.
|
|
286
|
+
*
|
|
287
|
+
* @param {{hi:number,lo:number,pr?:number}} month
|
|
288
|
+
* @returns {{mean:number, tempPenalty:number, rainPenalty:number, score:number}}
|
|
289
|
+
*/
|
|
290
|
+
function pleasantnessScore(month) {
|
|
291
|
+
const mean = meanTemp(month);
|
|
292
|
+
const tempPenalty = mean < COMFORT_LO ? COMFORT_LO - mean
|
|
293
|
+
: mean > COMFORT_HI ? mean - COMFORT_HI : 0;
|
|
294
|
+
let pr = 0;
|
|
295
|
+
if (month.pr != null) pr = num(month.pr, 'pr');
|
|
296
|
+
const rainPenalty = pr * RAIN_PENALTY_PER_MM;
|
|
297
|
+
return { mean, tempPenalty, rainPenalty, score: tempPenalty + rainPenalty };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Rank a year of months from most to least pleasant (ascending score).
|
|
302
|
+
* Stable-ish: ties keep calendar order because Array.prototype.sort is stable
|
|
303
|
+
* in Node and we sort a mapped copy. Returns per-month scored rows plus the
|
|
304
|
+
* ranked index order.
|
|
305
|
+
*
|
|
306
|
+
* @param {Array<{hi:number,lo:number,pr?:number}>} months exactly 12 months
|
|
307
|
+
* @returns {{scored:Array<{i:number,mean:number,tempPenalty:number,
|
|
308
|
+
* rainPenalty:number,score:number}>, ranked:number[]}}
|
|
309
|
+
* `ranked` is month indices best-first.
|
|
310
|
+
*/
|
|
311
|
+
function rankMonths(months) {
|
|
312
|
+
requireMonths(months, true);
|
|
313
|
+
const scored = months.map((v, i) => Object.assign({ i }, pleasantnessScore(v)));
|
|
314
|
+
const ranked = scored.slice().sort((a, b) => a.score - b.score).map((x) => x.i);
|
|
315
|
+
return { scored, ranked };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
// constants
|
|
320
|
+
DAYS_IN_MONTH,
|
|
321
|
+
HDD_BASE,
|
|
322
|
+
COMFORT_LO,
|
|
323
|
+
COMFORT_HI,
|
|
324
|
+
RAIN_PENALTY_PER_MM,
|
|
325
|
+
FEELS_WARM_HI,
|
|
326
|
+
FEELS_COLD_LO,
|
|
327
|
+
// helpers
|
|
328
|
+
meanTemp,
|
|
329
|
+
// functions
|
|
330
|
+
degreeDays,
|
|
331
|
+
heatIndex,
|
|
332
|
+
windChill,
|
|
333
|
+
apparentTemperature,
|
|
334
|
+
diurnalRange,
|
|
335
|
+
continentality,
|
|
336
|
+
pleasantnessScore,
|
|
337
|
+
rankMonths,
|
|
338
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verifyhash/climate-normals",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency climate math over monthly normals: heating/cooling degree-days, apparent (\"feels like\") temperature, diurnal range, a continentality index, and a monthly pleasantness score.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/index.test.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"climate",
|
|
12
|
+
"climate-normals",
|
|
13
|
+
"degree-days",
|
|
14
|
+
"heating-degree-days",
|
|
15
|
+
"cooling-degree-days",
|
|
16
|
+
"apparent-temperature",
|
|
17
|
+
"heat-index",
|
|
18
|
+
"wind-chill",
|
|
19
|
+
"continentality",
|
|
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": "climate-normals"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/verifyhash/libs/tree/main/climate-normals#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/verifyhash/libs/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|