作者:YaoJaro · 本文由 AI 辅助完成
《风不是一个数》系列第 6 篇。第 5 篇结尾留了一句话:「一年看完了,三十年呢------把四种画法套上去,会看见一个不太舒服的趋势。」现在把三十年摆上来:杆和场到顶,玫瑰答分布不答趋势,接得住「变没变」的只有一条年际线。先说好一条规矩------再分析不是观测,你看到的是模式的记忆,不是风的日记。
一、一个机场的风,在变小吗
先接第 5 篇结尾那个问题。1995 到 2024,数据在手边(哪来的,第二节交代),答案直接摆出来:10,958 天的日值切成首尾两个十年------前十年,「一天里最大的那阵风」平均是 12.57 kt (kt=节,航海上传下来的风速单位,1 节≈0.514 米/秒),后十年 11.51 kt ,差 −1.05 kt、降幅 8.4% ;再拟一条直线,斜率 −0.47 kt/十年。
(十年均值是全精度算完再四舍五入的:首十年真值 12.5655,舍到两位小数就是 12.57------差 −1.05 kt 和降幅 8.4% 都按全精度算,不受显示舍入影响。)
看起来答案已经出来了:再分析里在变小。先别信这条线。看到趋势,先问数据出身------这条线用什么画的、数字从哪来、代表谁,不弄清这些,−0.47 只是一个方向很好看的数字。

二、先问数据出身:再分析与取数工程
原料叫 ERA5:ECMWF(欧洲中期天气预报中心)的再分析 (reanalysis)第 5 代。再分析是什么?说白了,就是把几十年能找到的观测------地面站、探空、卫星、飞机报、浮标......------喂给数值模式,让模式按物理规则把过去的大气重新演一遍,得到一套覆盖全球、逐日连续的格点数据。「没有缺测」不是再分析的普遍承诺,只是这三个字段的事实------账在下面。第 1 篇讲过「这是模式廓线,不是探空」;第 4 篇把这条定成了规矩------模式值不是实测。再分析往观测那边更靠了一步------模式+观测的混血。但混血不是观测:观测进了模式、被物理规则重新分配过。

取数一条命令------Open-Meteo 的 archive 接口(历史档,非第 1 篇的实时层),一次取回三十年:
bash
# latitude/longitude 换成你自己的坐标,其余参数原样------同一条命令取回的就是本地三十年
curl -o era5-pudong-1995-2024.json "https://archive-api.open-meteo.com/v1/archive?latitude=31.14&longitude=121.80&start_date=1995-01-01&end_date=2024-12-31&daily=wind_speed_10m_max,wind_gusts_10m_max,wind_direction_10m_dominant&wind_speed_unit=kn&timezone=UTC"
这条命令是模板:把 latitude/longitude 换成你自己那儿的坐标(这一接口全球覆盖),同一条命令取回的就是你本地的三十年。
回来的东西很小:10,958 天 (22×365+8×366),三个字段(每日最大风速、最大阵风、主导风向)缺测一个没有;响应约 0.28 MB(阵风字段顺手取回,这里用不上)。
三个参数各停一秒。wind_speed_unit=kn:单位直接切到 kt,全系列一套刻度。timezone=UTC:日界按 UTC 日切,跟第 5 篇的观测窗口是同一套切法。第三个藏在返回体里:请求 31.14/121.80,接口声明实际给的是 31.177504/121.78359(海拔 2 米) ------坐标被吸附到 ERA5 的再分析格点。它离浦东机场的观测站不远,但不是那个站 ;标题那个「机场」要带引号读,你看到的所有数字说的都是浦东附近点位。
最后一样得说清楚的:样本是日值 ,而且是一对拼起来的------风速 =当日 24 个整点逐时值里的最大;风向 =当日全天逐时风按风速加权合成 的来向(字段名 dominant 是 Open-Meteo 日聚合的命名,下面都叫它日主导 )------不是最大风那一刻的风向。接口文档对这个字段只有一个词,上面的机制是开源实现里逐行读出来的。加权合成自带一个推论:只要有风,全天合成的来向就有定义------静风与不定在这套数法里天生就不存在,机理的账第三节算给你。每张图都按这套规矩数:什么算进来、怎么算。
三、玫瑰:三十年对一年
第 1 篇那张地图上有四种画法------杆、场、玫瑰、廓线(廓线答垂直,轮不到它)。哪几件吃得动这份数据?杆是此刻的照片,套上三十年只剩一根平均杆------首尾十年差的 8.4%,一根平均杆连这 8.4% 都装不下,更别说哪年转折。场更吃不起,成本台阶当场算:单点位约 0.28 MB,9 个点位约 2.5 MB,勉强扛住;铺开一张区域,全域不可行。三十年真正的主人公是两件:玫瑰,和这个系列从没上过的趋势线。先看玫瑰。
装配与读法第 5 篇整篇讲过,不重讲;三十年玫瑰只换原料(10,958 个日主导样本),这次不用手工装配:metweave 0.3.7 的 windRose,样本裸数组直喂,零适配器。装包一行,核心两行:
bash
npm init -y && npm i metweave@0.3.7
js
// rose-30y.mjs ------ 三十年玫瑰:日主导样本裸数组直喂 windRose(装包复现,0.3.7 实测成立)
import { readFileSync, writeFileSync } from "node:fs";
import { windRose } from "metweave";
// Node 没有 DOM:垫一个最小 document(组件出几何与账目,样式注入在垫片下自然空转)
class El {
constructor(tag, ns = null) {
this.tagName = tag;
this.namespaceURI = ns;
this.children = [];
this.attrs = {};
this.dataset = {};
this._cls = new Set();
this.style = {};
}
get className() { return [...this._cls].join(" "); }
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
get classList() {
const s = this._cls;
return {
add: (...cs) => cs.forEach((c) => s.add(c)),
remove: (...cs) => cs.forEach((c) => s.delete(c)),
contains: (c) => s.has(c),
};
}
setAttribute(k, v) { this.attrs[k] = String(v); if (k === "class") this.className = v; }
getAttribute(k) { return this.attrs[k] ?? null; }
append(...ns) { this.children.push(...ns); }
appendChild(n) { this.children.push(n); return n; }
walk(fn) { fn(this); for (const c of this.children) if (c instanceof El) c.walk(fn); }
}
globalThis.document = {
createElement: (t) => new El(t),
createElementNS: (ns, t) => new El(t, ns),
getElementById: () => null, // 判空 → 组件跳过样式注入
head: new El("head"),
};
// 原料:10,958 个日主导样本,一对一天------{direction, speed} 裸数组,零适配器
const d = JSON.parse(readFileSync("./era5-pudong-1995-2024.json", "utf8")).daily;
const samples = d.time.map((_, i) => ({
direction: d.wind_direction_10m_dominant[i], // 当日主导来向(0--360,0 = 北)
speed: d.wind_speed_10m_max[i], // 当日最大风速(接口已切 kn)
}));
const root = windRose(samples, { sectors: 16 }); // 其余缺省:unit kt、静风阈 1 kt、6 档
console.log(root.dataset);
// → { samples: '10958', inSector: '10958', calm: '0', missingDirection: '0',
// missingSpeed: '0', unit: 'kt', sectors: '16', bins: '6', rim: '15' }
// 组件返回挂在 <div> 下的 <svg>:抽出来补上 xmlns,序列化落盘
const svgOf = (el) => {
const attrs = Object.entries(el.attrs).map(([k, v]) => ` ${k}="${v}"`).join("");
const inner = (el.textContent ?? "") +
el.children.map((c) => (typeof c === "string" ? c : svgOf(c))).join("");
return `<${el.tagName}${attrs}>${inner}</${el.tagName}>`;
};
const svgEl = root.children.find((c) => c.tagName === "svg");
writeFileSync("rose-30y.svg", svgOf({ ...svgEl, attrs: { ...svgEl.attrs, xmlns: "http://www.w3.org/2000/svg" } }));
console.log(`入扇区 ${root.dataset.inSector} / ${root.dataset.samples},静风 ${root.dataset.calm},缺向 ${root.dataset.missingDirection},缺速 ${root.dataset.missingSpeed}`);
// → 入扇区 10958 / 10958,静风 0,缺向 0,缺速 0------账目与手工实算逐项相等
其余走缺省------单位 kt、静风阈 1 kt、6 档、中心对齐归桶;账目都在 root.dataset 里:入扇区 10,958/10,958、静风 0、缺向 0、缺速 0------这几笔跑一遍代码就能对上。扇区归桶另用花瓣外径反演复核过,与手工实算 16/16 相等;你要较真,第 5 篇的归桶式十行就能自己数一遍------装包复现,实测能跑通。
和第 5 篇一年玫瑰(8,760 逐时)摆一起,先看形状:北瓣(N、NNE)+东南瓣(SE、SSE)双瓣都在,西南洼地(SW 1.87%、WSW 1.27%)三十年没填平。再看主导扇区(风最常从哪个方向来):全期 SSE 10.96% 排第一;切首尾十年,首十年 SSE 10.92% 独大,末十年 NNE 10.05% 登顶------换手了,但这个「手」很轻(领先差 0.22 个百分点≈一年 8 天):末十年前四扇区 NNE 10.05、N 9.99、SE 9.94、SSE 9.83,差距不到 0.25 个百分点------从一枝独大换成四分天下,没有新老大。
还有一笔账,和第 5 篇正好倒过来:那边 8,502 个时次剔了静风 26、不定风向 232;这边 10,958 个样本静风 0、缺向 0、缺速 0。机理第二节交代过:方向是全天加权合成,有风就有定义;风速是全天最大,只有 24 个整点全部小于 1 kt 才算静风------三十年里一次没有。「静风」与「不定」在日主导样本里压根就不存在:没有中心静风圈,不是画漏,是规矩。

玫瑰答得了分布------从哪来、多大、多常来;答不了趋势:首尾十年比得出形状,比不出「变小」------占比是频率,不是速度。瓣里还有个影子:首尾十年逐扇区平均风速 16 扇区 15 个在降(降幅 0.33--1.73 kt)、只有西风微升(+0.13 kt)------玫瑰只给影子,普遍在降只是旁证;正式的线在下一节。
四、趋势线与距平:「变没变」由一条线答

画法两件。年际线 :把每一年压成一个点(该年日值均值),30 个点连成线,再拟一条直线。距平条 :先定气候态------10,958 个日值的全池均值 12.24 kt------每年均值减它,正负挂成条。读一遍:1995 年距平 +0.14,2024 年 −0.80;前二十年只有 2008 一年微负(−0.03),后十年九年负距平,唯一例外 2016(+0.46)------2016 到 2017 从 +0.46 跳到 −1.06,全序列最大的单年跳降(为什么跳,这里不查------序列只负责如实,追因留给下一问);峰 2013(+0.93),谷 2019(−1.24)。首尾两个距平一摆,「不太舒服的趋势」落在明面上。
趋势那条直线的斜率:−0.473 kt/十年 。一条公式给足------最小二乘,斜率 = cov(x, y) ⁄ var(x),x 是日序号(0 到 10,957)、y 是当日最大风速;算出来是 kt/天,乘 3,652.667(10,958 ÷ 30 × 10)折成 kt/十年。代码接在第二节的取数后面:
js
// annual-trend.mjs ------ 年聚合+距平+趋势:接在第二节的 JSON 后面跑
import { readFileSync } from "node:fs";
const d = JSON.parse(readFileSync("./era5-pudong-1995-2024.json", "utf8")).daily;
const rows = d.time.map((t, i) => [t, d.wind_speed_10m_max[i]]); // [日期, 当日最大风速 kt]
// 年聚合:一年压成一点(30 个点,画年际线);十年均值=逐日池均值(12.57/11.51 的出处)
const byYear = new Map();
for (const [t, ws] of rows) {
const y = t.slice(0, 4);
if (!byYear.has(y)) byYear.set(y, { sum: 0, n: 0 });
const o = byYear.get(y);
o.sum += ws;
o.n += 1;
}
const yearly = [...byYear.entries()]
.map(([y, o]) => ({ y: Number(y), mean: o.sum / o.n }))
.toSorted((a, b) => a.y - b.y);
// 气候态=10,958 个日值的全池均值;距平=逐年均值 − 气候态(画距平条)
const clim = rows.reduce((a, [, ws]) => a + ws, 0) / rows.length;
const pool = (from, to) => {
const sel = rows.filter(([t]) => t.slice(0, 4) >= String(from) && t.slice(0, 4) <= String(to));
return sel.reduce((a, [, ws]) => a + ws, 0) / sel.length;
};
// 最小二乘斜率,一条公式:cov / var
const ols = (pts) => {
const n = pts.length;
const mx = pts.reduce((a, p) => a + p.x, 0) / n;
const my = pts.reduce((a, p) => a + p.y, 0) / n;
let cov = 0, varx = 0;
for (const p of pts) { cov += (p.x - mx) * (p.y - my); varx += (p.x - mx) ** 2; }
return cov / varx;
};
const DECADE_DAYS = (rows.length / 30) * 10; // 3,652.667 天/十年(22×365 + 8×366)
const first = pool(1995, 2004), last = pool(2015, 2024);
console.log(`前十年 ${first.toFixed(2)} / 后十年 ${last.toFixed(2)} kt,差 ${(last - first).toFixed(2)}(${(((last - first) / first) * 100).toFixed(1)}%)`);
console.log(`气候态 ${clim.toFixed(2)} kt;1995 距平 ${(yearly[0].mean - clim).toFixed(2)},2024 距平 ${(yearly[29].mean - clim).toFixed(2)}`);
console.log(`趋势·全逐日 OLS(主口径):${(ols(rows.map((r, i) => ({ x: i, y: r[1] }))) * DECADE_DAYS).toFixed(3)} kt/十年`);
console.log(`趋势·年际均值 OLS(30 点):${(ols(yearly.map((o) => ({ x: o.y, y: o.mean }))) * 10).toFixed(3)} kt/十年`);
// → 前十年 12.57 / 后十年 11.51 kt,差 −1.05(−8.4%)
// → 气候态 12.24 kt;1995 距平 +0.14,2024 距平 −0.80
// → 趋势·全逐日 OLS(主口径):−0.473 kt/十年
// → 趋势·年际均值 OLS(30 点):−0.466 kt/十年
跑出两行趋势:全逐日 −0.473,年际 −0.466------都对,算法不同。图上那条趋势线是全逐日 OLS------10,958 个日值一个不落地进拟合;年际那条先把 30 个年均值算出来再拟合------一年 365 天压成一个点、只投一票------两条线差 0.007。复现这些数字用全逐日版;手里只有年表就用年际版,但图上必须注明是哪条。
到这儿,信号立住了:这个趋势值得看,也值得怀疑------下一节,专门怀疑它。
五、四个怀疑,三招自验
怀疑不是态度,是清单:四个怀疑管「哪里可能失真」,三招管「失真了多大」------两套不一一配对,每招挑最重的怀疑下手。
怀疑一:喂进去的观测在变。1995 年的 ERA5 吃的卫星观测和 2024 年不是一个量级:同化系统全程冻结(IFS Cycle 41r2,一套模式跑三十年),但喂进去的观测在变 ------卫星资料三十年激增,趋势里混着「模式见到的观测变多了」这份系统差。再分析里的趋势,不等于纯大气信号------这里只说「再分析里的趋势在降」,不说「风速真的在变小」。
**怀疑二:日主导的损失。**10,958 个日值对第 5 篇 8,760 个时次,粗一档;而且一天只出一个样本:方向是全天一票加权合成、风速取最大那一刻------两者甚至不是同一时刻的。方向结构被整段抹平一层------这一层损失图上看不出来,可它确实在那儿。
**怀疑三:格点代表性。**单格点是那一个格子里模式演绎的风,不是跑道头的风------把它读成「浦东机场实测」,从源头就错了。
**怀疑四:极端不等于趋势。**均值三十年在降,30 年极值 42.5 kt 却落在 2022-09-14------台风「梅花」过境日。均值下降不等于没有大风;那一天怎么画,台风篇另有一笔。
招一,换点位 (对付怀疑三)。同一接口、同参数、同管线,点位挪到虹桥附近(31.20/121.34)再跑三十年:趋势 +0.230 (年际算法 +0.240,同号),对浦东的 −0.473------换一个点位,趋势翻了个方向。四十多公里外一正一负(两点还被吸到同一纬行上,只差 0.43° 经度------更近),一个反例就够推翻「上海的风在降」;至于单格点趋势到底对点位多敏感,两点位给不出统计答案,这里也不假装能给。
招二,换时段(对付怀疑四)。首尾十年切片你已经有了(12.57 → 11.51),玫瑰首末十年两列在第三节------切片看形状,不拆全长。
招三,用观测复核 (对付怀疑一)。第 5 篇那份 ZSPD 逐时观测(IEM 归档)只读不改,和 ERA5 对 2024 一年:「逐时最大风的日均」观测 14.66 kt 、ERA5 同窗 11.45 kt ------观测比 ERA5 高约 28%。这 28% 的主机理不是「抹平极值」:格点和站点本来就不是一回事,再分析对近地面风整体偏低------这是「不是那个站」的系统性部分,均值先差这么多。极值另有一笔:全年最大,观测 48.6 kt (09-16 02:00 UTC,台风过境那夜),ERA5 最大日值 34.5 kt 记在 09-15------差 14.1 kt,连日期都错开一天,这一笔才是极值被格点抹平。两行各自说明「不是观测」,但说明的是两件事------「模式的记忆,不是风的日记」,定量就在这两行。(观测取数命令见第 5 篇第二节,同一份 CSV 只读不改;核对的办法:逐时报文按 UTC 日取当日最大,对 365 天求均值;ERA5 这边用第四节的代码改个窗口就行。)
三招跑完,−0.473 还站在那儿,但站的范围缩小了:只属于这一格点、这套规矩、这一数据出身。三招不是保票,是排雷------排完雷,这条线仍然值得看,但你已经知道它的边界在哪:一个被怀疑过、边界标得清清楚楚的「再分析里的趋势」------不是「上海的风在变小」。

六、三十年怎么读:选型
| 图 | 回答什么 | 丢什么 | 输入 |
|---|---|---|---|
| 三十年玫瑰 | 长期分布:从哪来 × 多大 × 多常来 | 年际趋势;极端没有体积 | 10,958 个日主导样本 |
| 趋势线 | 年际变化:方向与快慢 | 分布与极端;算法(全逐日/年际)与点位一换,数字就变(第五节自验) | 全逐日序列或逐年均值 |
| 距平条 | 偏离气候态的年际脉搏 | 长期方向之外的结构 | 逐年均值 − 30 年气候态 |

四种画法到这儿收成一句:**杆场答时刻,玫瑰答分布,廓线答结构,趋势线答变化。**先问要答什么,再挑画法------第 1 篇立下的规矩,放到三十年上,一个字不用改。
七、收尾:复现、边界、下一个问题
复现四步,全是免费公开接口:
-
取数:第二节那条 curl,坐标换成你自己机场附近的------URL 里改
latitude/longitude两个数就行(换点位结果会不一样,第五节招一干的就是这个); -
年聚合+距平+趋势:第四节代码(全逐日 OLS,Node 20+);
-
玫瑰:第三节装包一行
npm i metweave@0.3.7,样本直喂windRose; -
自验:第五节三招。
该交代的,一行收拢:ERA5 再分析(经 Open-Meteo archive API,免费层非商用:CC-BY-4.0 署名+AGPL),非观测;样本=日主导(风速=当日逐时最大,风向=全天逐时风按风速加权合成的来向);点位=再分析格点 31.177504/121.78359,非 ZSPD 观测站;不做气候结论、不做天气研判,只谈「再分析里的趋势」。
三十年告诉你风在怎么变;不过说到底,你看到的是模式的记忆,不是风的日记。
还剩一个问题,一路用下来却没有答:**这些数字到底是怎么量出来的?**风杯、超声、卫星怎么在天上摸风------第 7 篇《风是怎么量出来的》。
一年叠成一朵,三十年连成一条线。
署名:YaoJaro
数据来源:ERA5 再分析(经 Open-Meteo archive API:免费层非商用,CC-BY-4.0 署名+AGPL;请求点位 31.14/121.80、吸附格点 31.177504/121.78359,非观测站;窗口 1995-01-01 → 2024-12-31,10,958 个日值)|观测复核(IEM ASOS 归档·上海浦东 ZSPD:公有领域,无准确性担保;2024 全年逐时,与第 5 篇同源同一份文件)|玫瑰渲染 metweave 0.3.7 windRose|文中数字均可由随文代码复算
下一篇《风是怎么量出来的》------风杯、超声、卫星怎么在天上摸风,一路用下来没答的那个问题到那儿答。如果这一篇让你把三十年的风连成了一条线,欢迎在掘金关注,系列接下来一篇一个台阶。