@verifyhash/climate-normals
v0.1.0
Published
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.
Maintainers
Readme
climate-normals
TODO (owner): pick the final npm name/scope before publishing. The npm package name is not finalized by this project. Convention used here:
package.jsonkeeps the working slugclimate-normalsas a placeholder — replace it (and thenpm installline below) with the real published name/scope before runningnpm publish.
A tiny, zero-dependency Node library of climate math over monthly normals — heating/cooling degree-days, apparent ("feels like") temperature, diurnal range, a continentality index, and a monthly pleasantness score.
Every export is a pure function: no I/O, no network, no dependencies, no
mutation of its arguments. The formulas are lifted verbatim from the proven
weatherhack page generator (generate.cjs) — this package
is the shared engine those city pages already use, extracted so other tools can
reuse the exact same, already-validated math rather than reinventing it.
Who it's for
- Building weather/climate pages, dashboards, or "best time to visit" tools and wanting energy-demand, comfort, and climate-character numbers from a simple 12-month normals table.
- Anyone who needs the standard NWS Heat Index / Wind Chill or the standard base-18 °C degree-day accounting as small, testable functions.
It is not a forecasting library and not a substitute for a metered energy audit or published frost dates: it works on long-term monthly averages, so it cannot capture an individual hot-and-humid afternoon, a cold-and-windy night, or a late-spring frost. Use it for climatology, not day-level prediction.
Install / use
No install step, no dependencies. Copy the folder in and require it:
const climate = require('./climate-normals');The month object
A "normal" is one month's long-term average. The canonical shape is:
| field | meaning | unit | used by |
|-------|---------|------|---------|
| hi | average daily high temperature | °C | all temperature functions |
| lo | average daily low temperature | °C | all temperature functions |
| pr | average monthly precipitation | mm | pleasantnessScore / rankMonths |
| rh | average relative humidity | % | apparentTemperature (heat index) |
| ws | average wind speed | m/s | apparentTemperature (wind chill) |
A month may omit fields a given function doesn't read. Functions that summarise
a whole year take a months array of exactly 12 entries in calendar order
(index 0 = January).
API
degreeDays(months, base = 18)
Heating (HDD) and cooling (CDD) degree-days for a year.
For each month, with mean = (hi + lo) / 2 and days = DAYS_IN_MONTH[i]
(non-leap: [31,28,31,30,31,30,31,31,30,31,30,31]):
HDD = max(0, base - mean) × days
CDD = max(0, mean - base) × dayssummed over the 12 months. base defaults to 18 °C — the point below which
buildings typically need heating and above which they need cooling. Degree-days
track roughly with heating and air-conditioning energy use.
Returns { rows, hdd, cdd, dominated }: rows[i] = { i, mean, hdd, cdd } with
raw (unrounded) per-month degree-days; hdd/cdd are the rounded annual
totals (unitless °C·days); dominated is 'heating' or 'cooling' (ties count
as heating).
climate.degreeDays(london).hdd; // 2360 (base 18 °C)heatIndex(tC, rh) → °C
NWS Heat Index (Rothfusz regression). Apparent temperature for air
temperature tC (°C) and relative humidity rh (%). The regression is defined
in °F, so the function converts in and out; below ~80 °F it uses the NWS simple
linear estimate, and it applies the NWS low-humidity (rh < 13) and
high-humidity (rh > 85) adjustment terms. Humidity can only make warm air feel
hotter. Example: heatIndex(30.3, 86.8) ≈ 40.8 °C.
windChill(tC, ws) → °C
NWS Wind Chill (2001 revision). Apparent temperature for air temperature
tC (°C) and wind speed ws (m/s). Internally works in °C and km/h
(km/h = ws × 3.6); it is only defined above ~4.8 km/h, so at lower wind the air
temperature is returned unchanged, and the result is clamped to never read warmer
than tC (wind only makes cold air feel colder). Example:
windChill(0, 10) ≈ -7.1 °C.
apparentTemperature(month, opts?)
Combines a month's temperature with its humidity and/or wind, exactly as the weatherhack "feels like" section does:
- feelsHi =
heatIndex(hi, rh)whenrhis present andhi ≥ warmThreshold(default 27 °C) and the heat index reads hotter thanhi; otherwisehi. - feelsLo =
windChill(lo, ws)whenwsis present andlo ≤ coldThreshold(default 10 °C) and the wind chill reads colder thanlo; otherwiselo.
opts may override { warmThreshold, coldThreshold } (°C). Returns
{ feelsHi, feelsLo, hiMode, loMode, dHot, dCold } where hiMode/loMode are
'actual' or 'heat'/'wind', dHot = feelsHi - hi (≥0 °C, how much hotter
humidity makes the day) and dCold = lo - feelsLo (≥0 °C, how much colder wind
makes the night). With neither rh nor ws, it simply restates the thermometer.
diurnalRange(months)
Day-to-night temperature swing across the year. For each month swing = hi - lo
(°C). Returns { swings, annual, big, small }: swings is the 12 monthly
swings, annual is their mean (°C), and big/small are the month indices of
the largest and smallest swing (first month wins ties). Big swings are typical of
dry, continental, or high-altitude places; small swings of humid coastal ones.
continentality(months)
Annual mean-temperature range and its continentality class. The warmest month is
the one with the highest average daily high, the coldest the one with the
lowest average daily low; the range is the difference between those two
months' mean daily temperatures ((hi + lo) / 2), rounded to one decimal °C.
Classification:
| range (°C) | klass |
|------------|---------|
| < 10 | maritime / oceanic |
| 10 – 20 (inclusive) | transitional / moderately continental |
| > 20 | continental |
Returns { warm, cold, warmMean, coldMean, range, klass } (warm/cold are
month indices; warmMean/coldMean/range are °C).
pleasantnessScore(month)
Comfort score for a single month — lower is more comfortable. With
mean = (hi + lo) / 2:
tempPenalty = mean < 18 ? 18 - mean : mean > 24 ? mean - 24 : 0
rainPenalty = (pr ?? 0) × 0.03 // ≈100 mm ~ being 3 °C off the band
score = tempPenalty + rainPenaltyThe comfort band is 18–24 °C mean daily temperature; a month inside it with
no rain scores 0. Returns { mean, tempPenalty, rainPenalty, score } (mean in
°C; penalties/score are unitless heuristic points). This is a deliberately simple,
transparent heuristic, not a physiological comfort model.
rankMonths(months)
Ranks a 12-month year from most to least pleasant using pleasantnessScore.
Returns { scored, ranked }: scored[i] adds { i } to each month's score
object; ranked is an array of month indices, best-first (ties keep calendar
order).
Constants
Exported for reference and reuse: DAYS_IN_MONTH, HDD_BASE (18),
COMFORT_LO (18), COMFORT_HI (24), RAIN_PENALTY_PER_MM (0.03),
FEELS_WARM_HI (27), FEELS_COLD_LO (10), and the helper meanTemp(month).
Running the tests
One command, from this directory:
node test/index.test.jsIt prints one ok - … line per test and exits 0 when all pass, non-zero on
the first failure (via node:assert). The suite has no dependencies and makes no
network calls. It covers every function with a real-city fixture (London and
Singapore 2020–2024 ERA5 normals) plus edge cases: all-cold and all-warm cities,
custom degree-day bases, the continentality threshold boundaries, missing
humidity/wind, missing precipitation, tie-breaking, and input-mutation checks.
Provenance
Formulas ported from weatherhack/generate.cjs:
degreeDays(), heatIndexC() / windChillC() / feelsLikeStats(),
diurnalStats(), annualRangeStats(), and bestTimeToVisit()'s comfort
heuristic (comfortScore()). The normals fixtures come from weatherhack's ERA5
2020–2024 dataset. No math was invented here.
License
MIT.
Install
Placeholder name: the
climate-normalsbelow is the working slug, not a finalized npm name — see the owner TODO near the top of this README.
npm install climate-normalsconst climate = require('climate-normals');
const july = { hi: 23.5, lo: 14.0, pr: 45, rh: 65, ws: 3.2 };
climate.meanTemp(july); // 18.75 (°C, midpoint of hi/lo)
climate.apparentTemperature(july); // { feelsHi, feelsLo, hiMode, loMode, ... }
climate.pleasantnessScore(july); // { mean, tempPenalty, rainPenalty, score }
// Whole-year helpers take a 12-element array (index 0 = January):
// climate.degreeDays(months).hdd → heating degree-days (base 18 °C)