@verifyhash/koppen-classifier 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 +152 -0
  3. package/index.js +167 -0
  4. package/package.json +35 -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,152 @@
1
+ # koppen-classifier
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 `koppen-classifier` 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 that assigns a location its
11
+ **Köppen–Geiger climate class** (e.g. `Cfa`, `BWh`, `Dfb`) from twelve monthly
12
+ mean temperatures and twelve monthly precipitation totals. Pure function, no
13
+ network, no files, no state — give it numbers, get back a code.
14
+
15
+ The classification logic is a direct port of the **proven** `koppenClass()`
16
+ that has run in production on [weatherhack.com](https://weatherhack.com) to
17
+ label real cities from ERA5 normals. It was lifted, not re-derived, so its
18
+ boundaries match what that site already ships.
19
+
20
+ ## Who it's for
21
+
22
+ Anyone who has monthly climate normals and wants the Köppen letter code without
23
+ pulling in a heavy geodata package: map/dashboard builders, teaching material,
24
+ data-pipeline enrichment, quiz/trivia generators, worldbuilding tools.
25
+
26
+ ## Install / use
27
+
28
+ No install step for local use — it's one file with no dependencies. Copy the
29
+ folder in, or `require('./koppen-classifier')`.
30
+
31
+ ```js
32
+ const { classify } = require('koppen-classifier');
33
+
34
+ // London, UK — monthly arrays run January → December.
35
+ const london = classify({
36
+ tempsC: [5.2, 5.3, 7.6, 9.6, 12.9, 16.0, 18.1, 17.8, 15.2, 11.4, 7.8, 5.5],
37
+ precipMm: [55, 41, 42, 44, 49, 45, 45, 50, 49, 69, 59, 55],
38
+ lat: 51.5,
39
+ });
40
+
41
+ console.log(london.code); // 'Cfb'
42
+ console.log(london.label); // 'temperate oceanic'
43
+ console.log(london.groupLabel); // 'Temperate'
44
+ ```
45
+
46
+ ## API
47
+
48
+ ### `classify(options) → result`
49
+
50
+ **`options`**
51
+
52
+ | field | type | required | notes |
53
+ |--------------|------------|----------|-------|
54
+ | `tempsC` | `number[]` | yes | 12 monthly **mean** temperatures in °C, **Jan → Dec**. |
55
+ | `precipMm` | `number[]` | yes | 12 monthly precipitation totals in mm, **Jan → Dec**. |
56
+ | `lat` | `number` | one of these | Signed latitude, north positive. Only its **sign** is used, to pick the warm half-year. |
57
+ | `hemisphere` | `string` | one of these | `'N'` / `'S'` (also `'north'` / `'south'`). Alternative to `lat`. |
58
+
59
+ Provide **either** `lat` **or** `hemisphere`. If both are present, `lat` wins.
60
+ Invalid input (arrays not length 12, non-finite numbers, no hemisphere given)
61
+ throws a `TypeError` rather than returning a wrong answer.
62
+
63
+ > Note: pass the monthly **mean** temperature. If your source has separate daily
64
+ > highs and lows, average them first: `mean = (high + low) / 2`. That is exactly
65
+ > what weatherhack does before calling the original function.
66
+
67
+ **`result`** (a plain object)
68
+
69
+ | field | type | example | meaning |
70
+ |--------------|----------|------------------------|---------|
71
+ | `code` | `string` | `'Cfb'` | Köppen–Geiger class code. |
72
+ | `label` | `string` | `'temperate oceanic'` | Human-readable class name. |
73
+ | `group` | `string` | `'C'` | Top-level group letter. |
74
+ | `groupLabel` | `string` | `'Temperate'` | Top-level group name (Tropical / Arid / Temperate / Continental / Polar). |
75
+ | `blurb` | `string` | `'A coldest month …'` | One-sentence plain-language summary. |
76
+
77
+ ## What the algorithm actually does
78
+
79
+ It applies the standard Köppen decision tree, tested in this order:
80
+
81
+ 1. **B (arid)** first, because aridity overrides temperature. The dryness
82
+ threshold is `P_th = 20·MAT + offset`, where the offset is **280** if ≥70% of
83
+ annual precipitation falls in the warm half-year, **140** if 30–70%, else
84
+ **0**. `BW` (desert) when annual precip `< ½·P_th`, otherwise `BS` (steppe);
85
+ the `h`/`k` split is at a mean annual temperature of **18 °C**.
86
+ 2. **A (tropical)** when the coldest month averages ≥ 18 °C (`Af`/`Am`/`Aw`/`As`).
87
+ 3. **E (polar)** when the warmest month is below 10 °C (`ET` tundra, `EF` ice cap).
88
+ 4. **C vs D** by the **0 °C coldest-month isotherm**, then a seasonal-precip
89
+ third letter (`f`/`s`/`w`) and a summer-heat fourth letter (`a`/`b`/`c`/`d`).
90
+
91
+ The **warm half-year** is April–September in the northern hemisphere and
92
+ October–March in the southern — this is why the hemisphere argument matters, and
93
+ why a southern-hemisphere Mediterranean city (dry *December–February* summer)
94
+ comes out `Cs…` rather than `Cw…`.
95
+
96
+ ### Honest limits
97
+
98
+ - This is the common textbook variant of the boundaries. Other published
99
+ variants differ on edge rules (e.g. the exact aridity offset, or a 22 °C vs
100
+ 0 °C `h`/`k` split, or the As/Aw threshold). Results near a boundary can
101
+ legitimately differ from other tools; this one matches weatherhack's output.
102
+ - It classifies from the **12 monthly normals you supply**. Garbage or
103
+ non-representative normals in → wrong class out; it has no way to know your
104
+ inputs are unusual.
105
+ - Southern/northern is derived purely from the sign of `lat` (or the
106
+ `hemisphere` string). Equatorial locations (`lat` ≈ 0) are treated as northern;
107
+ for tropical `A` climates the hemisphere choice rarely changes the code.
108
+ - The four-letter `d` sub-code and some rare combinations are emitted but not
109
+ every one is covered by a named-city fixture in the test suite.
110
+
111
+ ## Tests
112
+
113
+ One command, no framework, no dependencies:
114
+
115
+ ```
116
+ node test/koppen.test.js
117
+ ```
118
+
119
+ It checks known-city fixtures across every top-level group — London `Cfb`,
120
+ Singapore `Af`, Cairo `BWh`, Moscow `Dfb`, Cape Town `Csb` (southern
121
+ hemisphere), Utqiagvik `ET` (polar) — plus a hemisphere-flip case proving the
122
+ `lat`/`hemisphere` argument changes the result, the return-shape contract, and
123
+ input validation. Exit code is non-zero on any failure.
124
+
125
+ ## Status
126
+
127
+ Incubator project — an npm-graduation **candidate**, not published. Promotion to
128
+ a real package registry is a separate, human-approved step.
129
+
130
+ ## License
131
+
132
+ MIT.
133
+
134
+ ## Install
135
+
136
+ > Placeholder name: the `koppen-classifier` below is the working slug, not a finalized
137
+ > npm name — see the owner TODO near the top of this README.
138
+
139
+ ```sh
140
+ npm install koppen-classifier
141
+ ```
142
+
143
+ ```js
144
+ const { classify } = require('koppen-classifier');
145
+
146
+ const london = classify({
147
+ tempsC: [5.2, 5.3, 7.6, 9.6, 12.9, 16.0, 18.1, 17.8, 15.2, 11.4, 7.8, 5.5],
148
+ precipMm: [55, 41, 42, 44, 49, 45, 45, 50, 49, 69, 59, 55],
149
+ lat: 51.5,
150
+ });
151
+ console.log(london.code); // 'Cfb'
152
+ ```
package/index.js ADDED
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ // koppen-classifier — a zero-dependency Köppen–Geiger climate classifier.
4
+ //
5
+ // Given a location's twelve monthly mean temperatures (°C) and twelve monthly
6
+ // precipitation totals (mm), classify() returns the Köppen–Geiger climate code
7
+ // (e.g. 'Cfa'), a human-readable label, and the top-level group.
8
+ //
9
+ // The decision tree is a straight port of the PROVEN koppenClass() used in
10
+ // production on weatherhack.com (generate.cjs). The only adaptation is the
11
+ // input shape: weatherhack passes daily-high/low pairs and takes the mean
12
+ // internally as (hi+lo)/2; this library accepts the monthly MEAN temperature
13
+ // directly (tempsC), which is the exact quantity the algorithm consumes. The
14
+ // classification boundaries below are byte-for-byte identical to the source.
15
+
16
+ // Human-readable names for the Köppen–Geiger codes we can emit. Codes not in
17
+ // the table fall back to the raw code, so the classifier can never crash on an
18
+ // unusual combination.
19
+ const KOPPEN_NAMES = {
20
+ Af: 'tropical rainforest', Am: 'tropical monsoon',
21
+ Aw: 'tropical savanna (dry winter)', As: 'tropical savanna (dry summer)',
22
+ BWh: 'hot desert', BWk: 'cold desert',
23
+ BSh: 'hot semi-arid (steppe)', BSk: 'cold semi-arid (steppe)',
24
+ Csa: 'hot-summer Mediterranean', Csb: 'warm-summer Mediterranean', Csc: 'cold-summer Mediterranean',
25
+ Cwa: 'monsoon-influenced humid subtropical', Cwb: 'subtropical highland', Cwc: 'cold subtropical highland',
26
+ Cfa: 'humid subtropical', Cfb: 'temperate oceanic', Cfc: 'subpolar oceanic',
27
+ Dsa: 'Mediterranean-influenced hot-summer continental', Dsb: 'Mediterranean-influenced warm-summer continental',
28
+ Dsc: 'Mediterranean-influenced subarctic', Dsd: 'Mediterranean-influenced extremely cold subarctic',
29
+ Dwa: 'monsoon-influenced hot-summer continental', Dwb: 'monsoon-influenced warm-summer continental',
30
+ Dwc: 'monsoon-influenced subarctic', Dwd: 'monsoon-influenced extremely cold subarctic',
31
+ Dfa: 'hot-summer humid continental', Dfb: 'warm-summer humid continental',
32
+ Dfc: 'subarctic', Dfd: 'extremely cold subarctic',
33
+ ET: 'polar tundra', EF: 'polar ice cap',
34
+ };
35
+
36
+ // Top-level group (first letter of the code) → short name.
37
+ const GROUP_NAMES = {
38
+ A: 'Tropical',
39
+ B: 'Arid',
40
+ C: 'Temperate',
41
+ D: 'Continental',
42
+ E: 'Polar',
43
+ };
44
+
45
+ function fmt1(n) {
46
+ return (Math.round(n * 10) / 10).toFixed(1);
47
+ }
48
+
49
+ // Resolve the hemisphere from the options. Accepts either `lat` (a signed
50
+ // latitude in degrees, north positive) or `hemisphere` ('N'/'S', 'north'/
51
+ // 'south', case-insensitive). `lat` wins if both are given. Returns true for
52
+ // the northern hemisphere.
53
+ function isNorth(opts) {
54
+ if (typeof opts.lat === 'number' && Number.isFinite(opts.lat)) {
55
+ return opts.lat >= 0;
56
+ }
57
+ if (opts.hemisphere != null) {
58
+ const h = String(opts.hemisphere).trim().toLowerCase();
59
+ if (h === 'n' || h === 'north' || h === 'northern') return true;
60
+ if (h === 's' || h === 'south' || h === 'southern') return false;
61
+ throw new TypeError(
62
+ `classify: unrecognised hemisphere ${JSON.stringify(opts.hemisphere)} (use 'N'/'S' or a numeric lat)`);
63
+ }
64
+ throw new TypeError("classify: provide either `lat` (number) or `hemisphere` ('N'/'S')");
65
+ }
66
+
67
+ function assert12(name, arr) {
68
+ if (!Array.isArray(arr) || arr.length !== 12) {
69
+ throw new TypeError(`classify: \`${name}\` must be an array of exactly 12 numbers`);
70
+ }
71
+ for (let i = 0; i < 12; i++) {
72
+ if (typeof arr[i] !== 'number' || !Number.isFinite(arr[i])) {
73
+ throw new TypeError(`classify: \`${name}[${i}]\` must be a finite number`);
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Classify a location's Köppen–Geiger climate.
80
+ *
81
+ * @param {object} opts
82
+ * @param {number[]} opts.tempsC 12 monthly MEAN temperatures in °C, Jan → Dec.
83
+ * @param {number[]} opts.precipMm 12 monthly precipitation totals in mm, Jan → Dec.
84
+ * @param {number} [opts.lat] Signed latitude (north positive). Used only for
85
+ * its sign, to pick the warm half-year.
86
+ * @param {string} [opts.hemisphere] 'N'/'S' (or 'north'/'south') — alternative to `lat`.
87
+ * @returns {{code:string, label:string, group:string, groupLabel:string, blurb:string}}
88
+ * code e.g. 'Cfa'
89
+ * label human-readable class name, e.g. 'humid subtropical'
90
+ * group top-level letter, e.g. 'C'
91
+ * groupLabel top-level name, e.g. 'Temperate'
92
+ * blurb one-sentence plain-language summary
93
+ */
94
+ function classify(opts) {
95
+ if (!opts || typeof opts !== 'object') {
96
+ throw new TypeError('classify: expected an options object { tempsC, precipMm, lat|hemisphere }');
97
+ }
98
+ assert12('tempsC', opts.tempsC);
99
+ assert12('precipMm', opts.precipMm);
100
+ const north = isNorth(opts);
101
+
102
+ // T is the monthly mean temperature directly (weatherhack derives it as
103
+ // (hi+lo)/2 before this exact point). P is monthly precipitation in mm.
104
+ const T = opts.tempsC.slice();
105
+ const P = opts.precipMm.slice();
106
+
107
+ const MAT = T.reduce((a, b) => a + b, 0) / 12;
108
+ const Pann = P.reduce((a, b) => a + b, 0);
109
+ const Tcold = Math.min(...T);
110
+ const Twarm = Math.max(...T);
111
+ const nWarm10 = T.filter((t) => t >= 10).length;
112
+
113
+ const summerIdx = north ? [3, 4, 5, 6, 7, 8] : [9, 10, 11, 0, 1, 2];
114
+ const winterIdx = north ? [9, 10, 11, 0, 1, 2] : [3, 4, 5, 6, 7, 8];
115
+ const Psummer = summerIdx.reduce((a, i) => a + P[i], 0);
116
+ const summerShare = Pann > 0 ? Psummer / Pann : 0;
117
+ const Psmin = Math.min(...summerIdx.map((i) => P[i]));
118
+ const Psmax = Math.max(...summerIdx.map((i) => P[i]));
119
+ const Pwmin = Math.min(...winterIdx.map((i) => P[i]));
120
+ const Pwmax = Math.max(...winterIdx.map((i) => P[i]));
121
+ const Pmin = Math.min(...P);
122
+
123
+ const done = (code) => ({
124
+ code,
125
+ label: KOPPEN_NAMES[code] || code,
126
+ group: code[0],
127
+ groupLabel: GROUP_NAMES[code[0]] || code[0],
128
+ blurb: `A coldest month averaging ${fmt1(Tcold)} °C, a warmest month near ${fmt1(Twarm)} °C `
129
+ + `and about ${Math.round(Pann)} mm of precipitation across the year place it in the `
130
+ + `${KOPPEN_NAMES[code] || code} band.`,
131
+ });
132
+
133
+ // B (arid) is tested first — it overrides the temperature classes.
134
+ const offset = summerShare >= 0.7 ? 280 : summerShare >= 0.3 ? 140 : 0;
135
+ const Pth = 20 * MAT + offset;
136
+ if (Pann < Pth) {
137
+ return done('B' + (Pann < 0.5 * Pth ? 'W' : 'S') + (MAT >= 18 ? 'h' : 'k'));
138
+ }
139
+
140
+ // Fourth letter (summer heat) for C/D climates.
141
+ const heat = () =>
142
+ Twarm >= 22 ? 'a' : nWarm10 >= 4 ? 'b' : Tcold > -38 ? 'c' : 'd';
143
+ // Third letter (seasonal precipitation) for C/D climates.
144
+ const precip = () => {
145
+ const dryS = Psmin < 40 && Psmin < Pwmax / 3; // dry summer
146
+ const dryW = Pwmin < Psmax / 10; // dry winter
147
+ if (dryS && !dryW) return 's';
148
+ if (dryW && !dryS) return 'w';
149
+ if (dryS && dryW) return Psmin <= Pwmin ? 's' : 'w';
150
+ return 'f';
151
+ };
152
+
153
+ // A (tropical): every month's mean ≥ 18 °C (coldest month ≥ 18).
154
+ if (Tcold >= 18) {
155
+ let third;
156
+ if (Pmin >= 60) third = 'f';
157
+ else if (Pmin >= 100 - Pann / 25) third = 'm';
158
+ else third = summerShare >= 0.5 ? 'w' : 's'; // dry season in winter → Aw
159
+ return done('A' + third);
160
+ }
161
+ // E (polar): warmest month below 10 °C.
162
+ if (Twarm < 10) return done(Twarm >= 0 ? 'ET' : 'EF');
163
+ // C / D by the 0 °C coldest-month isotherm.
164
+ return done((Tcold > 0 ? 'C' : 'D') + precip() + heat());
165
+ }
166
+
167
+ module.exports = { classify, KOPPEN_NAMES, GROUP_NAMES };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@verifyhash/koppen-classifier",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency Köppen–Geiger climate classifier from 12 monthly mean-temperature and precipitation values (hemisphere-aware).",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "test": "node test/koppen.test.js"
9
+ },
10
+ "keywords": [
11
+ "koppen",
12
+ "koppen-geiger",
13
+ "climate",
14
+ "classification",
15
+ "weather",
16
+ "geography"
17
+ ],
18
+ "license": "MIT",
19
+ "files": [
20
+ "index.js",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/verifyhash/libs.git",
29
+ "directory": "koppen-classifier"
30
+ },
31
+ "homepage": "https://github.com/verifyhash/libs/tree/main/koppen-classifier#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/verifyhash/libs/issues"
34
+ }
35
+ }