1.EF因子
RWEQ 将 EF 定义为:表层约 25 mm 土壤中,粒径小于 0.84 mm 的部分所占比例。它需要通过标准旋转筛分测定。
例如:
EF=0.60 EF=0.60 EF=0.60
意味着表层土样中约 60% 的质量由小于 0.84 mm 的颗粒或团聚体组成。EF 越大,说明地表可被风起动、跳跃和搬运的细小物质越多,潜在风蚀性通常越强。
实际应用中,最理想的方法不是只测一次,而是在多年中定期采集表层土样、筛分,并关注一年内较高的 EF 状态。原因很简单:风蚀往往发生在地表最脆弱的时期,而不是土壤最稳定的时期。
当没有筛分数据时,RWEQ 用土壤基本性质估算 EF:
EF=29.09+0.31Sa+0.17Si+0.33(Sa/Cl)−2.59OM−0.95CaCO3100 EF= \frac{ 29.09+0.31Sa+0.17Si+0.33(Sa/Cl)-2.59OM-0.95CaCO_3 }{100} EF=10029.09+0.31Sa+0.17Si+0.33(Sa/Cl)−2.59OM−0.95CaCO3
其中:
Sa:砂粒含量(%);
Si:粉粒含量(%);
Cl:黏粒含量(%);
OM:有机质含量(%);
CaCO_3:碳酸钙含量(%)。
从符号方向可以快速理解这个经验式:
-
砂粒 (Sa) 增加,EF 倾向增加;
-
粉粒 (Si) 增加,EF 倾向增加;
-
砂/黏比 (Sa/Cl) 增加,EF 倾向增加;
-
有机质 (OM) 增加,EF 倾向降低;
-
碳酸钙 (CaCO_3) 增加,EF 倾向降低。
这背后的土壤学直觉是:较多砂粒、粉粒或较高砂/黏比,往往意味着更多易松散、易移动的颗粒;而有机质有助于团聚体形成和稳定,碳酸钙也可能带来一定胶结或结构稳定作用,因此会降低模型估计的可蚀比例。
但要注意:这不是一个"由质地决定一切"的公式。它是用土壤性质去预测筛分 EF 的经验关系,文档报告其 r2=0.67r^2=0.67r2=0.67。因此,若已经有本地表层土的筛分结果,应优先使用实测 EF;若输入值超出原始建模数据范围,外推结果更应谨慎解释。
2.SCF因子
SCF 的全称是 Soil Crust Factor,即土壤结皮因子。它描述的是:当雨滴打击地表、重新分配颗粒并形成结皮后,土壤团聚体抵抗风沙磨蚀的能力如何变化。在RWEQ中,当自上次耕作以来累积降雨量等于或超过12毫米时,计算土壤地壳因子。当粘土含量低于5%或耕作后无表壳时,SCF设为1。
利用Hagen等人(1992)的磨料系数数据库开发SCF的经验系数。其的估算式为:
SCF=11+0.0066(Cl)2+0.021(OM)2 SCF= \frac{1} {1+0.0066(Cl)^2+0.021(OM)^2} SCF=1+0.0066(Cl)2+0.021(OM)21
可以立刻看出:
0<SCF≤1 0<SCF\leq1 0<SCF≤1
且黏粒 (Cl) 或有机质 (OM) 增加时,分母增大,SCF 减小。
在 RWEQ 的计算中,SCF 是乘到风蚀潜力上的因子。因而:
SCF≈1SCF\approx1SCF≈1:结皮造成的抑制较弱;
SCFSCFSCF 较小:模型认为该结皮表面的抗磨蚀作用较强,对风蚀潜力的削弱更明显。
二、因子计算原理
1. 有机碳转换为有机质
HWSD 输入为有机碳 OC,而公式要求有机质 OM:
text
OM = 1.724 × OC
OM、OC 均以百分数表示。转换结果单独保存为 OM_from_OC_HWSD.tif,便于检查和复用。
2. 土壤可蚀因子 EF
text
EF = (29.09 + 0.31×Sa + 0.17×Si + 0.33×Sa/Cl
- 2.59×OM - 0.95×CaCO3) / 100
参考相关文献,本次计算未考虑碳酸钙,令 CaCO3=0。因此输出文件名明确标记为 EF_HWSD_CaCO3_ignored.tif,避免以后误认为已经包含碳酸钙项。
公式产生的负 EF 没有物理意义,程序按文献报告的值域将其截为 0;同时设置上限为 1。
3.其他的考虑
有研究表明,美国制的土壤粒度和中国制的有所差别,因而要进行调整。研究基于HWSD数据发现土壤有机质和其范围的差别比较大,因而对其限制在4.7%范围内(研究也分析了soilgrid数据也发现有诸如此类的问题)。
三、代码实现
1. 最终口径
本版本根据土壤变量含义和RWEQ经验公式适用范围作如下处理:
- Sa、Si、Cl:直接使用HWSD v1.2表层0--30 cm质量百分数;
- OC:保留HWSD原始值;
- 原始OM:
OM_raw = 1.724 × OC,用于数据审计; - 模型OM:
OM_model = min(OM_raw, 4.79); - CaCO3:直接使用HWSD原始质量百分数0--29.5%,不截断;
- 基准EF:使用OM_model,不包含CaCO3项;
- 敏感性EF:使用OM_model和完整原始CaCO3;
- 推荐SCF:使用OM_model。
2. 推荐结果
rasters/EF_baseline_OMcap4p79.tif:推荐基准EF;rasters/EF_sensitivity_CaCO3raw.tif:加入未截断CaCO3的敏感性EF;rasters/SCF_OMcap4p79.tif:推荐SCF;rasters/OM_0_4p79pct.tif:推荐RWEQ模型OM输入;rasters/CaCO3_raw_pct.tif:未截断CaCO3输入。
EF_baseline_rawOM.tif、SCF_rawOM.tif和OM_raw_pct.tif用于复现与审计,不建议作为最终RWEQ输入。
3. 修订依据
HWSD的T_OC和T_CACO3均为表层0--30 cm质量百分数。高OM主要来自Histosols等有机土,并非单位错误,但超过了RWEQ经验方程常用的OM标定范围。本版本按用户确认将OM最大值限制为4.79%。
HWSD CaCO3的0--29.5%范围与钙质土、盐土等土壤类型相符,且接近文献中RWEQ方程使用过的0--25.2%范围。本版本依据用户判断保留HWSD完整CaCO3范围,不做截断;29.5%附近属于轻度超出常见标定范围的外推,已在限制部分注明。
4. 公式
text
OM_raw = 1.724 × OC
OM_model = min(OM_raw, 4.79)
EF_baseline = (29.09 + 0.31×Sa + 0.17×Si + 0.33×Sa/Cl
- 2.59×OM_model) / 100
EF_sensitivity = (29.09 + 0.31×Sa + 0.17×Si + 0.33×Sa/Cl
- 2.59×OM_model - 0.95×CaCO3_raw) / 100
SCF = 1 / (1 + 0.0066×Cl² + 0.021×OM_model²)
python
# -*- coding: utf-8 -*-
"""Create auditable HWSD inputs and RWEQ EF/SCF products for China."""
from __future__ import annotations
import csv
import json
import logging
from pathlib import Path
import arcpy
from arcpy.sa import Con, Raster
ROOT = Path(r"D:\RWEQ_china\data\RWEQ_EF_SCF_OMcap4p79_CaCO3raw_20260905")
RASTERS = ROOT / "rasters"
TABLES = ROOT / "tables"
LOGS = ROOT / "logs"
SOURCE = Path(r"F:\geodata\15土壤数据\HWSD")
INPUTS = {
"Sa_pct": SOURCE / "HWSD_china_sand.tif",
"Si_pct": SOURCE / "HWSD_china_slit.tif",
"Cl_pct": SOURCE / "HWSD_china_clay.tif",
"OC_pct": SOURCE / "HWSD_china_oc.tif",
"CaCO3_raw_pct": SOURCE / "HWSD_china_CaCO3.tif",
}
OC_TO_OM = 1.724
OM_CAP_PCT = 4.79
def setup_logging() -> None:
"""Configure UTF-8 file and console logging."""
LOGS.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler(LOGS / "run.log", encoding="utf-8", mode="w"),
logging.StreamHandler(),
],
)
def validate_grids(paths: list[Path]) -> None:
"""Assert that all source rasters share one grid and spatial reference."""
for path in paths:
if not arcpy.Exists(str(path)):
raise FileNotFoundError(f"Missing source raster: {path}")
ref = arcpy.Describe(str(paths[0]))
for path in paths[1:]:
desc = arcpy.Describe(str(path))
checks = (
desc.width == ref.width,
desc.height == ref.height,
abs(desc.meanCellWidth - ref.meanCellWidth) < 1e-12,
abs(desc.meanCellHeight - ref.meanCellHeight) < 1e-12,
desc.spatialReference.factoryCode == ref.spatialReference.factoryCode,
abs(desc.extent.XMin - ref.extent.XMin) < 1e-10,
abs(desc.extent.YMin - ref.extent.YMin) < 1e-10,
)
if not all(checks):
raise ValueError(f"Grid mismatch: {path}")
def save_raster(raster: Raster, path: Path) -> None:
"""Save a compressed GeoTIFF and calculate exact statistics."""
raster.save(str(path))
arcpy.management.CalculateStatistics(str(path), 1, 1, "", "OVERWRITE")
logging.info("Saved %s", path)
def copy_input(source: Path, target: Path) -> None:
"""Copy a source raster into the self-contained calculation folder."""
arcpy.management.CopyRaster(
str(source), str(target), nodata_value="", pixel_type="", format="TIFF"
)
arcpy.management.CalculateStatistics(str(target), 1, 1, "", "OVERWRITE")
logging.info("Copied %s", target)
def property_value(path: Path, prop: str) -> float:
"""Read a numeric ArcGIS raster property."""
return float(arcpy.management.GetRasterProperties(str(path), prop).getOutput(0))
def raster_stats(path: Path) -> dict[str, object]:
"""Return exact descriptive statistics and grid metadata."""
desc = arcpy.Describe(str(path))
return {
"raster": path.name,
"path": str(path),
"minimum": property_value(path, "MINIMUM"),
"maximum": property_value(path, "MAXIMUM"),
"mean": property_value(path, "MEAN"),
"std": property_value(path, "STD"),
"rows": desc.height,
"columns": desc.width,
"cell_width_deg": desc.meanCellWidth,
"cell_height_deg": desc.meanCellHeight,
"crs": desc.spatialReference.name,
}
def main() -> None:
"""Run the complete, idempotent EF/SCF production workflow."""
for directory in (ROOT, RASTERS, TABLES, LOGS):
directory.mkdir(parents=True, exist_ok=True)
setup_logging()
validate_grids(list(INPUTS.values()))
arcpy.env.overwriteOutput = True
arcpy.env.compression = "LZW"
arcpy.env.parallelProcessingFactor = "100%"
arcpy.env.snapRaster = str(INPUTS["Sa_pct"])
arcpy.env.cellSize = str(INPUTS["Sa_pct"])
arcpy.env.extent = str(INPUTS["Sa_pct"])
arcpy.env.outputCoordinateSystem = arcpy.Describe(
str(INPUTS["Sa_pct"])
).spatialReference
arcpy.CheckOutExtension("Spatial")
try:
copied: dict[str, Path] = {}
for name, source in INPUTS.items():
target = RASTERS / f"{name}.tif"
copy_input(source, target)
copied[name] = target
sa = Raster(str(copied["Sa_pct"]))
si = Raster(str(copied["Si_pct"]))
cl = Raster(str(copied["Cl_pct"]))
oc = Raster(str(copied["OC_pct"]))
caco3_raw = Raster(str(copied["CaCO3_raw_pct"]))
products: dict[str, Path] = dict(copied)
om_raw = oc * OC_TO_OM
products["OM_raw_pct"] = RASTERS / "OM_raw_pct.tif"
save_raster(om_raw, products["OM_raw_pct"])
om_model = Con(om_raw > OM_CAP_PCT, OM_CAP_PCT, om_raw)
products["OM_0_4p79pct"] = RASTERS / "OM_0_4p79pct.tif"
save_raster(om_model, products["OM_0_4p79pct"])
ef_base_raw = (
29.09 + 0.31 * sa + 0.17 * si + 0.33 * (sa / cl) - 2.59 * om_raw
) / 100.0
ef_base_raw = Con(ef_base_raw < 0, 0, Con(ef_base_raw > 1, 1, ef_base_raw))
products["EF_baseline_rawOM"] = RASTERS / "EF_baseline_rawOM.tif"
save_raster(ef_base_raw, products["EF_baseline_rawOM"])
ef_base = (
29.09 + 0.31 * sa + 0.17 * si + 0.33 * (sa / cl) - 2.59 * om_model
) / 100.0
ef_base = Con(ef_base < 0, 0, Con(ef_base > 1, 1, ef_base))
products["EF_baseline_OMcap4p79"] = (
RASTERS / "EF_baseline_OMcap4p79.tif"
)
save_raster(ef_base, products["EF_baseline_OMcap4p79"])
ef_sensitivity = (
29.09
+ 0.31 * sa
+ 0.17 * si
+ 0.33 * (sa / cl)
- 2.59 * om_model
- 0.95 * caco3_raw
) / 100.0
ef_sensitivity = Con(
ef_sensitivity < 0, 0, Con(ef_sensitivity > 1, 1, ef_sensitivity)
)
products["EF_sensitivity_CaCO3raw"] = (
RASTERS / "EF_sensitivity_CaCO3raw.tif"
)
save_raster(ef_sensitivity, products["EF_sensitivity_CaCO3raw"])
scf_raw = 1.0 / (1.0 + 0.0066 * (cl**2) + 0.021 * (om_raw**2))
products["SCF_rawOM"] = RASTERS / "SCF_rawOM.tif"
save_raster(scf_raw, products["SCF_rawOM"])
scf = 1.0 / (1.0 + 0.0066 * (cl**2) + 0.021 * (om_model**2))
products["SCF_OMcap4p79"] = RASTERS / "SCF_OMcap4p79.tif"
save_raster(scf, products["SCF_OMcap4p79"])
stats = [raster_stats(path) for path in products.values()]
csv_path = TABLES / "all_raster_statistics.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(stats[0]))
writer.writeheader()
writer.writerows(stats)
(TABLES / "all_raster_statistics.json").write_text(
json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8"
)
logging.info("Statistics written to %s", csv_path)
finally:
arcpy.CheckInExtension("Spatial")
if __name__ == "__main__":
main()