Skip to main content

第五节:气象预报数据加载

这一节我们主要加载GFS预报数据,数据源来自ucar的在线OPeNDAP服务。

获取GFS数据

一个正常系统一般数据来源于后端的业务接口,本系统本着尽可能利用外部接口,重点演示QE系统使用的原则,尽量从外部获取数据进行演示。在这里,我们发现了ucar的thredds服务下的GFS数据,是可以薅羊毛的!本教程本着教学目的使用此数据!

https://thredds.ucar.edu/thredds/catalog.html

从这里,我们可以看到 Forecast Model Data/下面,就是GFS模式的预报数据,这里有0.25、0.5和1度三种分辨率的数据,简直是非常贴心!出于速度和效果的权衡,我们选择0.5度分辨率的数据。

由于数值模式体量较大,ucar也没有永久存储,因此我们这里不贴出具体的数据路径,因为这个地址随时可能过期,请从上面的链接一步步点下去,就可以到最终的数据页面。

监测预报-第五节-GFS.png

OPeNDAP服务

在上方的打开的页面中,点击OPeNDAP,即可访问对应的服务。

什么是OPeNDAP?

OPeNDAP服务是一个标准化的在线数据服务,可以支持参数过滤,比如对获取的数据范围、层次、时次等信息进行过滤,在气象上有一定的使用场景,对于内网的二维数据应用,是一个不错的选择,开源的thredds服务器支持此服务。

监测预报-第五节-OPeNDAP.png

这个页面是一个数据辅助页,可以对指定的要素进行过滤和数据的获取,数据格式包括了文本格式和二进制格式,文本格式一般用于预览,二进制一般用于网络传输。

QE对OPeNDAP的支持
QE中内置了OPeNDAP的数据解析服务,支持区域范围、时间、层次的过滤器。由于OPeNDAP服务下数据的维度可能非常复杂,并不是仅针对气象数据,目前QE仅对常用的时间、层次、经纬度这四个维度做了适配,如果您发现有QE无法处理的OPeNDAP服务,欢迎反馈。

在QE中,使用DAPService对该服务进行封装,该对象实例的loadDataInfo可以获取该服务地址上的数据信息,loadData方法可以获取数据并且生成格点provider。

获取最新的GFS数据信息

GFS预报每天出四次结果,每次提供未来一段时间的预报,我们需要获取最新一个起报时次的数据,因此创建一个方法用于获取最新时次的OPeNDAP服务。

private async _getLatestDapService(backTimes = 1): Promise<{ service: DAPService, initialTime: moment.Moment }> {
    if (this._dapServiceInfo) {
        return this._dapServiceInfo;
    }
    const timeValid = async (time: moment.Moment): Promise<DAPService> => {
        const timeStr = time.format("YYYYMMDD_HH");
        const url = this._gfsUrlTemplate.replace("$time", timeStr);
        const service = new DAPService(url, undefined, false);
        const info = await service.loadDataInfo();
        return info ? service : undefined;
    };
    let now = moment().utc().minute(0).second(0).millisecond(0);
    //这个数据源的数据较慢
    const hour = now.hour();
    if (hour <= 5) {
        now.subtract(1, "days").hour(18);
    } else if (hour <= 11) {
        now.hour(0);
    } else if (hour <= 18) {
        now.hour(6);
    } else {
        now.hour(12);
    }
    let idx = 0;
    let service: DAPService = null;
    while (idx <= backTimes) {
        service = await timeValid(now);
        if (service) {
            this._dapServiceInfo = { service, initialTime: now };
            return this._dapServiceInfo;
        }
        now.subtract(6, "hours");
        idx++;
    }
    message.error("GFS数据服务出错!");
    return undefined;
}

在上面的方法中,我们支持了数据回溯,也就是如果我们按照指定时间规则(if语句中)没有能够成功获取数据之后,自动往前推一个时次(6小时),在我们当前的参数下,基本能够保证能够获取到正常数据,如果还发生异常,也可以把backTimes设置的更高,以确保能获取数据。

以上方法中用到的两个局部变量,这个需要我们在图层管理器中预先声明:

private _gfsUrlTemplate = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/GFS/Global_0p5deg/GFS_Global_0p5deg_$time00.grib2";
private _dapServiceInfo: { service: DAPService, initialTime: moment.Moment };

接下来就是使用获取到的服务来获取数据了。我们看一下loadData的方法定义是一个过滤器类型IDAPFilter

export interface IDAPFilter {
    varName: string;
    tIdx?: number;
    xStartIdx?: number;
    xEndIdx?: number;
    yStartIdx?: number;
    yEndIdx?: number;
    zIdx?: number;
}

这意味着我们获取数据之前需要先知道变量的名称,以及要获取的数据的时次、层次和经纬度范围信息,如果我们获取默认范围的数据,则可以不填写经纬度范围信息,如果我们时间和层次默认为0也可以不填写tIdx和zIdx,但是咱们的应用使用的时未来24小时预报,因此时次信息必须填写,在850hPa风场中,还必须填写层次信息。

生成获取数据的过滤器

  • 变量名:这个可以从上面的OPeNDAP辅助页面获取,里面列出了所有的变量名信息。

  • 层次、时间和范围信息

    这些信息可以从变量名所在的地方获取,如下图:

监测预报-第五节-变量信息.png

这是地表温度变量,下面的time1表示时间维度使用的时time1,lat和lon表示空间范围使用这两个维度。也就是这个变量的高度层只有1层,这时候,我们获取未来24小时的数据,就需要知道未来24小时是第几个预报时次,可以通过查找time1实现:

监测预报-第五节-时间信息.png

然后点击顶部的Get ASCII按钮,就可以获取到这个时间维度具体的信息:

监测预报-第五节-时间详情.png

数一数,就能发现未来24小时预报是第八个,对应的tIdx就是7(索引从0开始)。

也就是,如果我们要获未来24小时全球温度预报数据,要使用的过滤器为:

const filter={
    varName: "Temperature_surface",
    tIdx: 7,
    zIdx: 0
}

我们可以在_createGFSLayer做一个简单的测试:

const dapServieInfo = await this._getLatestDapService(1);
const dataRes=await dapServieInfo.service.loadData({
    varName:"Temperature_surface",
    tIdx:7,
    zIdx:0
});
console.log(dataRes);

打开控制台,可以看到输出如下: 监测预报-第五节-dap信息输出.png

关于格点数据的格式

在上一节中,我们知道了矢量数据是使用GeoJSON进行表达,在QE中,格点数据本质上是使用一个TypedArray进行表达,即一个连续的内存区域,这个数据块被封装为GridData类型,这个只表示一个二维格点数据场,跟坐标系无关。

当需要在地图上呈现时,就需要知道数据所代表的坐标信息,在QE中,目前仅支持经纬度坐标系的数据展示,因此数据的坐标信息使用经纬度进行描述,将坐标信息和格点数据封装在一起就是一个格点数据的provider(也称为解析器)。

通常我们接触到的接口返回的数据是一个一维或者二维的数组带一个文件头(数据元信息),这时候,我们通常可以直接使用内置的Array2DGridDataProvider来简化provider的创建,具体可以参考核心概念的数据源部分。

QE通过这样的结构可以支持任意格式的格点数据加载,只要能够自行实现IGridDataProvider接口即可,我们还提供了一个抽象的基类GridDataProviderBase来简化该接口的实现。

如果以上概念您理解起来稍有困难,可以多看我们云平台关于格点数据的示例,会有更深的理解,也可以阅读核心概念的数据源部分以加深理解!

根据用户选择的菜单动态获取数据

我们当然不能直接在代码中写我们需要温度数据,而是应该根据用户的点击获取不同的数据,我们又想起了之前的userData,这个很适合用来存储针对当前菜单的附加信息,因此我们只要把需要的信息,添加进来即可,这里我们创建一个dataInfo字段,用来存储当前菜单对应的数据获取参数。

在气象数据中,有一类比较特殊的数据是风场,这是由两个变量符合而成(UV或者风向风速),因此我们要针对这个数据做特殊处理,我们使用一个|来进行变量的分隔,然后增加一个额外的isVector字段用来表示该字段是否为矢量字段(即存在xy方向两个分量)。

菜单信息修改如下:

    public menuData: IMenuItem[] = [
    {
        name: "气象实况",
        childs: [
            {
                name: "Metar报",
                id: "realtime_metar",
                userData: {
                    overlay: true
                }
            },
            {
                name: "卫星云图",
                id: "realtime_sate",
                userData: {
                    baseMap: predefinedImageTiles.geoQPurplishBlue
                }
            }
        ],
        id: "realtime"
    },
    {
        name: "GFS预报",
        childs: [
            {
                name: "2米温度-024",
                id: "gfs_temp2m_024",
                userData: {
                    dataInfo: {
                        varName: "Temperature_surface",
                        tIdx: 7,
                        zIdx: 0,
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "累计降水-024",
                id: "gfs_accprecp_024",
                userData: {
                    dataInfo: {
                        varName: "Total_precipitation_surface_Mixed_intervals_Accumulation",
                        tIdx: 13,
                        zIdx: 0,
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "850hPa风场-024",
                id: "gfs_850uv_024",
                userData: {
                    dataInfo: {
                        varName: "u-component_of_wind_isobaric|v-component_of_wind_isobaric",
                        tIdx: 7,
                        zIdx: 35,
                        isVector: true
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "2米相对湿度-024",
                id: "gfs_rhu2m-024",
                userData: {
                    dataInfo: {
                        varName: "Relative_humidity_height_above_ground",
                        tIdx: 7,
                        zIdx: 0,
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "零度层相对湿度-024",
                id: "gfs_0rhu_024",
                userData: {
                    dataInfo: {
                        varName: "Relative_humidity_zeroDegC_isotherm",
                        tIdx: 7,
                        zIdx: 0,
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "地面气压-024",
                id: "gfs_pslp_024",
                userData: {
                    dataInfo: {
                        varName: "Pressure_surface",
                        tIdx: 7,
                        zIdx: 0,
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            }
        ],
        id: "gfs"
    },
];

接下来需要获取数据,然后创建图层,但是在此之前,为了避免创建图层的方法过大,我们将获取数据的逻辑拆分为_getGFSData,然后在创建图层的方法中调用:


private async _getGFSData(config: IGFSLayerConfig): Promise<IGridDataProvider> {
    const dapServieInfo = await this._getLatestDapService(1);
    if (!dapServieInfo) {
        message.warn("当前没有获取到有效的数据时次!");
        return undefined;
    }
	//如果是风矢量,则分别获取u和v变量
    if (config.isVector) {
        const vars = config.varName.split("|");
        const providerU = await this._getSinleData({ ...config, varName: vars[0] }, dapServieInfo.service);
        if (!providerU) {
            return undefined;
        }
        const providerV = await this._getSinleData({ ...config, varName: vars[1] }, dapServieInfo.service);
        if (!providerV) {
            return undefined;
        }
        const vectorProvider = new MemoryWindDataProvider(providerU, providerV, { lazyCalc: true, isUV: true });
        return vectorProvider;
    } else {
        return this._getSinleData(config, dapServieInfo.service);
    }
}

private async _getSinleData(config: IGFSLayerConfig, service: DAPService): Promise<IGridDataProvider> {
    let filter: IDAPFilter = { varName: config.varName, tIdx: config.tIdx, zIdx: config.zIdx };
    const result = await service.loadData(filter);
    return result.provider;
}

private async _createGFSLayer(dataInfo: IGFSLayerConfig): Promise<L.Layer> {
    let provider = await this._getGFSData(dataInfo);
    return undefined;
}
当数据为风场时,我们使用MemoryWindDataProvider来构建风场数据的解析器,框架会根据需要自动进行风向和风速的转换,lazyCalc表示是否延迟计算,如果是,只要当第一次访问未计算的数据时才会启动计算,建议设置为true,isUV表示前面两个provider是否是UV分量,如果为否,则表示是速度和方向。

至此,我们已经可以根据用户点击的不同菜单,来获取不同的数据了,接下来只要设置样式和创建图层即可!

设置图层样式

在创建图层之前,我们需要创建图层的样式,不同的气象要素我们希望实现不同的样式,这时候仍然可以将样式的信息配置到userData的dataInfo中。在这里,我们准备实现一个填色图层和一个格点填值图层,如果是风矢量,则是填色图层和一个动态风场图层。

自定义的信息有两种方式,一种是仅提供色标的配置,其余使用默认,另一种是完全自定义,如:

仅定义色标

dataInfo: {
    varName: "u-component_of_wind_isobaric|v-component_of_wind_isobaric",
    tIdx: 7,
    zIdx: 35,
    legend: predefinedLegendNames.wind_17lev,
    isVector: true
}

完全自定义

dataInfo: {
    varName: "Total_precipitation_surface_Mixed_intervals_Accumulation",
    tIdx: 13,
    zIdx: 0,
    pixelStyle: {
        fillColor: "color-precp#res"
    },
    labelStyle: {
        text: {
            data: "#decimal?len=1",
            zoomMin: 5,
            visible: (val) => {
                return val >= 0.01;
            }
        }
    }
}

在完全自定义的时候,我们区分了格点填色个格点填值两种类型,这两个图层对应的具体样式配置可以参考二维图层清单中的具体内容。

更新菜单配置

完整的菜单配置更新如下:

public menuData: IMenuItem[] = [
    {
        name: "气象实况",
        childs: [
            {
                name: "Metar报",
                id: "realtime_metar",
                userData: {
                    overlay: true
                }
            },
            {
                name: "卫星云图",
                id: "realtime_sate",
                userData: {
                    baseMap: predefinedImageTiles.geoQPurplishBlue
                }
            }
        ],
        id: "realtime"
    },
    {
        name: "GFS预报",
        childs: [
            {
                name: "2米温度-024",
                id: "gfs_temp2m_024",
                userData: {
                    dataInfo: {
                        varName: "Temperature_surface",
                        tIdx: 7,
                        zIdx: 0,
                        legend: predefinedLegendNames.temp_19lev,
                        labelStyle: {
                            text: {
                                data: (val) => {
                                    return (val - 273.15).toFixed(1)
                                },
                                zoomMin: 5
                            }
                        }
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "累计降水-024",
                id: "gfs_accprecp_024",
                userData: {
                    dataInfo: {
                        varName: "Total_precipitation_surface_Mixed_intervals_Accumulation",
                        tIdx: 13,
                        zIdx: 0,
                        pixelStyle: {
                            fillColor: "color-precp#res"
                        },
                        labelStyle: {
                            text: {
                                data: "#decimal?len=1",
                                zoomMin: 5,
                                visible: (val) => {
                                    return val >= 0.01;
                                }
                            }
                        }
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "850hPa风场-024",
                id: "gfs_850uv_024",
                userData: {
                    dataInfo: {
                        varName: "u-component_of_wind_isobaric|v-component_of_wind_isobaric",
                        tIdx: 7,
                        zIdx: 35,
                        legend: predefinedLegendNames.wind_17lev,
                        isVector: true
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "2米相对湿度-024",
                id: "gfs_rhu2m-024",
                userData: {
                    dataInfo: {
                        varName: "Relative_humidity_height_above_ground",
                        tIdx: 7,
                        zIdx: 0,
                        pixelStyle: {
                            fillColor: "color-rh#res"
                        }
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "零度层相对湿度-024",
                id: "gfs_0rhu_024",
                userData: {
                    dataInfo: {
                        varName: "Relative_humidity_zeroDegC_isotherm",
                        tIdx: 7,
                        zIdx: 0,
                        legend: predefinedLegendNames.rh_19lev
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            },
            {
                name: "地面气压-024",
                id: "gfs_pslp_024",
                userData: {
                    dataInfo: {
                        varName: "Pressure_surface",
                        tIdx: 7,
                        zIdx: 0,
                        legend: predefinedLegendNames.BlueWhiteOrangeRed
                    },
                    baseMap: predefinedImageTiles.windy,
                    baseMapPane: consts.customPanes.topmap.name
                }
            }
        ],
        id: "gfs"
    }
];
QE内置了大量色标,这些色标来源于NCL绘图软件中的配置,可以直接使用在QE中。

为了方便获得编辑器的提示,我们定义一个数据类型,用于表达GFS的图层配置信息(即上面的dataInfo):

interface IGFSLayerConfig {
    varName: string,
    legend: string,
    tIdx: number,
    zIdx: number,
    isVector?: boolean;
    labelStyle?: IGridLabelStyleOptions;
    windStyle?: IWindLayerStyleOptions;
    pixelStyle?: IPixelLayerStyleOptions;
    dataMin?: number;
    dataMax?: number;
}

这里的dataMin和dataMax是图层样式配置中,使用bitmap方式设置色标时需要的色标的最小和最大值,如果不提供,将会实时根据数据实际值进行最大最小值计算(自动计算下,每个数据的色标对应的数值区间会有所差异)。

创建图层

定义好图层样式后,我们就可以开始创建图层了,这里需要注意的问题有:

  • 如果是风矢量图层,我们需要创建动态风场图层,否则需要创建格点填值图层
  • 不管什么数据,都需要一个填色的图层,当数据是风场数据时,要根据风场的provider获取风速的provider
  • 要根据图层菜单中色标的配置信息或者样式的配置信息来构建样式,如果样式中提供了自定义信息,优先使用,否则使用默认配置
  • 使用一个图层组管理上面所有创建的图层,并且加到地图中

根据以上思路,具体的实现代码如下:

private async _createGFSLayer(dataInfo: IGFSLayerConfig): Promise<L.Layer> {
    let provider = await this._getGFSData(dataInfo);
    const layer = L.layerGroup();
    if (dataInfo.isVector) {
        const windProvider = provider as MemoryWindDataProvider;
        const speedGrid = windProvider.getS();
        provider = new MemoryGridDataProvider([[speedGrid]], { gridOptions: windProvider.gridOptions });
        //create wind layer
        const windLayer = new LWindLayer({ pane: consts.customPanes.feature.name })
            .setDataSource(windProvider)
            .setDrawOptions(dataInfo.windStyle || {
                usePoint: true,
                pointSize: 2,
                color: "white",
                fadeRate: 0.95,
                minOpacity: 0.2,
                interpMethod: WindInterpMethodType.fast
            });
        layer.addLayer(windLayer);
    } else {
        const labelLayer = new LGridLabelLayer({ pane: consts.customPanes.station.name })
            .setDataSource(provider)
            .setDrawOptions(dataInfo.labelStyle || {
                text: {
                    data: "#decimal?len=1",
                    color: "black",
                    zoomMin: 5
                }
            });
        layer.addLayer(labelLayer);
    }
    let colorScale: BitmapColorScaleGL;
    if (!dataInfo.pixelStyle) {
        const legendName = dataInfo.legend || predefinedLegendNames.BkBlAqGrYeOrReViWh200;
        let maxMin = { max: 0, min: 100 };
        if (!defined(dataInfo.dataMin) || !defined(dataInfo.dataMax)) {
            maxMin = provider.getGrid().maxMin;
        } else {
            maxMin.max = dataInfo.dataMax;
            maxMin.min = dataInfo.dataMin;
        }
        colorScale = await getPredefinedBitmapScale(legendName, maxMin.min, maxMin.max, true);
    }
    const pixelLayer = new LPixelLayer()
        .setDataSource(provider)
        .setDrawOptions(dataInfo.pixelStyle || {
            colorScale
        });
    layer.addLayer(pixelLayer);
    return layer;
}
在QE中,有两种格点配色实现方式,一种是使用fillColor,另一种是使用colorScale,前者是使用分级填色规则,后者是使用一个bitmap色条结合最大最小值来线性取值(也可以手动指定取值位置实现非线性定位),具体可以参考核心概念的格点填色规则部分。

修改对创建GFS图层的调用

在之前的代码中,我们没有给创建GFS图层传入参数,但是现在GFS图层的创建代码要求传入用户配置的定义信息,所以需要将该信息传入创建GFS图层的方法:

...
layer = await this._createGFSLayer(item.userData.dataInfo);
...

现在,点击菜单试试,我们可以正常加载GFS数据了,而且不同的数据还能够使用不同的配色!除了外网数据有些慢,其他完美!

监测预报-第五节-温度数据.jpg

监测预报-第五节-降水.png

监测预报-第五节-相对湿度.png

监测预报-第五节-风场.png

显示色标

对于使用colorScale的数据,我们可以很方便的增加色标,如果是分级规则的,我们可以创建为colorScale方式或者直接根据分级规则使用css创建色标(在此不做更多介绍)。

创建色标容器

在创建地图的地方增加以下代码,用于放置色标:


function createLegendContainer(): HTMLDivElement {
    const div = document.createElement("div");
    div.style.position = "fixed";
    div.style.bottom = "30px";
    div.style.left = "110px";
    div.style.width = "100%";
    div.style.height = "15px";
    div.style.zIndex = "999999";
    div.id = "panel";
    document.body.appendChild(div);
    return div;
}
const legendContainer = createLegendContainer();

获取colorScale对应的色标

当创建colorScale的时候,已经默认生成了色标的css代码,更新创建GFS图层的代码:

...
if (!dataInfo.pixelStyle) {
    ...
    colorScale = await getPredefinedBitmapScale(legendName, maxMin.min, maxMin.max, true);
    legendContainer.style.backgroundImage = colorScale["css"];
}
...

这样,当有colorScale时,在页面下方就会有一个对应的色标出现。

监测预报-第五节-色标.jpg

当加载新的图层的时候,我们要先移除之前的色标(这里假设所有有色标的图层都是不支持叠加的),那么我们可以在创建图层的代码中先清除现有色标:

public async createLayer(item: IMenuItem): Promise<boolean> {
    if (this._layers[item.id]) {
        return;
    }
    legendContainer.style.backgroundImage = "";
	...
}

总结

在本节中,我们通过OPeNDAP服务获取了GFS数据,然后在菜单中更新了不同数据的样式配置,最后创建了格点图层来实现数据的显示,通过本章节,您已经学会了格点数据的加载和显示了。到目前为止,您已经完全可以完成矢量和栅格数据渲染的应用系统了!

完整代码与效果