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