@verifyhash/wind-scale 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 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,200 @@
1
+ # wind-scale
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 `wind-scale` 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
+ Zero-dependency, pure-JavaScript wind utilities: convert speeds between the five
11
+ units meteorologists actually use, and classify a wind on the three scales the
12
+ public sees on forecasts — the Beaufort force scale, the Saffir-Simpson
13
+ hurricane scale, and the official U.S. National Weather Service wind-chill
14
+ temperature.
15
+
16
+ No dependencies, no network, no I/O. Every function is pure: given the same
17
+ inputs it always returns the same value and mutates nothing. It runs anywhere
18
+ Node.js runs and is trivial to vendor into a browser bundle.
19
+
20
+ ## Who it's for
21
+
22
+ - Weather / marine / aviation dashboards that receive wind in one unit (say
23
+ METAR knots) and must display another (mph, km/h).
24
+ - Anyone turning a raw wind speed into a human label ("Force 7, near gale" or
25
+ "Category 3 hurricane").
26
+ - Cold-climate apps computing the "feels-like" wind-chill temperature with the
27
+ correct, current NWS formula instead of the obsolete pre-2001 one.
28
+
29
+ ## Install / use
30
+
31
+ It's a single file with no dependencies. Copy `src/wind-scale.js` into your
32
+ project or `require` it directly:
33
+
34
+ ```js
35
+ const { convert, beaufort, saffirSimpson, windChill } = require('./src/wind-scale.js');
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### `convert(value, fromUnit, toUnit) -> number`
41
+
42
+ Convert a wind speed between any two of these units:
43
+
44
+ | code | unit |
45
+ |--------|------------------|
46
+ | `ms` | metres/second |
47
+ | `kmh` | kilometres/hour |
48
+ | `mph` | miles/hour |
49
+ | `kn` | knots |
50
+ | `fts` | feet/second |
51
+
52
+ Exact factors relative to 1 m/s:
53
+ `1 m/s = 3.6 km/h = 2.2369362920544 mph = 1.9438444924406 kn = 3.280839895 ft/s`.
54
+
55
+ ```js
56
+ convert(10, 'ms', 'kmh'); // 36
57
+ convert(100, 'kmh', 'mph'); // 62.137119223733...
58
+ convert(50, 'kn', 'mph'); // 57.539019...
59
+ ```
60
+
61
+ Throws `TypeError` if `value` is not a finite number and `RangeError` for an
62
+ unknown unit code.
63
+
64
+ ### `beaufort(speedMs) -> { force, description, minMs, maxMs }`
65
+
66
+ Classify a wind speed **in metres per second** on the 0–12 Beaufort scale. The
67
+ result gives the integer `force`, its standard WMO `description`, and the band's
68
+ bounds in m/s: `minMs` (inclusive lower bound) and `maxMs` (exclusive upper
69
+ bound; `Infinity` for Force 12). Bands are contiguous — the `maxMs` of one force
70
+ equals the `minMs` of the next.
71
+
72
+ Boundaries (m/s), Force 0 below 0.5 up to Force 12 at 32.7 and above:
73
+
74
+ | force | m/s band | description |
75
+ |-------|--------------|-----------------|
76
+ | 0 | < 0.5 | Calm |
77
+ | 1 | 0.5–1.6 | Light air |
78
+ | 2 | 1.6–3.4 | Light breeze |
79
+ | 3 | 3.4–5.5 | Gentle breeze |
80
+ | 4 | 5.5–8.0 | Moderate breeze |
81
+ | 5 | 8.0–10.8 | Fresh breeze |
82
+ | 6 | 10.8–13.9 | Strong breeze |
83
+ | 7 | 13.9–17.2 | Near gale |
84
+ | 8 | 17.2–20.8 | Gale |
85
+ | 9 | 20.8–24.5 | Strong gale |
86
+ | 10 | 24.5–28.5 | Storm |
87
+ | 11 | 28.5–32.7 | Violent storm |
88
+ | 12 | >= 32.7 | Hurricane force |
89
+
90
+ ```js
91
+ beaufort(9.0);
92
+ // { force: 5, description: 'Fresh breeze', minMs: 8, maxMs: 10.8 }
93
+
94
+ beaufort(40);
95
+ // { force: 12, description: 'Hurricane force', minMs: 32.7, maxMs: Infinity }
96
+ ```
97
+
98
+ If your speed is in another unit, feed it through `convert` first:
99
+ `beaufort(convert(20, 'kn', 'ms'))`.
100
+
101
+ Throws `TypeError` for non-finite input and `RangeError` for negative speeds.
102
+
103
+ ### `saffirSimpson(sustainedMph) -> { category, label }`
104
+
105
+ Classify a tropical cyclone by its **1-minute sustained wind in mph**. Uses the
106
+ Saffir-Simpson Hurricane Wind Scale plus the U.S. National Hurricane Center's
107
+ sub-hurricane designations. `category` is `'TD'`, `'TS'`, or the number `1`–`5`.
108
+
109
+ | category | sustained mph | label |
110
+ |----------|---------------|-----------------------|
111
+ | `'TD'` | < 39 | Tropical Depression |
112
+ | `'TS'` | 39–73 | Tropical Storm |
113
+ | `1` | 74–95 | Category 1 Hurricane |
114
+ | `2` | 96–110 | Category 2 Hurricane |
115
+ | `3` | 111–129 | Category 3 Hurricane |
116
+ | `4` | 130–156 | Category 4 Hurricane |
117
+ | `5` | >= 157 | Category 5 Hurricane |
118
+
119
+ ```js
120
+ saffirSimpson(35); // { category: 'TD', label: 'Tropical Depression' }
121
+ saffirSimpson(74); // { category: 1, label: 'Category 1 Hurricane' }
122
+ saffirSimpson(160); // { category: 5, label: 'Category 5 Hurricane' }
123
+ ```
124
+
125
+ Throws `TypeError` for non-finite input and `RangeError` for negative speeds.
126
+
127
+ ### `windChill(tempF, windMph) -> number`
128
+
129
+ Wind chill temperature in degrees Fahrenheit using the official 2001 NWS /
130
+ Environment Canada formula:
131
+
132
+ ```
133
+ WC = 35.74 + 0.6215*T - 35.75*V^0.16 + 0.4275*T*V^0.16
134
+ ```
135
+
136
+ with `T` air temperature in °F and `V` wind speed in mph (at the standard 10 m
137
+ anemometer height).
138
+
139
+ **Validity range.** The NWS defines this formula only for **air temperature ≤ 50 °F
140
+ and wind speed > 3 mph**. Outside that domain wind chill is not meaningful, so
141
+ this function returns the input air temperature (`tempF`) unchanged when
142
+ `tempF > 50` or `windMph <= 3`. Inside the valid domain it returns the raw
143
+ formula value (not rounded); published NWS charts round to the nearest whole
144
+ degree, so expect sub-degree differences from a chart cell.
145
+
146
+ ```js
147
+ windChill(0, 35); // -27.40... (NWS chart cell: -27)
148
+ windChill(40, 5); // 36.47... (NWS chart cell: 36)
149
+ windChill(60, 20); // 60 (T > 50 F -> out of range, returns tempF)
150
+ windChill(20, 2); // 20 (V <= 3 mph -> out of range, returns tempF)
151
+ ```
152
+
153
+ Throws `TypeError` if either argument is not a finite number, and `RangeError`
154
+ for negative wind speed.
155
+
156
+ ## Running the tests
157
+
158
+ One command, no install step (uses only `node:assert`):
159
+
160
+ ```
161
+ node test/wind-scale.test.js
162
+ ```
163
+
164
+ The suite checks round-trip and cross-unit conversions, every Beaufort force
165
+ boundary (and the value just below it), every Saffir-Simpson category boundary,
166
+ and ten published NWS wind-chill chart cells.
167
+
168
+ ## Status / limitations
169
+
170
+ - Beaufort m/s thresholds follow the widely published WMO table rounded to one
171
+ decimal; different sources round the knot-derived cut points slightly
172
+ differently, so a value sitting exactly on a boundary may differ by one force
173
+ from another table.
174
+ - `saffirSimpson` expects 1-minute sustained wind in mph, the U.S. convention.
175
+ Agencies using 10-minute sustained winds (much of the world) will classify the
176
+ same storm differently — convert your averaging period first.
177
+ - Wind chill is only valid for cold, windy conditions (≤ 50 °F, > 3 mph); it is
178
+ not a general "feels like" index and says nothing about heat or humidity.
179
+
180
+ ## License
181
+
182
+ MIT.
183
+
184
+ ## Install
185
+
186
+ > Placeholder name: the `wind-scale` below is the working slug, not a finalized
187
+ > npm name — see the owner TODO near the top of this README.
188
+
189
+ ```sh
190
+ npm install wind-scale
191
+ ```
192
+
193
+ ```js
194
+ const { convert, beaufort, saffirSimpson, windChill } = require('wind-scale');
195
+
196
+ convert(10, 'ms', 'kmh'); // 36
197
+ beaufort(9.0); // { force: 5, description: 'Fresh breeze', minMs: 8, maxMs: 10.8 }
198
+ saffirSimpson(100); // { category: 2, label: 'Category 2 Hurricane' } (sustained mph)
199
+ windChill(20, 15); // 6.22 (°F, from tempF=20, windMph=15)
200
+ ```
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@verifyhash/wind-scale",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency wind speed conversions plus Beaufort, Saffir-Simpson, and the official 2001 NWS wind-chill formula.",
5
+ "keywords": [
6
+ "wind",
7
+ "beaufort",
8
+ "saffir-simpson",
9
+ "hurricane",
10
+ "wind-chill",
11
+ "knots",
12
+ "meteorology",
13
+ "conversion"
14
+ ],
15
+ "main": "src/wind-scale.js",
16
+ "scripts": {
17
+ "test": "node test/wind-scale.test.js"
18
+ },
19
+ "license": "MIT",
20
+ "files": [
21
+ "src/wind-scale.js",
22
+ "README.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/verifyhash/libs.git",
30
+ "directory": "wind-scale"
31
+ },
32
+ "homepage": "https://github.com/verifyhash/libs/tree/main/wind-scale#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/verifyhash/libs/issues"
35
+ }
36
+ }
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * wind-scale — zero-dependency wind speed conversions and meteorological scales.
5
+ *
6
+ * Provides four pure functions:
7
+ * - convert(value, fromUnit, toUnit) unit conversion among m/s, km/h, mph, kn, ft/s
8
+ * - beaufort(speedMs) Beaufort force (0-12) from a speed in m/s
9
+ * - saffirSimpson(sustainedMph) Saffir-Simpson hurricane category from sustained mph
10
+ * - windChill(tempF, windMph) 2001 NWS wind chill temperature (deg F)
11
+ *
12
+ * No I/O, no network, no external dependencies. Every function is pure and
13
+ * deterministic: same inputs always produce the same output, and nothing is
14
+ * mutated or observed outside the return value.
15
+ */
16
+
17
+ // --- unit conversion -------------------------------------------------------
18
+
19
+ /**
20
+ * Conversion factors expressed as "how many of this unit equal 1 metre/second".
21
+ * These are exact per the task specification:
22
+ * 1 m/s = 3.6 km/h = 2.2369362920544 mph = 1.9438444924406 kn = 3.280839895 ft/s
23
+ *
24
+ * (1 knot = 1 nautical mile/hour = 1852 m / 3600 s; 1 mph = 1609.344 m / 3600 s;
25
+ * 1 ft/s = 0.3048 m / s.)
26
+ */
27
+ const FACTORS = Object.freeze({
28
+ ms: 1,
29
+ kmh: 3.6,
30
+ mph: 2.2369362920544,
31
+ kn: 1.9438444924406,
32
+ fts: 3.280839895,
33
+ });
34
+
35
+ const UNIT_NAMES = Object.freeze(Object.keys(FACTORS));
36
+
37
+ function isFiniteNumber(x) {
38
+ return typeof x === 'number' && Number.isFinite(x);
39
+ }
40
+
41
+ /**
42
+ * Convert a wind speed between any two supported units.
43
+ *
44
+ * @param {number} value the numeric speed to convert (finite number)
45
+ * @param {string} fromUnit one of 'ms','kmh','mph','kn','fts'
46
+ * @param {string} toUnit one of 'ms','kmh','mph','kn','fts'
47
+ * @returns {number} the value expressed in toUnit
48
+ *
49
+ * @example
50
+ * convert(10, 'ms', 'kmh') // => 36
51
+ * convert(100, 'kmh', 'mph') // => 62.137... (100 km/h in mph)
52
+ * convert(50, 'kn', 'kn') // => 50 (round-trip identity)
53
+ */
54
+ function convert(value, fromUnit, toUnit) {
55
+ if (!isFiniteNumber(value)) {
56
+ throw new TypeError(`convert: value must be a finite number, got ${value}`);
57
+ }
58
+ if (!Object.prototype.hasOwnProperty.call(FACTORS, fromUnit)) {
59
+ throw new RangeError(
60
+ `convert: unknown fromUnit '${fromUnit}', expected one of ${UNIT_NAMES.join(', ')}`
61
+ );
62
+ }
63
+ if (!Object.prototype.hasOwnProperty.call(FACTORS, toUnit)) {
64
+ throw new RangeError(
65
+ `convert: unknown toUnit '${toUnit}', expected one of ${UNIT_NAMES.join(', ')}`
66
+ );
67
+ }
68
+ // Convert to the m/s base, then out to the target unit.
69
+ const ms = value / FACTORS[fromUnit];
70
+ return ms * FACTORS[toUnit];
71
+ }
72
+
73
+ // --- Beaufort wind force scale --------------------------------------------
74
+
75
+ /**
76
+ * Beaufort force table. Each entry gives the inclusive lower bound in m/s
77
+ * (`minMs`) and the standard WMO description. Boundaries follow the widely
78
+ * published m/s thresholds: Force 0 is below 0.5 m/s, Force 12 (hurricane
79
+ * force) begins at 32.7 m/s. A given force f spans [minMs[f], minMs[f+1]).
80
+ */
81
+ const BEAUFORT = Object.freeze([
82
+ { force: 0, minMs: 0.0, description: 'Calm' },
83
+ { force: 1, minMs: 0.5, description: 'Light air' },
84
+ { force: 2, minMs: 1.6, description: 'Light breeze' },
85
+ { force: 3, minMs: 3.4, description: 'Gentle breeze' },
86
+ { force: 4, minMs: 5.5, description: 'Moderate breeze' },
87
+ { force: 5, minMs: 8.0, description: 'Fresh breeze' },
88
+ { force: 6, minMs: 10.8, description: 'Strong breeze' },
89
+ { force: 7, minMs: 13.9, description: 'Near gale' },
90
+ { force: 8, minMs: 17.2, description: 'Gale' },
91
+ { force: 9, minMs: 20.8, description: 'Strong gale' },
92
+ { force: 10, minMs: 24.5, description: 'Storm' },
93
+ { force: 11, minMs: 28.5, description: 'Violent storm' },
94
+ { force: 12, minMs: 32.7, description: 'Hurricane force' },
95
+ ]);
96
+
97
+ /**
98
+ * Classify a wind speed (in metres per second) on the Beaufort scale.
99
+ *
100
+ * @param {number} speedMs wind speed in m/s (finite, >= 0)
101
+ * @returns {{force:number, description:string, minMs:number, maxMs:number}}
102
+ * force is 0..12; minMs/maxMs are the inclusive lower and exclusive upper
103
+ * bounds of that force band in m/s. For Force 12 maxMs is Infinity.
104
+ *
105
+ * @example
106
+ * beaufort(0.2) // => { force: 0, description: 'Calm', minMs: 0, maxMs: 0.5 }
107
+ * beaufort(9.0) // => { force: 5, description: 'Fresh breeze', minMs: 8, maxMs: 10.8 }
108
+ * beaufort(40) // => { force: 12, description: 'Hurricane force', minMs: 32.7, maxMs: Infinity }
109
+ */
110
+ function beaufort(speedMs) {
111
+ if (!isFiniteNumber(speedMs)) {
112
+ throw new TypeError(`beaufort: speedMs must be a finite number, got ${speedMs}`);
113
+ }
114
+ if (speedMs < 0) {
115
+ throw new RangeError(`beaufort: speedMs must be >= 0, got ${speedMs}`);
116
+ }
117
+ // Find the highest force whose lower bound is <= speedMs.
118
+ let idx = 0;
119
+ for (let i = 0; i < BEAUFORT.length; i++) {
120
+ if (speedMs >= BEAUFORT[i].minMs) idx = i;
121
+ else break;
122
+ }
123
+ const entry = BEAUFORT[idx];
124
+ const next = BEAUFORT[idx + 1];
125
+ return {
126
+ force: entry.force,
127
+ description: entry.description,
128
+ minMs: entry.minMs,
129
+ maxMs: next ? next.minMs : Infinity,
130
+ };
131
+ }
132
+
133
+ // --- Saffir-Simpson hurricane wind scale ----------------------------------
134
+
135
+ /**
136
+ * Classify a tropical cyclone by its 1-minute sustained wind in mph on the
137
+ * Saffir-Simpson Hurricane Wind Scale, extended downward with the U.S.
138
+ * National Hurricane Center's sub-hurricane designations:
139
+ * TD (tropical depression) < 39 mph
140
+ * TS (tropical storm) 39-73 mph
141
+ * Category 1 74-95 mph
142
+ * Category 2 96-110 mph
143
+ * Category 3 111-129 mph (major hurricane)
144
+ * Category 4 130-156 mph (major hurricane)
145
+ * Category 5 >= 157 mph (major hurricane)
146
+ *
147
+ * @param {number} sustainedMph sustained wind speed in mph (finite, >= 0)
148
+ * @returns {{category:('TD'|'TS'|1|2|3|4|5), label:string}}
149
+ *
150
+ * @example
151
+ * saffirSimpson(35) // => { category: 'TD', label: 'Tropical Depression' }
152
+ * saffirSimpson(74) // => { category: 1, label: 'Category 1 Hurricane' }
153
+ * saffirSimpson(160) // => { category: 5, label: 'Category 5 Hurricane' }
154
+ */
155
+ function saffirSimpson(sustainedMph) {
156
+ if (!isFiniteNumber(sustainedMph)) {
157
+ throw new TypeError(
158
+ `saffirSimpson: sustainedMph must be a finite number, got ${sustainedMph}`
159
+ );
160
+ }
161
+ if (sustainedMph < 0) {
162
+ throw new RangeError(`saffirSimpson: sustainedMph must be >= 0, got ${sustainedMph}`);
163
+ }
164
+ if (sustainedMph < 39) return { category: 'TD', label: 'Tropical Depression' };
165
+ if (sustainedMph < 74) return { category: 'TS', label: 'Tropical Storm' };
166
+ if (sustainedMph < 96) return { category: 1, label: 'Category 1 Hurricane' };
167
+ if (sustainedMph < 111) return { category: 2, label: 'Category 2 Hurricane' };
168
+ if (sustainedMph < 130) return { category: 3, label: 'Category 3 Hurricane' };
169
+ if (sustainedMph < 157) return { category: 4, label: 'Category 4 Hurricane' };
170
+ return { category: 5, label: 'Category 5 Hurricane' };
171
+ }
172
+
173
+ // --- NWS wind chill --------------------------------------------------------
174
+
175
+ /**
176
+ * Wind chill temperature using the official 2001 U.S. National Weather Service
177
+ * / Environment Canada formula:
178
+ *
179
+ * WC = 35.74 + 0.6215*T - 35.75*V^0.16 + 0.4275*T*V^0.16
180
+ *
181
+ * where T is air temperature in degrees Fahrenheit and V is the wind speed in
182
+ * miles per hour (measured at the standard 10 m anemometer height).
183
+ *
184
+ * VALIDITY: the formula is only defined by the NWS for air temperature at or
185
+ * below 50 deg F AND wind speed above 3 mph. Outside that domain wind chill is
186
+ * not meaningful, so this function returns the input air temperature (tempF)
187
+ * unchanged when tempF > 50 or windMph <= 3. Within the valid domain it returns
188
+ * the raw formula result (not rounded); published NWS charts round to the
189
+ * nearest whole degree.
190
+ *
191
+ * @param {number} tempF air temperature in degrees Fahrenheit (finite)
192
+ * @param {number} windMph wind speed in mph (finite, >= 0)
193
+ * @returns {number} wind chill temperature in deg F, or tempF if out of range
194
+ *
195
+ * @example
196
+ * windChill(0, 35) // => -27.4 (NWS chart cell: -27)
197
+ * windChill(40, 5) // => 36.4 (NWS chart cell: 36)
198
+ * windChill(60, 20) // => 60 (T > 50 F: out of range, returns tempF)
199
+ * windChill(20, 2) // => 20 (V <= 3 mph: out of range, returns tempF)
200
+ */
201
+ function windChill(tempF, windMph) {
202
+ if (!isFiniteNumber(tempF)) {
203
+ throw new TypeError(`windChill: tempF must be a finite number, got ${tempF}`);
204
+ }
205
+ if (!isFiniteNumber(windMph)) {
206
+ throw new TypeError(`windChill: windMph must be a finite number, got ${windMph}`);
207
+ }
208
+ if (windMph < 0) {
209
+ throw new RangeError(`windChill: windMph must be >= 0, got ${windMph}`);
210
+ }
211
+ // Outside the NWS validity domain the formula is undefined; return tempF.
212
+ if (tempF > 50 || windMph <= 3) {
213
+ return tempF;
214
+ }
215
+ const v16 = Math.pow(windMph, 0.16);
216
+ return 35.74 + 0.6215 * tempF - 35.75 * v16 + 0.4275 * tempF * v16;
217
+ }
218
+
219
+ module.exports = {
220
+ convert,
221
+ beaufort,
222
+ saffirSimpson,
223
+ windChill,
224
+ // handy for callers / tests
225
+ FACTORS,
226
+ BEAUFORT,
227
+ UNITS: UNIT_NAMES,
228
+ };