"""
集合预报检验与后处理：基于参数字典 para（与 para_example/config.json 结构一致）
提供场与点上的集合平均、ACC、RMSE/Spread、Talagrand 直方图、SDRMS 比值及异常点偏差等计算。

主要入口（均以 para 字典为入参）：
  - calc_ensemble_mean_field:  集合平均场，输出 NetCDF
  - generate_points_to_compare: 指定纬经度点提取成员与观测，输出 CSV
  - calc_acc_middle_result:     Z500 ACC 中间结果（依赖 meteva），输出 parquet
  - calc_rmse_spread_by_latband: 按纬度带 RMSE/Spread，输出 txt
  - calc_talagrand_histogram:   Talagrand 直方图，输出 txt 与 png
  - calc_sdrms_ratio:           (Spread/RMSE - 1) 空间分布，输出 nc 与填色图
  - calc_outliers_diff:         异常点比例偏差，输出 npy 与填色图

典型用法::

    import json
    from meteva.product.program import calc_ensemble_mean_field

    with open("config.json", "r") as f:
        para = json.load(f)

    calc_ensemble_mean_field(para)
"""

import math
import os
from collections import defaultdict
from glob import glob
from datetime import datetime, timedelta

import numpy as np
import xarray as xr
import pandas as pd

try:
    import matplotlib.pyplot as plt

    _MATPLOTLIB_AVAILABLE = True
except ImportError:
    _MATPLOTLIB_AVAILABLE = False

try:
    import cartopy.crs as ccrs
    import cartopy.feature as cfeature
    from matplotlib.colors import ListedColormap, BoundaryNorm

    _CARTOPY_AVAILABLE = True
except ImportError:
    _CARTOPY_AVAILABLE = False

try:
    import meteva

    _METAVA_AVAILABLE = True
except ImportError:
    _METAVA_AVAILABLE = False


def _get_obs_data_var_name(ds):
    """从观测 Dataset 中自动获取数据变量名（兼容无命名 NetCDF 的 __xarray_dataarray_variable__）。"""
    if hasattr(ds, "data_vars") and len(ds.data_vars) > 0:
        return list(ds.data_vars)[0]
    if hasattr(ds, "name") and ds.name is not None:
        return ds.name
    return "__xarray_dataarray_variable__"


def _generate_timestr(date_range, hours=("00", "12")):
    """根据单个日期范围字符串（如 '20231201-20231231'）生成起报时间列表 YYYYMMDDHH。"""
    start_str, end_str = date_range.split("-")
    start_date = datetime.strptime(start_str, "%Y%m%d")
    end_date = datetime.strptime(end_str, "%Y%m%d")

    timestr_list = []
    current_date = start_date
    while current_date <= end_date:
        date_str = current_date.strftime("%Y%m%d")
        for hour in hours:
            datetime_str = f"{date_str}{hour}"
            timestr_list.append(datetime_str)
        current_date += timedelta(days=1)

    return timestr_list


def _generate_timestrs(date_ranges, hours=("00", "12")):
    """根据多个日期范围生成合并的起报时间列表 YYYYMMDDHH。"""
    timestr_list = []

    for date_range in date_ranges:
        start_str, end_str = date_range.split("-")
        start_date = datetime.strptime(start_str, "%Y%m%d")
        end_date = datetime.strptime(end_str, "%Y%m%d")

        current_date = start_date
        while current_date <= end_date:
            date_str = current_date.strftime("%Y%m%d")
            for hour in hours:
                timestr_list.append(f"{date_str}{hour}")
            current_date += timedelta(days=1)

    return timestr_list


def _generate_timestr_from_range(begin_times, end_times, time_step_hours):
    """根据 begin_times、end_times 和 time_step 生成起报时间列表 YYYYMMDDHH。"""
    start_dt = datetime.strptime(begin_times, "%Y%m%d%H")
    end_dt = datetime.strptime(end_times, "%Y%m%d%H")
    timestr_list = []
    current = start_dt
    while current <= end_dt:
        timestr_list.append(current.strftime("%Y%m%d%H"))
        current += timedelta(hours=time_step_hours)
    return timestr_list


def calc_ensemble_mean_field(para):
    """
    根据 para 字典（para_example 风格）计算集合平均场并输出 NetCDF。

    参数
    ----
    para : dict
        至少包含以下键（与 para_example 一致）：
        - "input_dir":  输入目录完整路径
        - "output_dir":  输出目录完整路径
        - "begin_times":  起报开始时间，格式 "YYYYMMDDHH"，如 "2023120108"
        - "end_times":  起报结束时间，格式 "YYYYMMDDHH"，如 "2024022920"
        - "time_step":  起报时间步长（小时），如 12
        - "dtimes":  预报时效 [起始, 结束, 步长]（小时），如 [24, 721, 24]
    """
    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")

    begin = para["begin_times"]
    end = para["end_times"]
    if isinstance(begin, list):
        begin, end = begin[0], end[0]
    timestr_list = _generate_timestr_from_range(begin, end, para["time_step"])

    dtimes = para["dtimes"]
    forecast_hour_start = dtimes[0]
    forecast_hour_end = dtimes[1]
    step = dtimes[2]
    n_times = len(timestr_list)
    n_steps = len(range(forecast_hour_start, forecast_hour_end, step))
    print(f"【集合平均场】开始计算，共 {n_times} 个起报时间、{n_steps} 个预报时效")

    count_written = 0
    for start_time in timestr_list:
        # 创建输出目录
        output_dir = os.path.join(output_path, "ensemble_mean_field", start_time)
        os.makedirs(output_dir, exist_ok=True)

        # 遍历所有预报时效
        for forecast_hour in range(forecast_hour_start, forecast_hour_end, step):
            # 预报时效三位数格式，如 024、048
            forecast_str = f"{forecast_hour:03d}"

            # 匹配该起报时次、该时效下所有成员文件 p_XX_YYYYMMDDHH.TTT.nc
            member_files = sorted(
                glob(os.path.join(input_path, start_time, f"p_*_{start_time}.{forecast_str}.nc"))
            )

            if not member_files:
                continue  # 若无对应成员文件则跳过

            # 打开所有成员并沿 member 维拼接
            datasets = [xr.open_dataset(f) for f in member_files]
            stacked_data = xr.concat(datasets, dim="member")

            # 沿 member 维求平均得到集合平均
            mean_data = stacked_data.mean(dim="member")

            # 写出集合平均到 NetCDF
            mean_file = os.path.join(output_dir, f"p_mean_{start_time}.{forecast_str}.nc")
            mean_data.to_netcdf(mean_file)
            count_written += 1
            print(f"  起报 {start_time} 时效 {forecast_str}h（{len(member_files)} 成员）-> {os.path.basename(mean_file)}")

            # 关闭数据集
            for ds in datasets:
                ds.close()

    print(f"【集合平均场】计算完成，共写出 {count_written} 个 NetCDF 文件")


# 默认两个点：与 02 脚本原逻辑一致，便于兼容
_DEFAULT_POINTS = [(45.0, 110.0), (-45.0, 110.0)]


def process_nc_files_by_group_latlon(
        directory, start, end, step, n, output_path, points=None, obs_path=None):
    """
    对 directory 下各成员（p_00～p_(n-1)）按预报时效提取指定多个纬经度点的 Z 值，
    对 p_00 同时从观测文件读对应时刻各点的 obs；结果汇总为 DataFrame 并保存 CSV。

    directory: 某起报时次目录，如 .../2024010100；output_path: 输出目录。
    start, end, step: 预报时效范围与步长（小时）；n: 成员个数（如 31）。
    points: 列表 [(lat1, lon1), (lat2, lon2), ...]，为 None 时使用默认两点 (45,110)、(-45,110)。
    obs_path: 观测文件所在目录，下含 obs_YYYYMMDDHH.000.nc；为 None 时不读观测。
    """
    if points is None:
        points = list(_DEFAULT_POINTS)
    if obs_path is None:
        obs_path = ""
    group_names = [f"p_{i:02}" for i in range(n)]
    time_steps = [f"{i:03}" for i in range(start, end, step)]
    z_stats = {}
    for group in group_names:
        group_stats = {}
        obs_stats = {}
        for step_str in time_steps:
            # 仅对控制预报 p_00 读观测场，取各点的 Z
            if group == "p_00":
                start_datetime = datetime.strptime(
                    os.path.basename(directory), "%Y%m%d%H"
                )
                obs_datetime = start_datetime + timedelta(hours=int(step_str))
                obs_time = obs_datetime.strftime("%Y%m%d%H")
                obs_file = os.path.join(
                    obs_path.rstrip("/\\"), f"obs_{obs_time}.000.nc"
                )
                if obs_path and os.path.exists(obs_file):
                    ds_obs = xr.open_dataset(obs_file)
                    obs_var = _get_obs_data_var_name(ds_obs)
                    vals = []
                    for lat, lon in points:
                        d = ds_obs[obs_var].sel(
                            lat=lat, lon=lon, method="nearest"
                        )
                        vals.append(d.item())
                    obs_stats[step_str] = tuple(vals)
                    ds_obs.close()
                z_stats["obs"] = obs_stats

            # 当前成员、当前时效的预报文件
            file_name = f"{group}_{os.path.basename(directory)}.{step_str}.nc"
            file_path = os.path.join(directory, file_name)
            if os.path.exists(file_path):
                try:
                    ds = xr.open_dataset(file_path)
                    if "Z" in ds.variables:
                        vals = []
                        for lat, lon in points:
                            z_data = ds["Z"].sel(
                                lat=lat, lon=lon, method="nearest"
                            )
                            vals.append(z_data.item())
                        group_stats[step_str] = tuple(vals)
                    else:
                        group_stats[step_str] = (9999.0,) * len(points)
                    ds.close()
                except Exception:
                    group_stats[step_str] = (9999.0,) * len(points)
            else:
                group_stats[step_str] = (9999.0,) * len(points)

        z_stats[group] = group_stats

    # 汇总为 DataFrame：行=组/obs，列=时效，值为各点 Z 的元组 (v1, v2, ...)
    z_stats_df = pd.DataFrame.from_dict(z_stats, orient="index")
    output_file = os.path.join(
        output_path, os.path.basename(directory) + "_z_lat_lon_values_by_group.csv"
    )
    z_stats_df.to_csv(output_file)
    print(f"  指定点 Z 值（{len(points)} 点）已保存: {os.path.basename(output_file)}")


def generate_points_to_compare(para):
    """
    封装 @post/02_generate_points_to_compare.py 中的主程序逻辑。
    参数 para 与 para_example 结构一致。

    参数 para 字典至少包含（与 para_example 一致）：
    - "input_dir":  预报数据目录完整路径（每起报时次对应其下子目录 YYYYMMDDHH）
    - "output_dir":  结果 CSV 的输出目录
    - "begin_times":  起报开始时间 "YYYYMMDDHH"
    - "end_times":  起报结束时间 "YYYYMMDDHH"
    - "time_step":  起报时间步长（小时）
    - "dtimes":  预报时效 [起始, 结束, 步长]（小时），如 [24, 721, 24]
    - "n_members":  成员个数，默认 31
    - "points":  可选，要提取的点；可为列表 [(lat1, lon1), ...] 或字典 {"lat": [lat1, ...], "lon": [lon1, ...]}；默认两点
    - "obs_path":  观测文件目录，用于读取对应时刻观测场
    """
    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")
    output_path = os.path.join(output_path, "points_to_compare")
    os.makedirs(output_path, exist_ok=True)
    begin = para["begin_times"]
    end = para["end_times"]
    if isinstance(begin, list):
        begin, end = begin[0], end[0]
    timestr_list = _generate_timestr_from_range(begin, end, para["time_step"])

    dtimes = para["dtimes"]
    start, end, step = dtimes[0], dtimes[1], dtimes[2]
    n = para.get("n_members", 31)
    points = para.get("points", _DEFAULT_POINTS)
    if isinstance(points, dict) and "lat" in points and "lon" in points:
        points = list(zip(points["lat"], points["lon"]))
    elif not isinstance(points, list):
        points = list(_DEFAULT_POINTS)
    obs_path = para.get("obs_path", "")
    if obs_path:
        obs_path = obs_path.rstrip("/\\")

    print(f"【指定点提取】开始计算，共 {len(timestr_list)} 个起报时间，{len(points)} 个纬经度点")
    # 遍历每个起报时间，在对应目录下按成员和时效提取各点 Z 并写 CSV
    for start_time in timestr_list:
        print(f"  处理起报时间 {start_time}")
        directory = os.path.join(input_path, start_time)
        process_nc_files_by_group_latlon(
            directory,
            start=start,
            end=end,
            step=step,
            n=n,
            output_path=output_path,
            points=points,
            obs_path=obs_path,
        )
    print("【指定点提取】计算完成")


def calc_acc_middle_result(para):
    """
    使用 meteva 计算 500hPa 位势高度（Z500）的 ACC（异常相关系数）中间结果。
    参数 para 与 para_example 结构一致。

    参数 para 至少包含：
    - "input_dir":  控制预报（p_00）所在目录
    - "output_dir":  集合平均（p_mean）所在目录及 ACC 中间结果（.h5）输出目录
    - "obs_path":  实况场目录，下含 obs_YYYYMMDDHH.000.nc
    - "begin_times":  列表，每段起报开始时间 "YYYYMMDDHH"，如 ["2023120108", "2024060108"]
    - "end_times":  列表，与 begin_times 等长，每段起报结束时间 "YYYYMMDDHH"
    - "dtimes":  预报时效 [起, 止, 步长]，如 [24, 721, 24]
    - "lat_lon_ranges":  区域字典，如 {"NH": {"lat_min":20, "lat_max":80, ...}, ...}
    - "time_step":  起报时间间隔（小时），与 para_example 一致

    可选：
    - "grid_reso":  网格分辨率（度），默认 0.25
    """
    if not _METAVA_AVAILABLE:
        raise ImportError("acc_calculate 需要安装 meteva，请先安装后使用。")

    input_dir = para["input_dir"].rstrip("/\\")
    output_dir = para["output_dir"].rstrip("/\\")
    obs_path = para["obs_path"].rstrip("/\\")
    dtimes = para["dtimes"]

    # 多段起止时间：begin_times / end_times 均为列表，长度一致
    begin_list = para["begin_times"]
    end_list = para["end_times"]
    if len(begin_list) != len(end_list):
        raise ValueError("begin_times 与 end_times 列表长度必须相同")
    start_times = [datetime.strptime(s, "%Y%m%d%H") for s in begin_list]
    end_times = [datetime.strptime(s, "%Y%m%d%H") for s in end_list]

    if "lat_lon_ranges" in para:
        lat_lon_ranges = para["lat_lon_ranges"]
    else:
        lat = para.get("latitude", {"lat_mins": [-90], "lat_maxs": [90]})
        lon = para.get("longitude", {"lon_min": 0, "lon_max": 359.75})
        lat_lon_ranges = {
            "default": {
                "lat_min": lat["lat_mins"][0] if isinstance(lat["lat_mins"], list) else lat["lat_mins"],
                "lat_max": lat["lat_maxs"][0] if isinstance(lat["lat_maxs"], list) else lat["lat_maxs"],
                "lon_min": lon["lon_min"],
                "lon_max": lon["lon_max"],
            }
        }
    time_step = para.get("time_step", 24)
    grid_reso = para.get("grid_reso", 0.25)

    dir_ob = os.path.join(obs_path, "obs_YYYYMMDDHH.000.nc")
    dir_fo_ens = os.path.join(output_dir, "ensemble_mean_field", "YYYYMMDDHH", "p_mean_YYYYMMDDHH.TTT.nc")
    dir_fo_ctl = os.path.join(input_dir, "YYYYMMDDHH", "p_00_YYYYMMDDHH.TTT.nc")

    n_regions = len(lat_lon_ranges)
    n_periods = len(start_times)
    print(f"【ACC 中间结果】开始计算，共 {n_periods} 个时段、{n_regions} 个区域")
    for region, values in lat_lon_ranges.items():
        lat_min = values["lat_min"]
        lat_max = values["lat_max"]
        lon_min = values["lon_min"]
        lon_max = values["lon_max"]
        for it, start_time in enumerate(start_times):
            end_time = end_times[it]
            start_time_str = start_time.strftime("%Y%m%d%H")
            end_time_str = end_time.strftime("%Y%m%d%H")
            middle_result_path = os.path.join(
                output_dir, "acc_middle_result",
                f"z500_acc_{start_time_str}_{end_time_str}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.parquet",
            )
            # INSERT_YOUR_CODE
            # 创建 middle_result_path 的文件夹
            os.makedirs(os.path.dirname(middle_result_path), exist_ok=True)
            para_acc = {
                "begin_time": start_time,
                "end_time": end_time,
                "time_step": time_step,
                "middle_result_path": middle_result_path,
                "time_type": "UT",
                "grid": meteva.base.grid(
                    [lon_min, lon_max, grid_reso],
                    [lat_min, lat_max, grid_reso],
                ),
                "step": None,
                "ob_data": {
                    "dir_ob": dir_ob,
                    "read_method": meteva.base.io.read_griddata_from_nc,
                    "read_para": {},
                    "time_type": "UT",
                    "multiple": 1,
                },
                "fo_data": {
                    "Pangu_ENS": {
                        "dir_fo": dir_fo_ens,
                        "dtime": dtimes,
                        "read_method": meteva.base.io.read_griddata_from_nc,
                        "read_para": {},
                        "time_type": "UT",
                        "multiple": 1,
                    },
                    "Pangu_CTL": {
                        "dir_fo": dir_fo_ctl,
                        "dtime": dtimes,
                        "read_method": meteva.base.io.read_griddata_from_nc,
                        "read_para": {},
                        "time_type": "UT",
                        "multiple": 1,
                    },
                },
            }
            print(
                f"  时段 {start_time.strftime('%Y%m%d%H')}～{end_time.strftime('%Y%m%d%H')} 区域 {region} -> 已写出 parquet")
            meteva.method.acc_middle_z500(para_acc)
    print("【ACC 中间结果】计算完成")


def calc_rmse_spread_by_latband(para):
    """
    按纬度带与起报/预报时效计算集合的加权 Spread、空间平均 RMSE（集合平均与控制预报）
    及未加权版本，结果写入每时效每纬度带的 txt 文件。参数 para 与 para_example 一致。

    参数 para 至少包含：
    - "input_dir":  预报成员所在目录
    - "output_dir":  输出 txt 的根目录（下按 start_time 建子目录）
    - "obs_path":  观测文件目录
    - "begin_times":  列表，每段起报开始 "YYYYMMDDHH"
    - "end_times":  列表，与 begin_times 等长
    - "time_step":  起报时间步长（小时）
    - "dtimes":  预报时效 [起, 止, 步长]，如 [24, 721, 24]
    - "lat_lon_ranges":  区域字典，如 {"NH": {"lat_min", "lat_max", "lon_min", "lon_max"}, ...}
    """
    if not _METAVA_AVAILABLE:
        raise ImportError("calc_rmse_spread_by_latband 需要安装 meteva，请先安装后使用。")

    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")
    obs_path = para["obs_path"].rstrip("/\\")

    begin_list = para["begin_times"]
    end_list = para["end_times"]
    time_step = para["time_step"]
    timestr_list = []
    for i in range(len(begin_list)):
        timestr_list.extend(
            _generate_timestr_from_range(begin_list[i], end_list[i], time_step)
        )

    dtimes = para["dtimes"]
    forecast_hour_start = dtimes[0]
    forecast_hour_end = dtimes[1]
    step = dtimes[2]

    if "lat_lon_ranges" in para:
        lat_lon_ranges = para["lat_lon_ranges"]
    else:
        lat = para.get("latitude", {"lat_mins": [-90], "lat_maxs": [90]})
        lon = para.get("longitude", {"lon_min": 0, "lon_max": 359.75})
        lat_lon_ranges = {
            "default": {
                "lat_min": lat["lat_mins"][0] if isinstance(lat["lat_mins"], list) else lat["lat_mins"],
                "lat_max": lat["lat_maxs"][0] if isinstance(lat["lat_maxs"], list) else lat["lat_maxs"],
                "lon_min": lon["lon_min"],
                "lon_max": lon["lon_max"],
            }
        }

    n_regions = len(lat_lon_ranges)
    n_times = len(timestr_list)
    print(f"【RMSE/Spread】开始计算，共 {n_regions} 个区域、{n_times} 个起报时间")
    for region, values in lat_lon_ranges.items():
        lat_min = values["lat_min"]
        lat_max = values["lat_max"]
        lon_min = values["lon_min"]
        lon_max = values["lon_max"]

        for start_time in timestr_list:
            print(f"  区域 {region} 起报 {start_time}")
            start_datetime = datetime.strptime(start_time, "%Y%m%d%H")
            output_dir = os.path.join(output_path, "rmse_spread_result", start_time)
            os.makedirs(output_dir, exist_ok=True)

            for forecast_hour in range(forecast_hour_start, forecast_hour_end, step):
                forecast_str = f"{forecast_hour:03d}"
                member_files = sorted(
                    glob(os.path.join(input_path, start_time, f"p_*_{start_time}.{forecast_str}.nc"))
                )
                if not member_files:
                    continue

                datasets = [xr.open_dataset(f) for f in member_files]
                stacked_data = xr.concat(datasets, dim="member")

                lats = stacked_data["lat"].values
                lons = stacked_data["lon"].values
                yy = np.meshgrid(lons, lats)[1]
                weight = np.cos(yy * math.pi / 180)
                grid0 = meteva.base.get_grid_of_data(stacked_data)
                grid0_ = meteva.base.grid(grid0.glon, grid0.glat)
                grd_weight = meteva.base.grid_data(grid0_, weight).mean(dim="member")

                cov_data = stacked_data.var(dim="member")
                weighted_cov_data = cov_data * grd_weight.values
                subset = weighted_cov_data.sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )
                space_avg = subset.mean(dim=["lat", "lon"])["Z"].item()
                spread_data = np.sqrt(space_avg)

                subset_raw = cov_data.sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )
                space_avg_raw = subset_raw.mean(dim=["lat", "lon"])["Z"].item()
                spread_data_raw = np.sqrt(space_avg_raw)

                obs_datetime = start_datetime + timedelta(hours=forecast_hour)
                obs_time = obs_datetime.strftime("%Y%m%d%H")
                obs_file = os.path.join(obs_path, f"obs_{obs_time}.000.nc")
                ds_obs_mean = (
                    meteva.base.read_griddata_from_nc(obs_file)
                    .to_dataset()
                    .mean(dim="member")
                    .sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
                )

                mean_data = stacked_data.mean(dim="member").sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )
                grd_slice = grd_weight.sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )
                obs_var = _get_obs_data_var_name(ds_obs_mean)
                obs_vals = ds_obs_mean[obs_var].values
                mse_data = (
                        np.square(
                            obs_vals - mean_data["Z"].values
                        )
                        * grd_slice.values
                )
                space_rmse = np.sqrt(np.mean(mse_data))

                ctl_data = stacked_data.sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                ).isel(member=0)
                mse_data_ctl = (
                        np.square(
                            obs_vals - ctl_data["Z"].values
                        )
                        * grd_slice.values
                )
                space_rmse_ctl = np.sqrt(np.mean(mse_data_ctl))

                mse_data_raw = np.square(
                    obs_vals - mean_data["Z"].values
                )
                space_rmse_raw = np.sqrt(np.mean(mse_data_raw))

                print(
                    f"    时效 {forecast_str}h -> SPREAD={spread_data:.2f}  RMSE(集合平均)={space_rmse:.2f}  RMSE(控制)={space_rmse_ctl:.2f}")

                out_file = os.path.join(
                    output_dir,
                    f"p_rmse_spread_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.txt",
                )
                with open(out_file, "w") as f:
                    f.write(f"SPREAD: {spread_data}\n")
                    f.write(f"RMSE: {space_rmse}\n")
                    f.write(f"SPREAD_RAW: {spread_data_raw}\n")
                    f.write(f"RMSE_RAW: {space_rmse_raw}\n")
                    f.write(f"RMSE_CTL: {space_rmse_ctl}\n")

                for ds in datasets:
                    ds.close()

    print("【RMSE/Spread】计算完成")


def _compute_histogram_2d(stacked_data, ds_obs_mean, grd_weight):
    """
    计算 2D Talagrand 直方图（集合秩直方图）。
    stacked_data: (member, lat, lon); ds_obs_mean: (lat, lon); grd_weight: 展平后的纬度权重。
    返回归一化后的直方图数组，长度为 num_members + 1。
    """
    forecast = stacked_data.values
    obs = ds_obs_mean.values
    num_members = forecast.shape[0]
    lat_size, lon_size = obs.shape
    histogram = np.zeros(num_members + 1)
    forecast_flat = forecast.reshape(num_members, -1)
    obs_flat = obs.flatten()
    forecast_sorted = np.sort(forecast_flat, axis=0)
    bin_indices = np.sum(obs_flat > forecast_sorted, axis=0)
    for i, idx in enumerate(bin_indices):
        histogram[idx] += 1.0 * float(grd_weight[i])
    histogram /= lat_size * lon_size
    histogram /= histogram.sum()
    return histogram


def calc_talagrand_histogram(para):
    """
    按纬度带与选定时效计算 Talagrand 直方图，对每时效每纬度带汇总各起报时间后写 txt 并出图。
    参数 para 与 para_example 一致。

    参数 para 至少包含：
    - "input_dir":  预报成员所在目录
    - "output_dir":  输出 txt/png 的根目录
    - "obs_path":  观测文件目录
    - "begin_times":  列表，每段起报开始 "YYYYMMDDHH"
    - "end_times":  列表，与 begin_times 等长
    - "time_step":  起报时间步长（小时）
    - "dtimes":  预报时效 [起, 止, 步长]，用于生成要计算的预报时效列表
    - "lat_lon_ranges":  区域字典
    """
    if not _METAVA_AVAILABLE:
        raise ImportError("calc_talagrand_histogram 需要安装 meteva，请先安装后使用。")

    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")
    obs_path = para["obs_path"].rstrip("/\\")

    begin_list = para["begin_times"]
    end_list = para["end_times"]
    time_step = para["time_step"]
    timestr_list = []
    for i in range(len(begin_list)):
        timestr_list.extend(
            _generate_timestr_from_range(begin_list[i], end_list[i], time_step)
        )

    dtimes = para["dtimes"]
    forecast_hours_list = list(range(dtimes[0], dtimes[1], dtimes[2]))

    if "lat_lon_ranges" in para:
        lat_lon_ranges = para["lat_lon_ranges"]
    else:
        lat = para.get("latitude", {"lat_mins": [-90], "lat_maxs": [90]})
        lon = para.get("longitude", {"lon_min": 0, "lon_max": 359.75})
        lat_lon_ranges = {
            "default": {
                "lat_min": lat["lat_mins"][0] if isinstance(lat["lat_mins"], list) else lat["lat_mins"],
                "lat_max": lat["lat_maxs"][0] if isinstance(lat["lat_maxs"], list) else lat["lat_maxs"],
                "lon_min": lon["lon_min"],
                "lon_max": lon["lon_max"],
            }
        }


    n_regions = len(lat_lon_ranges)
    n_hours = len(forecast_hours_list)
    print(f"【Talagrand 直方图】开始计算，共 {n_regions} 个区域、{n_hours} 个预报时效、{len(timestr_list)} 个起报时间")

    for region, values in lat_lon_ranges.items():
        lat_min = values["lat_min"]
        lat_max = values["lat_max"]
        lon_min = values["lon_min"]
        lon_max = values["lon_max"]

        for forecast_hour in forecast_hours_list:
            forecast_str = f"{forecast_hour:03d}"
            all_histograms = []

            for start_time in timestr_list:
                print(f"  区域 {region} 时效 {forecast_str}h 起报 {start_time}")
                start_datetime = datetime.strptime(start_time, "%Y%m%d%H")
                output_dir = os.path.join(output_path, "histogram", start_time)
                os.makedirs(output_dir, exist_ok=True)

                member_files = sorted(
                    glob(os.path.join(input_path, start_time, f"p_*_{start_time}.{forecast_str}.nc"))
                )
                if not member_files:
                    continue

                datasets = [xr.open_dataset(f) for f in member_files]
                stacked_data = xr.concat(datasets, dim="member").sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )

                lats = stacked_data["lat"].values
                lons = stacked_data["lon"].values
                yy = np.meshgrid(lons, lats)[1]
                weight = np.cos(yy * math.pi / 180)
                grid0 = meteva.base.get_grid_of_data(stacked_data)
                grid0_ = meteva.base.grid(grid0.glon, grid0.glat)
                grd_weight = meteva.base.grid_data(grid0_, weight)
                grd_weight_reshape = grd_weight.values.squeeze().reshape(-1)

                obs_datetime = start_datetime + timedelta(hours=forecast_hour)
                obs_time = obs_datetime.strftime("%Y%m%d%H")
                obs_file = os.path.join(obs_path, f"obs_{obs_time}.000.nc")
                ds_obs_mean = (
                    meteva.base.read_griddata_from_nc(obs_file)
                    .to_dataset()
                    .mean(dim="member")
                    .sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
                )

                forecast_data = stacked_data["Z"].isel(level=0, time=0, dtime=0)
                obs_var = _get_obs_data_var_name(ds_obs_mean)
                obs_data = ds_obs_mean.isel(level=0, time=0, dtime=0)[obs_var]
                histogram_result = _compute_histogram_2d(
                    forecast_data, obs_data, grd_weight_reshape
                )
                all_histograms.append({
                    "start_time": start_time,
                    "forecast_hour": forecast_str,
                    "lat_range": f"lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}",
                    "histogram": histogram_result,
                })

                for ds in datasets:
                    ds.close()

            if not all_histograms:
                continue

            out_file = os.path.join(
                output_dir,
                f"p_histogram_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.txt",
            )
            out_png = os.path.join(
                output_dir,
                f"p_histogram_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.png",
            )
            with open(out_file, "w") as f:
                for record in all_histograms:
                    f.write(
                        f"Start_time: {record['start_time']}, "
                        f"Forecast_hour: {record['forecast_hour']}, "
                        f"Lat_range: {record['lat_range']}, "
                        f"Histogram: {record['histogram'].tolist()}\n"
                    )

            grouped_data = defaultdict(lambda: {"sum_histogram": None, "count": 0})
            lat_range_str = f"lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}"
            for record in all_histograms:
                group_key = (record["forecast_hour"], record["lat_range"])
                histogram = np.array(record["histogram"])
                if grouped_data[group_key]["sum_histogram"] is None:
                    grouped_data[group_key]["sum_histogram"] = histogram.copy()
                else:
                    grouped_data[group_key]["sum_histogram"] += histogram
                grouped_data[group_key]["count"] += 1

            group_key = (forecast_str, lat_range_str)
            avg_histogram = grouped_data[group_key]["sum_histogram"] / grouped_data[group_key]["count"]

            if _MATPLOTLIB_AVAILABLE:
                plt.figure(figsize=(10, 6))
                plt.bar(range(len(avg_histogram)), avg_histogram, color="orange", edgecolor="black")
                plt.xlabel("Bins")
                plt.ylabel("Frequency")
                plt.title("Talagrand Histogram (Ensemble Forecast Verification)")
                plt.grid(axis="y", linestyle="--", alpha=0.7)
                plt.xticks(range(len(avg_histogram)))
                plt.savefig(out_png, format="png")
                plt.close()

    print("【Talagrand 直方图】计算完成")


def _adjust_forecast_time_for_sdrms(forecast):
    """调整 forecast 的时间：time = time + dtime，并将 dtime 设为 0。"""
    if forecast["dtime"].size == 1:
        adjusted_time = forecast["time"] + np.timedelta64(int(forecast["dtime"].values), "h")
        forecast = forecast.assign_coords(time=adjusted_time).assign_coords(dtime=0)
    else:
        raise ValueError("dtime 必须是标量，或需要调整代码以处理非标量情况。")
    return forecast


def _compute_spread_rmse_ratio_chunk(forecast, obs, num_chunks=4):
    """
    计算 (Spread/RMSE - 1) 的空间分布，支持按经纬度分块计算。

    forecast: xarray.DataArray，至少包含 ["time", "member", "lat", "lon"] 维度。
    obs: xarray.DataArray，至少包含 ["time", "lat", "lon"] 维度。
    """
    forecast = _adjust_forecast_time_for_sdrms(forecast)
    forecast_aligned = forecast.sel(time=obs["time"], method="nearest")

    lat_size = forecast_aligned.sizes["lat"]
    lon_size = forecast_aligned.sizes["lon"]

    lat_step = lat_size // num_chunks
    lon_step = lon_size // num_chunks

    result = np.full((lat_size, lon_size), np.nan)

    for lat_idx in range(num_chunks):
        for lon_idx in range(num_chunks):
            lat_start = lat_idx * lat_step
            lat_end = lat_size if lat_idx == num_chunks - 1 else (lat_idx + 1) * lat_step
            lon_start = lon_idx * lon_step
            lon_end = lon_size if lon_idx == num_chunks - 1 else (lon_idx + 1) * lon_step

            forecast_chunk = forecast_aligned.isel(lat=slice(lat_start, lat_end), lon=slice(lon_start, lon_end))
            obs_chunk = obs.isel(lat=slice(lat_start, lat_end), lon=slice(lon_start, lon_end))

            ensemble_mean = forecast_chunk.mean(dim="member")
            M = forecast_chunk.sizes["member"]
            N = forecast_chunk.sizes["time"]

            squared_diffs = (forecast_chunk - ensemble_mean) ** 2
            spread = np.sqrt(squared_diffs.sum(dim="member").sum(dim="time") / (N * (M - 1)))

            squared_errors = (ensemble_mean - obs_chunk) ** 2
            rmse = np.sqrt(squared_errors.mean(dim="time"))

            epsilon = 1e-8
            spread_rmse_ratio_chunk = (spread / (rmse + epsilon)) - 1

            result[lat_start:lat_end, lon_start:lon_end] = spread_rmse_ratio_chunk.values

    result_da = xr.DataArray(
        result,
        dims=["lat", "lon"],
        coords={"lat": forecast_aligned["lat"], "lon": forecast_aligned["lon"]},
    )
    return result_da


def calc_sdrms_ratio(para):
    """
    按预报时效聚合多起报时间后，计算 (Spread/RMSE - 1) 的空间分布，并输出 nc 与填色图。
    主要用于 DJF 等季节内集合离散度与误差关系诊断。

    参数 para（与 para_example 一致）至少包含：
    - "input_dir":  预报成员所在目录
    - "output_dir":  结果 nc/png 输出目录
    - "obs_path":  观测文件目录
    - "begin_times":  列表，每段起报开始 "YYYYMMDDHH"
    - "end_times":  列表，与 begin_times 等长
    - "time_step":  起报时间步长（小时）
    - "lat_lon_ranges":  区域字典（若缺省则用 latitude/longitude 构造全球）
    - "dtimes":  预报时效 [起, 止, 步长]，用于生成要计算的预报时效列表
    """
    if not _METAVA_AVAILABLE:
        raise ImportError("calc_sdrms_ratio 需要安装 meteva，请先安装后使用。")

    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")
    obs_path = para["obs_path"].rstrip("/\\")

    begin_list = para["begin_times"]
    end_list = para["end_times"]
    time_step = para["time_step"]
    timestr_list = []
    for i in range(len(begin_list)):
        timestr_list.extend(
            _generate_timestr_from_range(begin_list[i], end_list[i], time_step)
        )

    if "lat_lon_ranges" in para:
        lat_lon_ranges = para["lat_lon_ranges"]
    else:
        lat = para.get("latitude", {"lat_mins": [-90], "lat_maxs": [90]})
        lon = para.get("longitude", {"lon_min": 0, "lon_max": 359.75})
        lat_lon_ranges = {
            "default": {
                "lat_min": lat["lat_mins"][0] if isinstance(lat["lat_mins"], list) else lat["lat_mins"],
                "lat_max": lat["lat_maxs"][0] if isinstance(lat["lat_maxs"], list) else lat["lat_maxs"],
                "lon_min": lon["lon_min"],
                "lon_max": lon["lon_max"],
            }
        }

    dtimes = para["dtimes"]
    forecast_hours_list = list(range(dtimes[0], dtimes[1], dtimes[2]))

    n_regions = len(lat_lon_ranges)
    n_hours = len(forecast_hours_list)
    print(f"【Spread/RMSE 空间分布】开始计算，共 {n_regions} 个区域、{n_hours} 个预报时效、{len(timestr_list)} 个起报时间")

    for region, values in lat_lon_ranges.items():
        lat_min = values["lat_min"]
        lat_max = values["lat_max"]
        lon_min = values["lon_min"]
        lon_max = values["lon_max"]

        for forecast_hour in forecast_hours_list:
            forecast_str = f"{forecast_hour:03d}"
            print(f"  区域 {region} 预报时效 {forecast_hour}h")

            forecasts = None
            obss = None

            for start_time in timestr_list:
                start_datetime = datetime.strptime(start_time, "%Y%m%d%H")

                output_dir = os.path.join(output_path, "sdrms_ratio", start_time)
                os.makedirs(output_dir, exist_ok=True)

                member_files = sorted(
                    glob(os.path.join(input_path, start_time, f"p_*_{start_time}.{forecast_str}.nc"))
                )
                if not member_files:
                    continue

                datasets = [xr.open_dataset(f) for f in member_files]
                stacked_data = xr.concat(datasets, dim="member").sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )

                obs_datetime = start_datetime + timedelta(hours=forecast_hour)
                obs_time = obs_datetime.strftime("%Y%m%d%H")
                obs_file = os.path.join(obs_path, f"obs_{obs_time}.000.nc")
                ds_obs_mean = (
                    meteva.base.read_griddata_from_nc(obs_file)
                    .to_dataset()
                    .mean(dim="member")
                    .sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
                )

                if forecasts is None:
                    forecasts = stacked_data
                else:
                    forecasts = xr.concat([forecasts, stacked_data], dim="time")

                if obss is None:
                    obss = ds_obs_mean
                else:
                    obss = xr.concat([obss, ds_obs_mean], dim="time")

                for ds in datasets:
                    ds.close()

            if forecasts is None or obss is None:
                continue

            forecast_data = forecasts["Z"].isel(level=0, dtime=0)
            obs_var = _get_obs_data_var_name(obss)
            obs_data = obss.isel(level=0, dtime=0)[obs_var]

            spread_rmse_ratio = _compute_spread_rmse_ratio_chunk(forecast_data, obs_data)

            out_file = os.path.join(
                output_dir,
                f"p_sdrms_ratio_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.nc",
            )
            out_png = os.path.join(
                output_dir,
                f"p_sdrms_ratio_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.png",
            )
            spread_rmse_ratio.to_netcdf(out_file)

            if _MATPLOTLIB_AVAILABLE and _CARTOPY_AVAILABLE:
                colors = [
                    "#FFFFFF",
                    "#D8ECFF",
                    "#A9C8FF",
                    "#6582FF",
                    "#1E4FFF",
                    "#00B800",
                    "#69FF00",
                    "#FFFF00",
                    "#FFB400",
                    "#FF6A00",
                    "#FF0000",
                ]
                levels = [-1, -0.8, -0.6, -0.4, -0.2, 0, 0.2, 0.4, 0.6, 0.8, 1]

                cmap = ListedColormap(colors)
                norm = BoundaryNorm(levels, cmap.N)

                plt.figure(figsize=(12, 6))
                ax = plt.axes(projection=ccrs.PlateCarree())
                spread_rmse_ratio.plot(
                    ax=ax,
                    cmap=cmap,
                    norm=norm,
                    cbar_kwargs={"label": "Ratio", "ticks": levels, "extend": "both"},
                    transform=ccrs.PlateCarree(),
                )
                ax.coastlines()
                ax.add_feature(cfeature.LAND, color="lightgray")
                ax.gridlines(draw_labels=True, linestyle="--", alpha=0.5)
                ax.set_title("Spread/RMSE -1 Spatial Distribution")
                plt.savefig(out_png, format="png")
                plt.close()

            print(f"    时效 {forecast_str}h -> 已写出 nc 与填色图")

    print("【Spread/RMSE 空间分布】计算完成")


def _compute_histogram_grid_2d(stacked_data, ds_obs_mean):
    """
    计算 2D Talagrand Histogram（集合秩直方图），对每个网格点 (lat, lon) 独立计算。

    stacked_data: (member, time, lat, lon)
    ds_obs_mean: (time, lat, lon)
    返回: (n_members+1, lat, lon)
    """
    n_members = stacked_data.shape[0]
    n_times = stacked_data.shape[1]
    n_lat = stacked_data.shape[2]
    n_lon = stacked_data.shape[3]

    obs_expanded = ds_obs_mean.expand_dims(member=1, axis=0)  # (1, time, lat, lon)

    n_bins = n_members + 1
    histogram = np.zeros((n_bins, n_lat, n_lon), dtype=int)

    ensemble_values = stacked_data.values  # (n_members, n_times, n_lat, n_lon)
    obs_values = obs_expanded.values  # (1, n_times, n_lat, n_lon)

    for ilat in range(n_lat):
        for ilon in range(n_lon):
            forecast_values = ensemble_values[:, :, ilat, ilon]  # (n_members, n_times)
            obs_point = obs_values[:, :, ilat, ilon]  # (1, n_times)
            for t in range(n_times):
                obs_single = obs_point[0, t]
                bin_idx = np.sum(forecast_values[:, t] < obs_single)
                histogram[bin_idx, ilat, ilon] += 1

    return histogram


def calc_outliers_diff(para):
    """
    基于 Talagrand 直方图计算空间上“异常点”比例的偏差（outliers diff）：
    每个格点统计观测落在首尾两 bin 的比例与均匀分布期望值之差，输出 npy 与填色图。

    参数 para 至少包含：
    - "input_dir":  预报成员所在目录
    - "output_dir":  结果 npy/png 输出目录
    - "obs_path":  观测目录
    - "begin_times":  列表，每段起报开始 "YYYYMMDDHH"
    - "end_times":  列表，与 begin_times 等长
    - "time_step":  起报时间步长（小时）
    - "lat_lon_ranges":  区域字典（若缺省则用 latitude/longitude 构造全球）
    - "dtimes":  预报时效 [起, 止, 步长]，用于生成要计算的预报时效列表
    """
    if not _METAVA_AVAILABLE:
        raise ImportError("calc_outliers_diff 需要安装 meteva，请先安装后使用。")

    input_path = para["input_dir"].rstrip("/\\")
    output_path = para["output_dir"].rstrip("/\\")
    obs_path = para["obs_path"].rstrip("/\\")

    begin_list = para["begin_times"]
    end_list = para["end_times"]
    time_step = para["time_step"]
    timestr_list = []
    for i in range(len(begin_list)):
        timestr_list.extend(
            _generate_timestr_from_range(begin_list[i], end_list[i], time_step)
        )

    if "lat_lon_ranges" in para:
        lat_lon_ranges = para["lat_lon_ranges"]
    else:
        lat = para.get("latitude", {"lat_mins": [-90], "lat_maxs": [90]})
        lon = para.get("longitude", {"lon_min": 0, "lon_max": 359.75})
        lat_lon_ranges = {
            "default": {
                "lat_min": lat["lat_mins"][0] if isinstance(lat["lat_mins"], list) else lat["lat_mins"],
                "lat_max": lat["lat_maxs"][0] if isinstance(lat["lat_maxs"], list) else lat["lat_maxs"],
                "lon_min": lon["lon_min"],
                "lon_max": lon["lon_max"],
            }
        }

    dtimes = para["dtimes"]
    forecast_hours_list = list(range(dtimes[0], dtimes[1], dtimes[2]))

    n_regions = len(lat_lon_ranges)
    n_hours = len(forecast_hours_list)
    print(f"【异常点比例偏差】开始计算，共 {n_regions} 个区域、{n_hours} 个预报时效、{len(timestr_list)} 个起报时间")

    for region, values in lat_lon_ranges.items():
        lat_min = values["lat_min"]
        lat_max = values["lat_max"]
        lon_min = values["lon_min"]
        lon_max = values["lon_max"]

        for forecast_hour in forecast_hours_list:
            forecast_str = f"{forecast_hour:03d}"
            print(f"  区域 {region} 预报时效 {forecast_hour}h")

            forecasts = None
            obss = None

            for start_time in timestr_list:
                start_datetime = datetime.strptime(start_time, "%Y%m%d%H")

                output_dir = os.path.join(output_path, "outliers_diff", start_time)
                os.makedirs(output_dir, exist_ok=True)

                member_files = sorted(
                    glob(os.path.join(input_path, start_time, f"p_*_{start_time}.{forecast_str}.nc"))
                )
                if not member_files:
                    continue

                datasets = [xr.open_dataset(f) for f in member_files]
                stacked_data = xr.concat(datasets, dim="member").sel(
                    lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max)
                )

                obs_datetime = start_datetime + timedelta(hours=forecast_hour)
                obs_time = obs_datetime.strftime("%Y%m%d%H")
                obs_file = os.path.join(obs_path, f"obs_{obs_time}.000.nc")
                ds_obs_mean = (
                    meteva.base.read_griddata_from_nc(obs_file)
                    .to_dataset()
                    .mean(dim="member")
                    .sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
                )

                if forecasts is None:
                    forecasts = stacked_data
                else:
                    forecasts = xr.concat([forecasts, stacked_data], dim="time")

                if obss is None:
                    obss = ds_obs_mean
                else:
                    obss = xr.concat([obss, ds_obs_mean], dim="time")

                for ds in datasets:
                    ds.close()

            if forecasts is None or obss is None:
                continue

            forecast_data = forecasts["Z"].isel(level=0, dtime=0)  # (member, time, lat, lon)
            obs_var = _get_obs_data_var_name(obss)
            obs_data = obss.isel(level=0, dtime=0)[obs_var]  # (time, lat, lon)

            histogram = _compute_histogram_grid_2d(forecast_data, obs_data)

            out_file = os.path.join(
                output_dir,
                f"p_histogram_grid_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.npy",
            )
            np.save(out_file, histogram)

            # 异常比例：首尾两 bin 占比与均匀期望(2/(n_members+1))的差，这里沿用原脚本中的常数 3.125*2
            outliers_data = (histogram[0, :, :] + histogram[-1, :, :]) - 3.125 * 2

            outliers = stacked_data["Z"].isel(member=0, level=0, time=0, dtime=0).copy()
            outliers.data = outliers_data / 100.0

            out_png = os.path.join(
                output_dir,
                f"p_outliers_diff_{start_time}.{forecast_hour:03d}_lat{lat_min}-{lat_max}_lon{lon_min}-{lon_max}.png",
            )

            if _MATPLOTLIB_AVAILABLE and _CARTOPY_AVAILABLE:
                colors = [
                    "#FFFFFF",
                    "#D8ECFF",
                    "#A9C8FF",
                    "#6582FF",
                    "#1E4FFF",
                    "#00B800",
                    "#69FF00",
                    "#FFFF00",
                    "#FFB400",
                    "#FF6A00",
                    "#FF0000",
                ]
                levels = [-1, -0.8, -0.6, -0.4, -0.2, 0, 0.2, 0.4, 0.6, 0.8, 1]

                cmap = ListedColormap(colors)
                norm = BoundaryNorm(levels, cmap.N)

                plt.figure(figsize=(12, 8))
                ax = plt.axes(projection=ccrs.PlateCarree())
                outliers.plot(
                    ax=ax,
                    cmap=cmap,
                    norm=norm,
                    cbar_kwargs={"label": "Ratio", "ticks": levels, "extend": "both"},
                    transform=ccrs.PlateCarree(),
                )
                ax.coastlines()
                ax.add_feature(cfeature.LAND, color="lightgray")
                ax.gridlines(draw_labels=True, linestyle="--", alpha=0.5)
                ax.set_title("Diff in % of Outliers from Uniform")
                plt.savefig(out_png, format="png")
                plt.close()

            print(f"    时效 {forecast_str}h -> 已写出 npy 与填色图")

    print("【异常点比例偏差】计算完成")


if __name__ == "__main__":
    para_example = {
        "input_dir": "J:/pangu_ens_rerun/Z/500/",
        "output_dir": "J:/pangu_ens_rerun_deal/Z/500/",
        "obs_path": "J:/pangu_ens_obs/Z/500/",
        "begin_times": ["2024101012"],  # 列表格式,每一段起报时间的开始时间
        "end_times": ["2024101012"],  # 列表格式, 每一段起报时间的结束时间,  与 begin_times 长度相同
        "time_step": 12,  # 起报时间间隔（小时）
        "dtimes": [24, 721, 24],  # 预报时效 [起始, 结束, 步长]（小时）
        "n_members": 4,
        "lat_lon_ranges": {
            "NH": {"lat_min": 20, "lat_max": 80, "lon_min": 0, "lon_max": 359.75},
            "TR": {"lat_min": -20, "lat_max": 20, "lon_min": 0, "lon_max": 359.75},
            "SH": {"lat_min": -80, "lat_max": -20, "lon_min": 0, "lon_max": 359.75},
        },
        "points": {
            "lat": [-45.0, 45.0],
            "lon": [110.0, 110.0]
        },
        "grid_reso": 0.25,  # 网格分辨率（度）
    }
    calc_ensemble_mean_field(para_example)
    calc_acc_middle_result(para_example)
    calc_rmse_spread_by_latband(para_example)
    calc_talagrand_histogram(para_example)
    calc_sdrms_ratio(para_example)
    calc_outliers_diff(para_example)
    generate_points_to_compare(para_example)
